SHA256
Добавить рейтинг и типы материалов в каналах
This commit is contained in:
+29
-2
@@ -309,16 +309,43 @@ 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 {
|
||||||
if (rootNumber == 0) {
|
if (rootNumber == 0) {
|
||||||
|
|||||||
+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();
|
||||||
|
|||||||
+4
@@ -132,6 +132,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
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<>();
|
||||||
|
|
||||||
@@ -173,6 +174,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; }
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.5.9
|
client.version=1.5.10
|
||||||
server.version=1.4.7
|
server.version=1.4.8
|
||||||
|
|||||||
@@ -192,6 +192,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 +249,13 @@
|
|||||||
- `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`.
|
||||||
|
Они идут в одной общей ветке обсуждения и сортируются по времени создания.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
## 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: 'Упражнение' };
|
||||||
|
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||||
|
return { label: 'Процедура' };
|
||||||
|
case MSG_SUBTYPE_TEXT_COURSE:
|
||||||
|
return { label: 'Курс' };
|
||||||
|
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) {
|
||||||
@@ -745,13 +777,23 @@ 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);
|
||||||
|
const typeMeta = getChannelMessageTypeMeta(node?.msgSubType);
|
||||||
|
if (typeMeta) {
|
||||||
|
const typeChip = document.createElement('span');
|
||||||
|
typeChip.className = 'channel-message-type-chip';
|
||||||
|
typeChip.textContent = typeMeta.label;
|
||||||
|
title.append(typeChip);
|
||||||
|
}
|
||||||
if (versionsTotal > 1) {
|
if (versionsTotal > 1) {
|
||||||
const editedMarker = document.createElement('button');
|
const editedMarker = document.createElement('button');
|
||||||
editedMarker.type = 'button';
|
editedMarker.type = 'button';
|
||||||
@@ -800,6 +842,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
|||||||
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 +921,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 +956,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 +1043,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 +1188,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 +1440,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 +1448,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,12 @@ 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 pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingScrollByRoute = new Map();
|
const pendingScrollByRoute = new Map();
|
||||||
@@ -288,6 +293,21 @@ function resolveMessageTimestampMs(message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChannelMessageTypeMeta(msgSubType) {
|
||||||
|
switch (Number(msgSubType || 0)) {
|
||||||
|
case MSG_SUBTYPE_TEXT_EXERCISE:
|
||||||
|
return { label: 'Упражнение' };
|
||||||
|
case MSG_SUBTYPE_TEXT_SERVICE:
|
||||||
|
return { label: 'Процедура' };
|
||||||
|
case MSG_SUBTYPE_TEXT_COURSE:
|
||||||
|
return { label: 'Курс' };
|
||||||
|
case MSG_SUBTYPE_TEXT_ENTRYPOINT:
|
||||||
|
return { label: 'Оглавление' };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 +580,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 +632,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 +645,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
|||||||
close();
|
close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
errorEl.textContent = toUserMessage(error, submitError);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -728,7 +757,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>
|
||||||
|
<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>
|
<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 +777,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 +788,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 +802,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 +812,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) {
|
||||||
@@ -919,10 +963,12 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
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: Number(message?.msgSubType || 0),
|
||||||
|
isRating: Number(message?.msgSubType || 0) === MSG_SUBTYPE_TEXT_RATING,
|
||||||
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(),
|
||||||
@@ -1228,6 +1274,7 @@ function renderPostCard(post, {
|
|||||||
selector,
|
selector,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onReply,
|
onReply,
|
||||||
|
onRating,
|
||||||
onRepost,
|
onRepost,
|
||||||
onShare,
|
onShare,
|
||||||
onEdit,
|
onEdit,
|
||||||
@@ -1236,6 +1283,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';
|
||||||
@@ -1248,6 +1296,8 @@ function renderPostCard(post, {
|
|||||||
|
|
||||||
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 +1305,19 @@ 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 typeMeta = getChannelMessageTypeMeta(post.msgSubType);
|
||||||
|
if (typeMeta) {
|
||||||
|
const typeChip = document.createElement('span');
|
||||||
|
typeChip.className = 'channel-message-type-chip';
|
||||||
|
typeChip.textContent = typeMeta.label;
|
||||||
|
title.append(typeChip);
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = document.createElement('div');
|
const timestamp = document.createElement('div');
|
||||||
timestamp.className = 'channel-message-time';
|
timestamp.className = 'channel-message-time';
|
||||||
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';
|
||||||
@@ -1313,6 +1370,12 @@ function renderPostCard(post, {
|
|||||||
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);
|
||||||
|
}
|
||||||
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 +1439,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 +1606,7 @@ 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,
|
||||||
onRepost: handlers.onRepost,
|
onRepost: handlers.onRepost,
|
||||||
onShare: handlers.onShare,
|
onShare: handlers.onShare,
|
||||||
onEdit: handlers.onEdit,
|
onEdit: handlers.onEdit,
|
||||||
@@ -1670,6 +1752,18 @@ 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 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 +1836,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 +1847,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__');
|
||||||
@@ -1830,9 +1925,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 +1951,14 @@ 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, 'Не удалось отправить оценку.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
onRepost: async (messageRef) => {
|
onRepost: async (messageRef) => {
|
||||||
try {
|
try {
|
||||||
await onRepost(messageRef);
|
await onRepost(messageRef);
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ 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_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 +568,38 @@ 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 makeTextRepostBodyBytes({
|
function makeTextRepostBodyBytes({
|
||||||
lineCode,
|
lineCode,
|
||||||
prevLineNumber,
|
prevLineNumber,
|
||||||
@@ -1769,6 +1805,31 @@ 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 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 +2273,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 +2311,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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4018,6 +4018,32 @@ textarea.input {
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 +4949,48 @@ 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 {
|
||||||
|
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-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