SHA256
Compare commits
2
Commits
3552e05e4c
...
67d63256c2
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
67d63256c2 | ||
|
|
ee185cf20a |
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -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 — лучше добавить новые значения,
|
||||||
|
|||||||
+4
@@ -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),
|
||||||
|
|||||||
+29
-8
@@ -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,10 +666,31 @@ 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;
|
||||||
}
|
}
|
||||||
if (target.getMsgType() != 5) {
|
int statusSubType = block.subType & 0xFFFF;
|
||||||
return "status_confirmed_target_must_be_status_action";
|
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 {
|
private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
|
||||||
|
|||||||
+29
-2
@@ -309,15 +309,42 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
static int[] loadStats(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
|
||||||
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
String sql = "SELECT likes_count,replies_count FROM message_stats WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? LIMIT 1";
|
||||||
|
int likesCount = 0;
|
||||||
|
int repliesCount = 0;
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, bch);
|
ps.setString(1, bch);
|
||||||
ps.setInt(2, blockNumber);
|
ps.setInt(2, blockNumber);
|
||||||
ps.setBytes(3, blockHash);
|
ps.setBytes(3, blockHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (!rs.next()) return new int[] {0, 0};
|
if (rs.next()) {
|
||||||
return new int[] {rs.getInt("likes_count"), rs.getInt("replies_count")};
|
likesCount = rs.getInt("likes_count");
|
||||||
|
repliesCount = rs.getInt("replies_count");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
String ratingsSql = """
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM blocks
|
||||||
|
WHERE msg_type = ?
|
||||||
|
AND msg_sub_type = ?
|
||||||
|
AND to_bch_name = ?
|
||||||
|
AND to_block_number = ?
|
||||||
|
AND to_block_hash = ?
|
||||||
|
""";
|
||||||
|
int ratingsCount = 0;
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(ratingsSql)) {
|
||||||
|
ps.setInt(1, MSG_TYPE_TEXT);
|
||||||
|
ps.setInt(2, MsgSubType.TEXT_RATING);
|
||||||
|
ps.setString(3, bch);
|
||||||
|
ps.setInt(4, blockNumber);
|
||||||
|
ps.setBytes(5, blockHash);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
ratingsCount = rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new int[] {likesCount, repliesCount, ratingsCount};
|
||||||
}
|
}
|
||||||
|
|
||||||
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
|
|||||||
+1
@@ -167,6 +167,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
int[] stats = ChannelsReadSupport.loadStats(c, ownerBch, post.blockNumber, post.blockHash);
|
||||||
item.setLikesCount(stats[0]);
|
item.setLikesCount(stats[0]);
|
||||||
item.setRepliesCount(stats[1]);
|
item.setRepliesCount(stats[1]);
|
||||||
|
item.setRatingsCount(stats[2]);
|
||||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
||||||
|
|
||||||
items.add(item);
|
items.add(item);
|
||||||
|
|||||||
+16
-9
@@ -17,6 +17,7 @@ import shine.db.DbController;
|
|||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -89,7 +90,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
|
private List<Net_GetMessageThread_Response.MessageNodeTree> loadChildren(Connection c, PostRow parent, int depthDown, int childLimit, String viewerLogin) throws Exception {
|
||||||
if (depthDown <= 0) return List.of();
|
if (depthDown <= 0) return List.of();
|
||||||
List<PostRow> replies = findReplies(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
List<PostRow> replies = findRepliesAndRatings(c, parent.bchName, parent.blockNumber, parent.blockHash, childLimit);
|
||||||
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
List<Net_GetMessageThread_Response.MessageNodeTree> out = new ArrayList<>();
|
||||||
for (PostRow row : replies) {
|
for (PostRow row : replies) {
|
||||||
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
Net_GetMessageThread_Response.MessageNodeTree t = new Net_GetMessageThread_Response.MessageNodeTree();
|
||||||
@@ -100,24 +101,29 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<PostRow> findReplies(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
private List<PostRow> findRepliesAndRatings(Connection c, String toBchName, int toBlockNumber, byte[] toBlockHash, int limit) throws Exception {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,line_code,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE msg_type=1 AND msg_sub_type=?
|
WHERE msg_type=1 AND msg_sub_type IN (?, ?)
|
||||||
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
AND to_bch_name=? AND to_block_number=? AND to_block_hash=?
|
||||||
ORDER BY block_number ASC
|
|
||||||
LIMIT ?
|
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setInt(1, MsgSubType.TEXT_REPLY);
|
ps.setInt(1, MsgSubType.TEXT_REPLY);
|
||||||
ps.setString(2, toBchName);
|
ps.setInt(2, MsgSubType.TEXT_RATING);
|
||||||
ps.setInt(3, toBlockNumber);
|
ps.setString(3, toBchName);
|
||||||
ps.setBytes(4, toBlockHash);
|
ps.setInt(4, toBlockNumber);
|
||||||
ps.setInt(5, limit);
|
ps.setBytes(5, toBlockHash);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostRow> out = new ArrayList<>();
|
List<PostRow> out = new ArrayList<>();
|
||||||
while (rs.next()) out.add(mapRow(rs));
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
|
out.sort(Comparator
|
||||||
|
.comparingLong((PostRow row) -> ChannelsReadSupport.parseTextAndTime(row.blockBytes).createdAtMs)
|
||||||
|
.thenComparing(row -> String.valueOf(row.bchName))
|
||||||
|
.thenComparingInt(row -> row.blockNumber));
|
||||||
|
if (out.size() > limit) {
|
||||||
|
return new ArrayList<>(out.subList(0, limit));
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +239,7 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
int[] stats = ChannelsReadSupport.loadStats(c, row.bchName, row.blockNumber, row.blockHash);
|
||||||
node.setLikesCount(stats[0]);
|
node.setLikesCount(stats[0]);
|
||||||
node.setRepliesCount(stats[1]);
|
node.setRepliesCount(stats[1]);
|
||||||
|
node.setRatingsCount(stats[2]);
|
||||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
||||||
if (row.lineCode != null && row.lineCode >= 0) {
|
if (row.lineCode != null && row.lineCode >= 0) {
|
||||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||||
|
|||||||
+179
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -127,11 +127,17 @@ 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;
|
||||||
private boolean likedByMe;
|
private boolean likedByMe;
|
||||||
private int repliesCount;
|
private int repliesCount;
|
||||||
|
private int ratingsCount;
|
||||||
private int versionsTotal;
|
private int versionsTotal;
|
||||||
private List<VersionItem> versions = new ArrayList<>();
|
private List<VersionItem> versions = new ArrayList<>();
|
||||||
|
|
||||||
@@ -158,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; }
|
||||||
|
|
||||||
@@ -173,6 +194,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public int getRepliesCount() { return repliesCount; }
|
public int getRepliesCount() { return repliesCount; }
|
||||||
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
|
||||||
|
|
||||||
|
public int getRatingsCount() { return ratingsCount; }
|
||||||
|
public void setRatingsCount(int ratingsCount) { this.ratingsCount = ratingsCount; }
|
||||||
|
|
||||||
public int getVersionsTotal() { return versionsTotal; }
|
public int getVersionsTotal() { return versionsTotal; }
|
||||||
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
|
||||||
|
|
||||||
|
|||||||
+18
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.5.9
|
client.version=1.5.11
|
||||||
server.version=1.4.7
|
server.version=1.4.9
|
||||||
|
|||||||
@@ -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-форматы для каналов и вложений
|
||||||
|
|
||||||
|
|||||||
@@ -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`) и загружаем полные списки.
|
||||||
|
|
||||||
@@ -192,6 +194,7 @@
|
|||||||
"text": "текущая версия",
|
"text": "текущая версия",
|
||||||
"likesCount": 12,
|
"likesCount": 12,
|
||||||
"repliesCount": 3,
|
"repliesCount": 3,
|
||||||
|
"ratingsCount": 2,
|
||||||
"versionsTotal": 4,
|
"versionsTotal": 4,
|
||||||
"versions": [
|
"versions": [
|
||||||
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
|
{ "versionIndex": 1, "blockNumber": 140, "blockHash": "...", "text": "v1", "createdAtMs": 1760000000000 },
|
||||||
@@ -248,6 +251,36 @@
|
|||||||
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
|
- `rawBlockB64` — сырой `block_bytes` текущего блока в Base64.
|
||||||
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
|
- Поле `rawBlockB64` присутствует у узлов во всех частях ответа `GetMessageThread`: `focus`, `ancestors[]`, `descendants[]`.
|
||||||
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ).
|
- В `GetChannelMessages` поле `rawBlockB64` **не добавляется** (лента канала без сырого блока, чтобы не раздувать ответ).
|
||||||
|
- И в `GetChannelMessages`, и в `GetMessageThread` каждое сообщение теперь содержит:
|
||||||
|
- `repliesCount` — число дочерних сообщений типа `TEXT_REPLY`;
|
||||||
|
- `ratingsCount` — число дочерних сообщений типа `TEXT_RATING`.
|
||||||
|
- В `descendants[]` операции `GetMessageThread` возвращаются оба типа дочерних текстовых сообщений:
|
||||||
|
- `TEXT_REPLY`;
|
||||||
|
- `TEXT_RATING`.
|
||||||
|
Они идут в одной общей ветке обсуждения и сортируются по времени создания.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) GetPersonalDiary
|
||||||
|
|
||||||
|
Возвращает виртуальный канал `Личный дневник` для самого пользователя.
|
||||||
|
|
||||||
|
- Вызов доступен только владельцу дневника.
|
||||||
|
- Сообщения в ответе строятся из блоков `STATUS_ACTION`.
|
||||||
|
- Поля `targetMsgSubType`, `targetText`, `targetAuthorLogin`, `targetAuthorBlockchainName`, `targetCreatedAtMs` описывают исходный материал, к которому относится действие.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetPersonalDiary",
|
||||||
|
"requestId": "req-4",
|
||||||
|
"payload": {
|
||||||
|
"login": "Alice",
|
||||||
|
"limit": 200,
|
||||||
|
"sort": "asc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
## Примечание
|
## Примечание
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
## Что не поддерживается
|
## Что не поддерживается
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
## 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`:
|
||||||
|
- `GetChannelMessages` и `GetMessageThread` теперь отдают отдельное поле `ratingsCount`;
|
||||||
|
- `GetMessageThread` включает `TEXT_RATING` в общее дерево потомков вместе с `TEXT_REPLY`;
|
||||||
|
- в `docs/API/06_Channels_Read_API.md` зафиксировано, что потомки треда возвращаются вперемешку по времени создания.
|
||||||
|
|
||||||
## 2026-08-09 18:55:16 +0400
|
## 2026-08-09 18:55:16 +0400
|
||||||
- Базовый коммит-ориентир: `43f54c9`.
|
- Базовый коммит-ориентир: `43f54c9`.
|
||||||
- Для первой итерации новых контентных типов обновлена карта `TEXT`-подтипов:
|
- Для первой итерации новых контентных типов обновлена карта `TEXT`-подтипов:
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|||||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||||
|
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||||
|
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 pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingThreadScroll = new Map();
|
const pendingThreadScroll = new Map();
|
||||||
@@ -227,6 +232,21 @@ function resolveChannelHeadingFromNode(node) {
|
|||||||
return `Сообщение в канале ${channelName}`;
|
return `Сообщение в канале ${channelName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChannelMessageTypeMeta(msgSubType) {
|
||||||
|
switch (Number(msgSubType || 0)) {
|
||||||
|
case MSG_SUBTYPE_TEXT_EXERCISE:
|
||||||
|
return { label: 'Упражнение', actionable: true };
|
||||||
|
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||||
|
return { label: 'Услуга', actionable: true };
|
||||||
|
case MSG_SUBTYPE_TEXT_COURSE:
|
||||||
|
return { label: 'Курс', actionable: true };
|
||||||
|
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
|
||||||
|
return { label: 'Оглавление' };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractChannelContextFromThreadPayload(payload) {
|
function extractChannelContextFromThreadPayload(payload) {
|
||||||
const focusInfo = payload?.focus?.channelInfo;
|
const focusInfo = payload?.focus?.channelInfo;
|
||||||
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
|
if (focusInfo?.ownerBlockchainName && focusInfo?.channelRoot?.blockNumber != null) {
|
||||||
@@ -461,13 +481,22 @@ function renderDraftAttachments(container, attachments) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReplyModal({ onSubmit, navigate }) {
|
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||||
|
const isRating = mode === 'rating';
|
||||||
|
const title = isRating ? 'Оценка' : 'Ответ';
|
||||||
|
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||||
|
const emptyError = isRating
|
||||||
|
? 'Введите текст оценки или добавьте вложение.'
|
||||||
|
: 'Введите текст ответа или добавьте вложение.';
|
||||||
|
const submitError = isRating
|
||||||
|
? 'Не удалось отправить оценку.'
|
||||||
|
: 'Не удалось отправить ответ.';
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="thread-reply-modal">
|
<div class="modal" id="thread-reply-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<h3 class="modal-title">Ответ</h3>
|
<h3 class="modal-title">${title}</h3>
|
||||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||||
<div class="draft-attachments" id="thread-reply-attachments"></div>
|
<div class="draft-attachments" id="thread-reply-attachments"></div>
|
||||||
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
<button class="secondary-btn attachment-trigger-btn" id="thread-reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||||
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
<div class="meta-muted inline-error" id="thread-reply-error"></div>
|
||||||
@@ -504,7 +533,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
|
|
||||||
const text = String(textEl?.value || '').trim();
|
const text = String(textEl?.value || '').trim();
|
||||||
if (!text && attachments.length === 0) {
|
if (!text && attachments.length === 0) {
|
||||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
errorEl.textContent = emptyError;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,7 +546,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
close();
|
close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
errorEl.textContent = toUserMessage(error, submitError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -713,7 +742,6 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
|
|||||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack thread-node-card channel-message-card';
|
card.className = 'card stack thread-node-card channel-message-card';
|
||||||
card.classList.add('is-counters-visible');
|
|
||||||
|
|
||||||
const author = node?.authorLogin || 'автор';
|
const author = node?.authorLogin || 'автор';
|
||||||
const versions = Array.isArray(node?.versions) ? node.versions : [];
|
const versions = Array.isArray(node?.versions) ? node.versions : [];
|
||||||
@@ -721,11 +749,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
const text = resolveNodeText(node) || (versionsTotal > 1 ? 'удалено' : '(пусто)');
|
||||||
const likes = Number(node?.likesCount || 0);
|
const likes = Number(node?.likesCount || 0);
|
||||||
const replies = Number(node?.repliesCount || 0);
|
const replies = Number(node?.repliesCount || 0);
|
||||||
|
const ratings = Number(node?.ratingsCount || 0);
|
||||||
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
const isOwnMessage = String(node?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase();
|
||||||
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
|
const isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
|
||||||
const msgSubType = Number(node?.msgSubType || 0);
|
const msgSubType = Number(node?.msgSubType || 0);
|
||||||
|
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
|
||||||
const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null;
|
const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null;
|
||||||
const parsedText = parseMessageAttachments(text);
|
const parsedText = parseMessageAttachments(text);
|
||||||
|
if (isRating) card.classList.add('is-rating');
|
||||||
|
card.classList.add('is-counters-visible');
|
||||||
|
|
||||||
const headingText = String(heading || '').trim();
|
const headingText = String(heading || '').trim();
|
||||||
if (headingText) {
|
if (headingText) {
|
||||||
@@ -738,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);
|
||||||
|
|
||||||
@@ -745,13 +779,16 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
authorBlock.className = 'channel-message-author';
|
authorBlock.className = 'channel-message-author';
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'channel-message-title author-line';
|
title.className = 'channel-message-title author-line';
|
||||||
|
const titleMain = document.createElement('div');
|
||||||
|
titleMain.className = 'author-line-main';
|
||||||
const loginEl = document.createElement('span');
|
const loginEl = document.createElement('span');
|
||||||
loginEl.className = 'author-line-login';
|
loginEl.className = 'author-line-login';
|
||||||
loginEl.textContent = author;
|
loginEl.textContent = author;
|
||||||
const numberEl = document.createElement('span');
|
const numberEl = document.createElement('span');
|
||||||
numberEl.className = 'author-line-num';
|
numberEl.className = 'author-line-num';
|
||||||
numberEl.textContent = `· #${localNumber}`;
|
numberEl.textContent = `· #${localNumber}`;
|
||||||
title.append(loginEl, numberEl);
|
titleMain.append(loginEl, numberEl);
|
||||||
|
title.append(titleMain);
|
||||||
if (versionsTotal > 1) {
|
if (versionsTotal > 1) {
|
||||||
const editedMarker = document.createElement('button');
|
const editedMarker = document.createElement('button');
|
||||||
editedMarker.type = 'button';
|
editedMarker.type = 'button';
|
||||||
@@ -773,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() === 'удалено';
|
||||||
|
|
||||||
@@ -793,13 +849,19 @@ 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,
|
||||||
messageTimestampMs: node?.createdAtMs,
|
messageTimestampMs: node?.createdAtMs,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (isRating) {
|
||||||
|
const ratingBadge = document.createElement('span');
|
||||||
|
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
|
||||||
|
ratingBadge.textContent = 'Оценка';
|
||||||
|
card.append(ratingBadge);
|
||||||
|
}
|
||||||
const body = document.createElement('p');
|
const body = document.createElement('p');
|
||||||
body.className = 'channel-message-body';
|
body.className = 'channel-message-body';
|
||||||
body.textContent = parsedText.text;
|
body.textContent = parsedText.text;
|
||||||
@@ -873,6 +935,24 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const ratingButton = document.createElement('button');
|
||||||
|
ratingButton.type = 'button';
|
||||||
|
ratingButton.className = 'channel-action-item thread-rating-btn';
|
||||||
|
ratingButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">★</span>
|
||||||
|
<span class="channel-action-label">Оценка</span>
|
||||||
|
<span class="channel-action-counter">${ratings}</span>
|
||||||
|
`;
|
||||||
|
setActionTitle(ratingButton, 'Оценка');
|
||||||
|
ratingButton.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openReplyModal({
|
||||||
|
navigate: handlers.navigate,
|
||||||
|
mode: 'rating',
|
||||||
|
onSubmit: async (textValue) => handlers.onRating(target, textValue),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
@@ -890,7 +970,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
|
|
||||||
// Репосты временно отключены до будущей реализации.
|
// Репосты временно отключены до будущей реализации.
|
||||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||||
actions.append(likeButton, replyButton, shareButton);
|
actions.append(likeButton, replyButton, ratingButton, shareButton);
|
||||||
if (repostTarget) {
|
if (repostTarget) {
|
||||||
const originalButton = document.createElement('button');
|
const originalButton = document.createElement('button');
|
||||||
originalButton.type = 'button';
|
originalButton.type = 'button';
|
||||||
@@ -977,7 +1057,7 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
|||||||
normalized.forEach((branch, index) => {
|
normalized.forEach((branch, index) => {
|
||||||
try {
|
try {
|
||||||
const nodeNumber = nextNumber();
|
const nodeNumber = nextNumber();
|
||||||
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber);
|
const row = renderNodeCard(branch?.node, '', handlers, nodeNumber);
|
||||||
row.classList.add('thread-node-level');
|
row.classList.add('thread-node-level');
|
||||||
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||||
wrap.append(row);
|
wrap.append(row);
|
||||||
@@ -1122,6 +1202,15 @@ export function render({ navigate, route }) {
|
|||||||
showStatus('');
|
showStatus('');
|
||||||
rerender();
|
rerender();
|
||||||
},
|
},
|
||||||
|
onRating: async (target, textValue) => {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
|
||||||
|
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||||
|
softHaptic(15);
|
||||||
|
showToast('Оценка отправлена');
|
||||||
|
showStatus('');
|
||||||
|
rerender();
|
||||||
|
},
|
||||||
onRepost: async (target) => {
|
onRepost: async (target) => {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||||
@@ -1365,7 +1454,7 @@ export function render({ navigate, route }) {
|
|||||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||||
const descendantsTitle = document.createElement('h3');
|
const descendantsTitle = document.createElement('h3');
|
||||||
descendantsTitle.className = 'section-title';
|
descendantsTitle.className = 'section-title';
|
||||||
descendantsTitle.textContent = 'Ответы';
|
descendantsTitle.textContent = 'Ответы и оценки';
|
||||||
descendantsWrap.append(descendantsTitle);
|
descendantsWrap.append(descendantsTitle);
|
||||||
|
|
||||||
if (descendants.length) {
|
if (descendants.length) {
|
||||||
@@ -1373,7 +1462,7 @@ export function render({ navigate, route }) {
|
|||||||
} 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 = 'Ответов и оценок пока нет.';
|
||||||
descendantsWrap.append(empty);
|
descendantsWrap.append(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,23 @@ import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
|||||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||||
const CHANNEL_TYPE_STORIES = 0;
|
const CHANNEL_TYPE_STORIES = 0;
|
||||||
const CHANNEL_TYPE_PERSONAL = 100;
|
const CHANNEL_TYPE_PERSONAL = 100;
|
||||||
|
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||||
const MSG_SUBTYPE_TEXT_REPOST = 50;
|
const MSG_SUBTYPE_TEXT_REPOST = 50;
|
||||||
|
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 pendingReactionActions = new Set();
|
||||||
const pendingScrollByRoute = new Map();
|
const pendingScrollByRoute = new Map();
|
||||||
@@ -288,6 +304,78 @@ function resolveMessageTimestampMs(message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChannelMessageTypeMeta(msgSubType) {
|
||||||
|
switch (Number(msgSubType || 0)) {
|
||||||
|
case MSG_SUBTYPE_TEXT_EXERCISE:
|
||||||
|
return { label: 'Упражнение', actionable: true };
|
||||||
|
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||||
|
return { label: 'Услуга', actionable: true };
|
||||||
|
case MSG_SUBTYPE_TEXT_COURSE:
|
||||||
|
return { label: 'Курс', actionable: true };
|
||||||
|
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
|
||||||
|
return { label: 'Оглавление' };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
@@ -560,13 +648,22 @@ function renderDraftAttachments(container, attachments) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReplyModal({ onSubmit, navigate }) {
|
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
|
||||||
|
const isRating = mode === 'rating';
|
||||||
|
const title = isRating ? 'Оценка' : 'Ответ';
|
||||||
|
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
|
||||||
|
const emptyError = isRating
|
||||||
|
? 'Введите текст оценки или добавьте вложение.'
|
||||||
|
: 'Введите текст ответа или добавьте вложение.';
|
||||||
|
const submitError = isRating
|
||||||
|
? 'Не удалось отправить оценку.'
|
||||||
|
: 'Не удалось отправить ответ.';
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div class="modal" id="reply-modal">
|
<div class="modal" id="reply-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<h3 class="modal-title">Ответ</h3>
|
<h3 class="modal-title">${title}</h3>
|
||||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||||
<div class="draft-attachments" id="reply-attachments"></div>
|
<div class="draft-attachments" id="reply-attachments"></div>
|
||||||
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
<button class="secondary-btn attachment-trigger-btn" id="reply-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||||
<div class="meta-muted inline-error" id="reply-error"></div>
|
<div class="meta-muted inline-error" id="reply-error"></div>
|
||||||
@@ -603,7 +700,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
|
|
||||||
const text = String(textEl?.value || '').trim();
|
const text = String(textEl?.value || '').trim();
|
||||||
if (!text && attachments.length === 0) {
|
if (!text && attachments.length === 0) {
|
||||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
errorEl.textContent = emptyError;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,7 +713,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
close();
|
close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
errorEl.textContent = toUserMessage(error, submitError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -645,6 +742,97 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
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 : [])
|
||||||
@@ -728,7 +916,16 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
|||||||
<p class="meta-muted">${channelName}</p>
|
<p class="meta-muted">${channelName}</p>
|
||||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||||||
<div class="draft-attachments" id="channel-message-attachments"></div>
|
<div class="draft-attachments" id="channel-message-attachments"></div>
|
||||||
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
<div class="channel-message-tools">
|
||||||
|
<select id="channel-message-type" class="input channel-message-type-select">
|
||||||
|
<option value="${10}">Пост</option>
|
||||||
|
<option value="${MSG_SUBTYPE_TEXT_EXERCISE}">Упражнение</option>
|
||||||
|
<option value="${MSG_SUBTYPE_TEXT_SERVICE}">Услуга</option>
|
||||||
|
<option value="${MSG_SUBTYPE_TEXT_COURSE}">Курс</option>
|
||||||
|
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||||
|
</select>
|
||||||
|
<button class="secondary-btn attachment-trigger-btn" id="channel-message-attach" type="button" aria-label="Добавить вложение" title="Добавить вложение">▣ 📎</button>
|
||||||
|
</div>
|
||||||
<div class="meta-muted inline-error" id="channel-message-error"></div>
|
<div class="meta-muted inline-error" id="channel-message-error"></div>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
<button class="secondary-btn" id="channel-message-cancel" type="button">Отмена</button>
|
||||||
@@ -739,6 +936,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const textEl = root.querySelector('#channel-message-text');
|
const textEl = root.querySelector('#channel-message-text');
|
||||||
|
const typeEl = root.querySelector('#channel-message-type');
|
||||||
const attachmentsEl = root.querySelector('#channel-message-attachments');
|
const attachmentsEl = root.querySelector('#channel-message-attachments');
|
||||||
const errorEl = root.querySelector('#channel-message-error');
|
const errorEl = root.querySelector('#channel-message-error');
|
||||||
const submitEl = root.querySelector('#channel-message-submit');
|
const submitEl = root.querySelector('#channel-message-submit');
|
||||||
@@ -749,6 +947,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
|||||||
inFlight = !!busy;
|
inFlight = !!busy;
|
||||||
submitEl.disabled = inFlight;
|
submitEl.disabled = inFlight;
|
||||||
if (textEl) textEl.disabled = inFlight;
|
if (textEl) textEl.disabled = inFlight;
|
||||||
|
if (typeEl) typeEl.disabled = inFlight;
|
||||||
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
|
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
|
||||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||||
};
|
};
|
||||||
@@ -762,6 +961,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
|||||||
if (inFlight) return;
|
if (inFlight) return;
|
||||||
|
|
||||||
const body = String(textEl?.value || '').trim();
|
const body = String(textEl?.value || '').trim();
|
||||||
|
const msgSubType = Number(typeEl?.value || 10);
|
||||||
if (!body && attachments.length === 0) {
|
if (!body && attachments.length === 0) {
|
||||||
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
|
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
|
||||||
return;
|
return;
|
||||||
@@ -771,7 +971,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
|||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(composeMessageWithAttachments(body, attachments));
|
await onSubmit({
|
||||||
|
text: composeMessageWithAttachments(body, attachments),
|
||||||
|
msgSubType,
|
||||||
|
});
|
||||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||||
close();
|
close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -899,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,
|
||||||
@@ -914,15 +1119,18 @@ 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),
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
|
ratingsCount: Number(message?.ratingsCount || 0),
|
||||||
timestampMs: resolveMessageTimestampMs(message),
|
timestampMs: resolveMessageTimestampMs(message),
|
||||||
messageRef,
|
messageRef,
|
||||||
rawMessage: message,
|
rawMessage: message,
|
||||||
msgSubType: Number(message?.msgSubType || 0),
|
msgSubType,
|
||||||
|
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(),
|
||||||
@@ -930,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(),
|
||||||
};
|
};
|
||||||
@@ -958,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();
|
||||||
@@ -1228,6 +1480,8 @@ function renderPostCard(post, {
|
|||||||
selector,
|
selector,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onReply,
|
onReply,
|
||||||
|
onRating,
|
||||||
|
onStatusAction,
|
||||||
onRepost,
|
onRepost,
|
||||||
onShare,
|
onShare,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -1236,6 +1490,7 @@ function renderPostCard(post, {
|
|||||||
|
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack channel-message-card';
|
card.className = 'card stack channel-message-card';
|
||||||
|
if (post.isRating) card.classList.add('is-rating');
|
||||||
|
|
||||||
const authorTile = document.createElement('button');
|
const authorTile = document.createElement('button');
|
||||||
authorTile.type = 'button';
|
authorTile.type = 'button';
|
||||||
@@ -1245,9 +1500,13 @@ 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';
|
||||||
|
const titleMain = document.createElement('div');
|
||||||
|
titleMain.className = 'author-line-main';
|
||||||
const loginEl = document.createElement('span');
|
const loginEl = document.createElement('span');
|
||||||
loginEl.className = 'author-line-login';
|
loginEl.className = 'author-line-login';
|
||||||
loginEl.textContent = post.authorLogin;
|
loginEl.textContent = post.authorLogin;
|
||||||
@@ -1255,12 +1514,12 @@ function renderPostCard(post, {
|
|||||||
const numberEl = document.createElement('span');
|
const numberEl = document.createElement('span');
|
||||||
numberEl.className = 'author-line-num';
|
numberEl.className = 'author-line-num';
|
||||||
numberEl.textContent = `· #${post.localNumber}`;
|
numberEl.textContent = `· #${post.localNumber}`;
|
||||||
|
titleMain.append(loginEl, numberEl);
|
||||||
|
title.append(titleMain);
|
||||||
|
|
||||||
const timestamp = document.createElement('div');
|
const timestamp = document.createElement('div');
|
||||||
timestamp.className = 'channel-message-time';
|
timestamp.className = 'channel-message-time';
|
||||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||||
|
|
||||||
title.append(loginEl, numberEl);
|
|
||||||
if (versionsTotal > 1) {
|
if (versionsTotal > 1) {
|
||||||
const editedMarker = document.createElement('button');
|
const editedMarker = document.createElement('button');
|
||||||
editedMarker.type = 'button';
|
editedMarker.type = 'button';
|
||||||
@@ -1279,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();
|
||||||
@@ -1306,13 +1584,38 @@ 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,
|
||||||
messageTimestampMs: post.timestampMs,
|
messageTimestampMs: post.timestampMs,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (post.isRating) {
|
||||||
|
const ratingBadge = document.createElement('span');
|
||||||
|
ratingBadge.className = 'channel-message-kind-badge channel-message-kind-badge--rating';
|
||||||
|
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 = `
|
||||||
|
<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;
|
||||||
@@ -1376,9 +1679,27 @@ function renderPostCard(post, {
|
|||||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const ratingButton = document.createElement('button');
|
||||||
|
ratingButton.type = 'button';
|
||||||
|
ratingButton.className = 'channel-action-item channel-action-rating';
|
||||||
|
ratingButton.innerHTML = `
|
||||||
|
<span class="channel-action-icon" aria-hidden="true">★</span>
|
||||||
|
<span class="channel-action-label">Оценка</span>
|
||||||
|
<span class="channel-action-counter">${post.ratingsCount || 0}</span>
|
||||||
|
`;
|
||||||
|
setActionTitle(ratingButton, 'Оценка');
|
||||||
|
ratingButton.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openReplyModal({
|
||||||
|
navigate,
|
||||||
|
mode: 'rating',
|
||||||
|
onSubmit: async (text) => onRating(post.messageRef, text),
|
||||||
|
});
|
||||||
|
});
|
||||||
// Репосты временно отключены до будущей реализации.
|
// Репосты временно отключены до будущей реализации.
|
||||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||||
actions.append(likeButton, replyButton);
|
actions.append(likeButton, replyButton, ratingButton);
|
||||||
|
|
||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
@@ -1525,6 +1846,8 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
selector: channelData.selector,
|
selector: channelData.selector,
|
||||||
onToggleLike: handlers.onToggleLike,
|
onToggleLike: handlers.onToggleLike,
|
||||||
onReply: handlers.onReply,
|
onReply: handlers.onReply,
|
||||||
|
onRating: handlers.onRating,
|
||||||
|
onStatusAction: handlers.onStatusAction,
|
||||||
onRepost: handlers.onRepost,
|
onRepost: handlers.onRepost,
|
||||||
onShare: handlers.onShare,
|
onShare: handlers.onShare,
|
||||||
onEdit: handlers.onEdit,
|
onEdit: handlers.onEdit,
|
||||||
@@ -1538,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1552,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);
|
||||||
@@ -1560,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
|
||||||
};
|
};
|
||||||
@@ -1670,6 +1997,47 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onRating = async (messageRef, text) => {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
await authService.addBlockRating({ login, storagePwd, message: messageRef, text });
|
||||||
|
|
||||||
|
const scrollTarget = messageRefKey(messageRef);
|
||||||
|
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
|
||||||
|
|
||||||
|
softHaptic(15);
|
||||||
|
showToast('Оценка отправлена');
|
||||||
|
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 : [];
|
||||||
@@ -1742,7 +2110,7 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddPost = async (bodyText) => {
|
const onAddPost = async (bodyText, msgSubType = 10) => {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
||||||
throw new Error('Идентификатор канала не готов.');
|
throw new Error('Идентификатор канала не готов.');
|
||||||
@@ -1753,6 +2121,7 @@ export function render({ navigate, route }) {
|
|||||||
storagePwd,
|
storagePwd,
|
||||||
channel: activeSelector,
|
channel: activeSelector,
|
||||||
text: bodyText,
|
text: bodyText,
|
||||||
|
msgSubType,
|
||||||
});
|
});
|
||||||
|
|
||||||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||||||
@@ -1816,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,
|
||||||
@@ -1830,9 +2199,9 @@ export function render({ navigate, route }) {
|
|||||||
openAddMessageModal({
|
openAddMessageModal({
|
||||||
channelName: apiData?.channel?.name || '',
|
channelName: apiData?.channel?.name || '',
|
||||||
navigate,
|
navigate,
|
||||||
onSubmit: async (bodyText) => {
|
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||||
try {
|
try {
|
||||||
await onAddPost(bodyText);
|
await onAddPost(bodyText, msgSubType);
|
||||||
showStatus('');
|
showStatus('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||||
@@ -1856,6 +2225,22 @@ export function render({ navigate, route }) {
|
|||||||
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
|
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRating: async (messageRef, text) => {
|
||||||
|
try {
|
||||||
|
await onRating(messageRef, text);
|
||||||
|
showStatus('');
|
||||||
|
} catch (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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -56,6 +57,19 @@ const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
|||||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||||
const MSG_SUBTYPE_TEXT_REPOST = 50;
|
const MSG_SUBTYPE_TEXT_REPOST = 50;
|
||||||
const MSG_SUBTYPE_TEXT_CHANNEL_META = 90;
|
const MSG_SUBTYPE_TEXT_CHANNEL_META = 90;
|
||||||
|
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_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;
|
||||||
@@ -564,6 +578,67 @@ function makeTextReplyBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text }) {
|
||||||
|
const cleanBch = String(toBlockchainName || '').trim();
|
||||||
|
if (!cleanBch) throw new Error('toBlockchainName is required for rating');
|
||||||
|
|
||||||
|
const blockNumber = Number(toBlockNumber);
|
||||||
|
if (!Number.isFinite(blockNumber) || blockNumber < 0) {
|
||||||
|
throw new Error('Invalid toBlockNumber for rating');
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = String(text || '').trim();
|
||||||
|
if (!message) throw new Error('Rating text is required');
|
||||||
|
|
||||||
|
const bchBytes = utf8Bytes(cleanBch);
|
||||||
|
if (bchBytes.length < 1 || bchBytes.length > 255) {
|
||||||
|
throw new Error('toBlockchainName must be 1..255 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const textBytes = utf8Bytes(message);
|
||||||
|
if (textBytes.length < 1 || textBytes.length > 65535) {
|
||||||
|
throw new Error('Rating text must be 1..65535 UTF-8 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return concatBytes(
|
||||||
|
int8Byte(bchBytes.length),
|
||||||
|
bchBytes,
|
||||||
|
int32Bytes(blockNumber),
|
||||||
|
hexToBytes(normalizeHex32(toBlockHashHex)),
|
||||||
|
int16Bytes(textBytes.length),
|
||||||
|
textBytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -1428,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(),
|
||||||
@@ -1769,6 +1851,71 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addBlockRating({ login, message, text, storagePwd }) {
|
||||||
|
const cleanLogin = String(login || '').trim();
|
||||||
|
const cleanText = String(text || '').trim();
|
||||||
|
const target = normalizeMessageRefTarget(message, 'rating');
|
||||||
|
const key = `rating:${cleanLogin}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`;
|
||||||
|
|
||||||
|
return this.runWriteLocked(key, async () => {
|
||||||
|
const bodyBytes = makeTextRatingBodyBytes({
|
||||||
|
toBlockchainName: target.blockchainName,
|
||||||
|
toBlockNumber: target.blockNumber,
|
||||||
|
toBlockHashHex: target.blockHash,
|
||||||
|
text: cleanText,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.addBlockSigned({
|
||||||
|
login: cleanLogin,
|
||||||
|
storagePwd,
|
||||||
|
msgType: MSG_TYPE_TEXT,
|
||||||
|
msgSubType: MSG_SUBTYPE_TEXT_RATING,
|
||||||
|
msgVersion: 1,
|
||||||
|
bodyBytes,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
@@ -2212,14 +2359,25 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBlockTextPost({ login, channel, text, storagePwd }) {
|
async addBlockTextPost({ login, channel, text, storagePwd, msgSubType = MSG_SUBTYPE_TEXT_POST }) {
|
||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = (login || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Missing login');
|
if (!cleanLogin) throw new Error('Missing login');
|
||||||
const cleanText = String(text || '').trim();
|
const cleanText = String(text || '').trim();
|
||||||
|
const cleanSubType = Number(msgSubType || MSG_SUBTYPE_TEXT_POST);
|
||||||
|
const allowedTextSubTypes = new Set([
|
||||||
|
MSG_SUBTYPE_TEXT_POST,
|
||||||
|
MSG_SUBTYPE_TEXT_ENTRYPOINT,
|
||||||
|
MSG_SUBTYPE_TEXT_EXERCISE,
|
||||||
|
MSG_SUBTYPE_TEXT_SERVICE,
|
||||||
|
MSG_SUBTYPE_TEXT_COURSE,
|
||||||
|
]);
|
||||||
|
if (!allowedTextSubTypes.has(cleanSubType)) {
|
||||||
|
throw new Error('Unsupported channel text subtype');
|
||||||
|
}
|
||||||
const selector = channel || {};
|
const selector = channel || {};
|
||||||
const owner = String(selector?.ownerBlockchainName || '').trim();
|
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||||
const root = Number(selector?.channelRootBlockNumber);
|
const root = Number(selector?.channelRootBlockNumber);
|
||||||
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanText}`;
|
const key = `text-post:${cleanLogin}:${owner}:${root}:${cleanSubType}:${cleanText}`;
|
||||||
|
|
||||||
return this.runWriteLocked(key, async () => {
|
return this.runWriteLocked(key, async () => {
|
||||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||||
@@ -2239,7 +2397,7 @@ export class AuthService {
|
|||||||
login: cleanLogin,
|
login: cleanLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
msgType: MSG_TYPE_TEXT,
|
msgType: MSG_TYPE_TEXT,
|
||||||
msgSubType: MSG_SUBTYPE_TEXT_POST,
|
msgSubType: cleanSubType,
|
||||||
msgVersion: 1,
|
msgVersion: 1,
|
||||||
bodyBytes,
|
bodyBytes,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,11 +4015,75 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-message-kind-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-kind-badge--rating {
|
||||||
|
color: #ffe8b0;
|
||||||
|
background: rgba(124, 92, 28, 0.36);
|
||||||
|
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);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(112, 84, 22, 0.12), rgba(20, 25, 35, 0.58)),
|
||||||
|
rgba(20, 25, 35, 0.55);
|
||||||
|
box-shadow: 0 0 38px rgba(181, 136, 42, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
.channel-message-body {
|
.channel-message-body {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -4923,9 +4989,62 @@ textarea.input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.author-line {
|
.author-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-line-main {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-type-chip,
|
||||||
|
.channel-message-type-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid rgba(224, 190, 117, 0.38);
|
||||||
|
background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34));
|
||||||
|
color: #f1d99c;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
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;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-type-select {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 46px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.author-line-login {
|
.author-line-login {
|
||||||
|
|||||||
Reference in New Issue
Block a user