SHA256
Добавить рейтинг и типы материалов в каналах
This commit is contained in:
@@ -24,6 +24,11 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
||||
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||
|
||||
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 pendingThreadScroll = new Map();
|
||||
@@ -227,6 +232,21 @@ function resolveChannelHeadingFromNode(node) {
|
||||
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) {
|
||||
const focusInfo = payload?.focus?.channelInfo;
|
||||
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');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="thread-reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<textarea id="thread-reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||
<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>
|
||||
<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();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -517,7 +546,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
close();
|
||||
} catch (error) {
|
||||
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) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack thread-node-card channel-message-card';
|
||||
card.classList.add('is-counters-visible');
|
||||
|
||||
const author = node?.authorLogin || 'автор';
|
||||
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 likes = Number(node?.likesCount || 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 isChannelPost = Number(node?.channelInfo?.channelRoot?.blockNumber) >= 0;
|
||||
const msgSubType = Number(node?.msgSubType || 0);
|
||||
const isRating = msgSubType === MSG_SUBTYPE_TEXT_RATING;
|
||||
const repostTarget = msgSubType === 50 ? buildRepostTargetFromNode(node) : null;
|
||||
const parsedText = parseMessageAttachments(text);
|
||||
if (isRating) card.classList.add('is-rating');
|
||||
card.classList.add('is-counters-visible');
|
||||
|
||||
const headingText = String(heading || '').trim();
|
||||
if (headingText) {
|
||||
@@ -745,13 +777,23 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
authorBlock.className = 'channel-message-author';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'channel-message-title author-line';
|
||||
const titleMain = document.createElement('div');
|
||||
titleMain.className = 'author-line-main';
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'author-line-login';
|
||||
loginEl.textContent = author;
|
||||
const numberEl = document.createElement('span');
|
||||
numberEl.className = 'author-line-num';
|
||||
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) {
|
||||
const editedMarker = document.createElement('button');
|
||||
editedMarker.type = 'button';
|
||||
@@ -800,6 +842,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
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');
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = parsedText.text;
|
||||
@@ -873,6 +921,24 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
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');
|
||||
shareButton.type = 'button';
|
||||
@@ -890,7 +956,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
|
||||
// Репосты временно отключены до будущей реализации.
|
||||
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
actions.append(likeButton, replyButton, ratingButton, shareButton);
|
||||
if (repostTarget) {
|
||||
const originalButton = document.createElement('button');
|
||||
originalButton.type = 'button';
|
||||
@@ -977,7 +1043,7 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
normalized.forEach((branch, index) => {
|
||||
try {
|
||||
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.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||
wrap.append(row);
|
||||
@@ -1122,6 +1188,15 @@ export function render({ navigate, route }) {
|
||||
showStatus('');
|
||||
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) => {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
const feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
@@ -1365,7 +1440,7 @@ export function render({ navigate, route }) {
|
||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||
const descendantsTitle = document.createElement('h3');
|
||||
descendantsTitle.className = 'section-title';
|
||||
descendantsTitle.textContent = 'Ответы';
|
||||
descendantsTitle.textContent = 'Ответы и оценки';
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
@@ -1373,7 +1448,7 @@ export function render({ navigate, route }) {
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ответов пока нет.';
|
||||
empty.textContent = 'Ответов и оценок пока нет.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,12 @@ import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
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 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) {
|
||||
const wrap = document.createElement('div');
|
||||
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');
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="reply-modal">
|
||||
<div class="modal-card stack">
|
||||
<h3 class="modal-title">Ответ</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="Текст ответа"></textarea>
|
||||
<h3 class="modal-title">${title}</h3>
|
||||
<textarea id="reply-text" class="input" rows="5" maxlength="2000" placeholder="${placeholder}"></textarea>
|
||||
<div class="draft-attachments" id="reply-attachments"></div>
|
||||
<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>
|
||||
@@ -603,7 +632,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
|
||||
const text = String(textEl?.value || '').trim();
|
||||
if (!text && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст ответа или добавьте вложение.';
|
||||
errorEl.textContent = emptyError;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -616,7 +645,7 @@ function openReplyModal({ onSubmit, navigate }) {
|
||||
close();
|
||||
} catch (error) {
|
||||
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>
|
||||
<textarea id="channel-message-text" class="input" rows="6" maxlength="2000" placeholder="Текст сообщения"></textarea>
|
||||
<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="form-actions-grid">
|
||||
<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 typeEl = root.querySelector('#channel-message-type');
|
||||
const attachmentsEl = root.querySelector('#channel-message-attachments');
|
||||
const errorEl = root.querySelector('#channel-message-error');
|
||||
const submitEl = root.querySelector('#channel-message-submit');
|
||||
@@ -749,6 +788,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
inFlight = !!busy;
|
||||
submitEl.disabled = inFlight;
|
||||
if (textEl) textEl.disabled = inFlight;
|
||||
if (typeEl) typeEl.disabled = inFlight;
|
||||
root.querySelector('#channel-message-attach')?.toggleAttribute('disabled', inFlight);
|
||||
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||
};
|
||||
@@ -762,6 +802,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
if (inFlight) return;
|
||||
|
||||
const body = String(textEl?.value || '').trim();
|
||||
const msgSubType = Number(typeEl?.value || 10);
|
||||
if (!body && attachments.length === 0) {
|
||||
errorEl.textContent = 'Введите текст сообщения или добавьте вложение.';
|
||||
return;
|
||||
@@ -771,7 +812,10 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
|
||||
errorEl.textContent = '';
|
||||
|
||||
try {
|
||||
await onSubmit(composeMessageWithAttachments(body, attachments));
|
||||
await onSubmit({
|
||||
text: composeMessageWithAttachments(body, attachments),
|
||||
msgSubType,
|
||||
});
|
||||
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
|
||||
close();
|
||||
} catch (error) {
|
||||
@@ -919,10 +963,12 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
||||
versions: Array.isArray(message?.versions) ? message.versions : [],
|
||||
likesCount: Number(message?.likesCount || 0),
|
||||
repliesCount: Number(message?.repliesCount || 0),
|
||||
ratingsCount: Number(message?.ratingsCount || 0),
|
||||
timestampMs: resolveMessageTimestampMs(message),
|
||||
messageRef,
|
||||
rawMessage: message,
|
||||
msgSubType: Number(message?.msgSubType || 0),
|
||||
isRating: Number(message?.msgSubType || 0) === MSG_SUBTYPE_TEXT_RATING,
|
||||
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
|
||||
? {
|
||||
blockchainName: String(message.targetBlockchainName).trim(),
|
||||
@@ -1228,6 +1274,7 @@ function renderPostCard(post, {
|
||||
selector,
|
||||
onToggleLike,
|
||||
onReply,
|
||||
onRating,
|
||||
onRepost,
|
||||
onShare,
|
||||
onEdit,
|
||||
@@ -1236,6 +1283,7 @@ function renderPostCard(post, {
|
||||
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack channel-message-card';
|
||||
if (post.isRating) card.classList.add('is-rating');
|
||||
|
||||
const authorTile = document.createElement('button');
|
||||
authorTile.type = 'button';
|
||||
@@ -1248,6 +1296,8 @@ function renderPostCard(post, {
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'channel-message-title author-line';
|
||||
const titleMain = document.createElement('div');
|
||||
titleMain.className = 'author-line-main';
|
||||
const loginEl = document.createElement('span');
|
||||
loginEl.className = 'author-line-login';
|
||||
loginEl.textContent = post.authorLogin;
|
||||
@@ -1255,12 +1305,19 @@ function renderPostCard(post, {
|
||||
const numberEl = document.createElement('span');
|
||||
numberEl.className = 'author-line-num';
|
||||
numberEl.textContent = `· #${post.localNumber}`;
|
||||
titleMain.append(loginEl, numberEl);
|
||||
title.append(titleMain);
|
||||
const typeMeta = getChannelMessageTypeMeta(post.msgSubType);
|
||||
if (typeMeta) {
|
||||
const typeChip = document.createElement('span');
|
||||
typeChip.className = 'channel-message-type-chip';
|
||||
typeChip.textContent = typeMeta.label;
|
||||
title.append(typeChip);
|
||||
}
|
||||
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||
|
||||
title.append(loginEl, numberEl);
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('button');
|
||||
editedMarker.type = 'button';
|
||||
@@ -1313,6 +1370,12 @@ function renderPostCard(post, {
|
||||
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');
|
||||
body.className = 'channel-message-body';
|
||||
body.textContent = parsedBody.text;
|
||||
@@ -1376,9 +1439,27 @@ function renderPostCard(post, {
|
||||
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
|
||||
actions.append(likeButton, replyButton);
|
||||
actions.append(likeButton, replyButton, ratingButton);
|
||||
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
@@ -1525,6 +1606,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
selector: channelData.selector,
|
||||
onToggleLike: handlers.onToggleLike,
|
||||
onReply: handlers.onReply,
|
||||
onRating: handlers.onRating,
|
||||
onRepost: handlers.onRepost,
|
||||
onShare: handlers.onShare,
|
||||
onEdit: handlers.onEdit,
|
||||
@@ -1670,6 +1752,18 @@ export function render({ navigate, route }) {
|
||||
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 feed = await authService.listSubscriptionsFeed(login, 1000);
|
||||
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();
|
||||
if (!activeSelector?.ownerBlockchainName || activeSelector.channelRootBlockNumber == null) {
|
||||
throw new Error('Идентификатор канала не готов.');
|
||||
@@ -1753,6 +1847,7 @@ export function render({ navigate, route }) {
|
||||
storagePwd,
|
||||
channel: activeSelector,
|
||||
text: bodyText,
|
||||
msgSubType,
|
||||
});
|
||||
|
||||
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||||
@@ -1830,9 +1925,9 @@ export function render({ navigate, route }) {
|
||||
openAddMessageModal({
|
||||
channelName: apiData?.channel?.name || '',
|
||||
navigate,
|
||||
onSubmit: async (bodyText) => {
|
||||
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||
try {
|
||||
await onAddPost(bodyText);
|
||||
await onAddPost(bodyText, msgSubType);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||
@@ -1856,6 +1951,14 @@ export function render({ navigate, route }) {
|
||||
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) => {
|
||||
try {
|
||||
await onRepost(messageRef);
|
||||
|
||||
@@ -56,6 +56,10 @@ const MSG_SUBTYPE_TEXT_EDIT_REPLY = 21;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_REPOST = 50;
|
||||
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_UNLIKE = 2;
|
||||
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({
|
||||
lineCode,
|
||||
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 }) {
|
||||
const cleanLogin = String(login || '').trim();
|
||||
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();
|
||||
if (!cleanLogin) throw new Error('Missing login');
|
||||
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 owner = String(selector?.ownerBlockchainName || '').trim();
|
||||
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 () => {
|
||||
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
|
||||
@@ -2239,7 +2311,7 @@ export class AuthService {
|
||||
login: cleanLogin,
|
||||
storagePwd,
|
||||
msgType: MSG_TYPE_TEXT,
|
||||
msgSubType: MSG_SUBTYPE_TEXT_POST,
|
||||
msgSubType: cleanSubType,
|
||||
msgVersion: 1,
|
||||
bodyBytes,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user