channels ux cleanup and create-flow recovery
This commit is contained in:
parent
07e57b8563
commit
126b4ba3a1
@ -3,6 +3,7 @@ import { authService, state } from '../state.js';
|
|||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
import {
|
import {
|
||||||
channelNameErrorText,
|
channelNameErrorText,
|
||||||
|
normalizeChannelDescription,
|
||||||
normalizeChannelDisplayName,
|
normalizeChannelDisplayName,
|
||||||
validateChannelDisplayName,
|
validateChannelDisplayName,
|
||||||
} from '../services/channel-name-rules.js';
|
} from '../services/channel-name-rules.js';
|
||||||
@ -19,6 +20,15 @@ function persistCreateSuccessFlash(message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateDescription(value) {
|
||||||
|
const normalized = normalizeChannelDescription(value);
|
||||||
|
const bytes = new TextEncoder().encode(normalized).length;
|
||||||
|
if (bytes > 200) {
|
||||||
|
return { ok: false, normalized, bytes, error: 'Описание слишком длинное: максимум 200 байт UTF-8.' };
|
||||||
|
}
|
||||||
|
return { ok: true, normalized, bytes, error: '' };
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack channels-screen channels-screen--add';
|
screen.className = 'stack channels-screen channels-screen--add';
|
||||||
@ -27,7 +37,7 @@ export function render({ navigate }) {
|
|||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Создать канал',
|
title: 'Создать канал',
|
||||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = document.createElement('form');
|
const form = document.createElement('form');
|
||||||
@ -35,9 +45,17 @@ export function render({ navigate }) {
|
|||||||
form.innerHTML = `
|
form.innerHTML = `
|
||||||
<strong class="channel-head-title">Создание канала</strong>
|
<strong class="channel-head-title">Создание канала</strong>
|
||||||
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
|
<p class="channel-head-meta">Можно использовать кириллицу, латиницу, цифры, пробел, _ и -.</p>
|
||||||
<p class="channel-head-meta">Длина: от 3 до 32 символов. Название уникально во всей системе.</p>
|
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p>
|
||||||
|
|
||||||
<label for="channel-name">Название канала</label>
|
<label for="channel-name">Название канала</label>
|
||||||
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Поток силы" required />
|
<input id="channel-name" class="input" maxlength="64" placeholder="Например: Поток силы" required />
|
||||||
|
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||||
|
|
||||||
|
<label for="channel-description">Описание канала (необязательно)</label>
|
||||||
|
<textarea id="channel-description" class="input" rows="4" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
|
||||||
|
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
|
||||||
|
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
||||||
|
|
||||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||||
<div class="form-actions-grid">
|
<div class="form-actions-grid">
|
||||||
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
|
<button type="button" class="secondary-btn" id="cancel-create-channel">Отмена</button>
|
||||||
@ -45,7 +63,11 @@ export function render({ navigate }) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const inputEl = form.querySelector('#channel-name');
|
const nameEl = form.querySelector('#channel-name');
|
||||||
|
const descriptionEl = form.querySelector('#channel-description');
|
||||||
|
const nameErrorEl = form.querySelector('#channel-name-error');
|
||||||
|
const descriptionErrorEl = form.querySelector('#channel-description-error');
|
||||||
|
const descriptionCounterEl = form.querySelector('#channel-description-counter');
|
||||||
const errorEl = form.querySelector('#channel-create-error');
|
const errorEl = form.querySelector('#channel-create-error');
|
||||||
const submitEl = form.querySelector('#submit-create-channel');
|
const submitEl = form.querySelector('#submit-create-channel');
|
||||||
const cancelEl = form.querySelector('#cancel-create-channel');
|
const cancelEl = form.querySelector('#cancel-create-channel');
|
||||||
@ -56,24 +78,33 @@ export function render({ navigate }) {
|
|||||||
submitInFlight = !!busy;
|
submitInFlight = !!busy;
|
||||||
submitEl.disabled = submitInFlight;
|
submitEl.disabled = submitInFlight;
|
||||||
cancelEl.disabled = submitInFlight;
|
cancelEl.disabled = submitInFlight;
|
||||||
inputEl.disabled = submitInFlight;
|
nameEl.disabled = submitInFlight;
|
||||||
|
descriptionEl.disabled = submitInFlight;
|
||||||
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateValidation = () => {
|
const updateValidation = () => {
|
||||||
const check = validateChannelDisplayName(inputEl.value);
|
const nameCheck = validateChannelDisplayName(nameEl.value);
|
||||||
if (!check.ok) {
|
const descriptionCheck = validateDescription(descriptionEl.value);
|
||||||
errorEl.textContent = channelNameErrorText(check.code);
|
|
||||||
} else {
|
nameErrorEl.textContent = nameCheck.ok ? '' : channelNameErrorText(nameCheck.code);
|
||||||
errorEl.textContent = '';
|
descriptionErrorEl.textContent = descriptionCheck.error;
|
||||||
}
|
|
||||||
submitEl.disabled = submitInFlight || !check.ok;
|
const descLength = Number(descriptionCheck.bytes || 0);
|
||||||
return check;
|
descriptionCounterEl.textContent = `${descLength} / 200 байт`;
|
||||||
|
|
||||||
|
const ok = nameCheck.ok && descriptionCheck.ok;
|
||||||
|
submitEl.disabled = submitInFlight || !ok;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
name: nameCheck.normalized,
|
||||||
|
description: descriptionCheck.normalized,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
inputEl.addEventListener('input', () => {
|
nameEl.addEventListener('input', updateValidation);
|
||||||
updateValidation();
|
descriptionEl.addEventListener('input', updateValidation);
|
||||||
});
|
|
||||||
|
|
||||||
form.addEventListener('submit', async (event) => {
|
form.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -93,31 +124,30 @@ export function render({ navigate }) {
|
|||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const channelName = normalizeChannelDisplayName(check.normalized);
|
const created = await authService.addBlockCreateChannel({
|
||||||
await authService.addBlockCreateChannel({
|
|
||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
channelName,
|
channelName: normalizeChannelDisplayName(check.name),
|
||||||
|
channelDescription: normalizeChannelDescription(check.description),
|
||||||
});
|
});
|
||||||
|
|
||||||
persistCreateSuccessFlash(`Канал "${channelName}" создан.`);
|
const baseMessage = `Канал "${normalizeChannelDisplayName(check.name)}" создан.`;
|
||||||
|
const successMessage = created?.usedLegacyDescriptionFallback
|
||||||
|
? `${baseMessage} Описание не сохранено: на текущем сервере включен legacy-формат create-channel.`
|
||||||
|
: baseMessage;
|
||||||
|
persistCreateSuccessFlash(successMessage);
|
||||||
navigate('channels-list');
|
navigate('channels-list');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
|
errorEl.textContent = toUserMessage(error, 'Не удалось создать канал.');
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
const checkAfterError = validateChannelDisplayName(inputEl.value);
|
|
||||||
submitEl.disabled = submitInFlight || !checkAfterError.ok;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
cancelEl.addEventListener('click', () => {
|
|
||||||
navigate('channels-list');
|
|
||||||
});
|
|
||||||
|
|
||||||
screen.append(form);
|
|
||||||
if (inputEl) {
|
|
||||||
inputEl.focus();
|
|
||||||
updateValidation();
|
updateValidation();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelEl.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
|
screen.append(form);
|
||||||
|
nameEl.focus();
|
||||||
|
updateValidation();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,17 @@
|
|||||||
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
|
||||||
import { captureClientError } from '../services/client-error-reporter.js';
|
import { captureClientError } from '../services/client-error-reporter.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
import {
|
||||||
|
animatePress,
|
||||||
|
createSkeletonCard,
|
||||||
|
showToast,
|
||||||
|
softHaptic,
|
||||||
|
} from '../services/channels-ux.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||||
|
|
||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
|
const pendingThreadScroll = new Map();
|
||||||
|
|
||||||
function logThreadRuntimeError(stage, error, context = {}) {
|
function logThreadRuntimeError(stage, error, context = {}) {
|
||||||
const message = String(error?.message || error || 'thread runtime error');
|
const message = String(error?.message || error || 'thread runtime error');
|
||||||
@ -14,10 +21,7 @@ function logThreadRuntimeError(stage, error, context = {}) {
|
|||||||
kind: 'channels_thread_runtime',
|
kind: 'channels_thread_runtime',
|
||||||
message,
|
message,
|
||||||
stack: error?.stack || '',
|
stack: error?.stack || '',
|
||||||
context: {
|
context: { stage, ...context },
|
||||||
stage,
|
|
||||||
...context,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,6 +55,14 @@ function makeReactionActionKey(messageRef) {
|
|||||||
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageRefKey(messageRef) {
|
||||||
|
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||||
|
const blockNumber = Number(messageRef?.blockNumber);
|
||||||
|
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
||||||
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||||||
|
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseThreadSelector(route) {
|
function parseThreadSelector(route) {
|
||||||
const params = route?.params || {};
|
const params = route?.params || {};
|
||||||
const blockNumber = toSafeInt(params.messageBlockNumber);
|
const blockNumber = toSafeInt(params.messageBlockNumber);
|
||||||
@ -161,22 +173,38 @@ function openReplyModal({ onSubmit }) {
|
|||||||
|
|
||||||
const textEl = root.querySelector('#thread-reply-text');
|
const textEl = root.querySelector('#thread-reply-text');
|
||||||
const errorEl = root.querySelector('#thread-reply-error');
|
const errorEl = root.querySelector('#thread-reply-error');
|
||||||
|
const submitEl = root.querySelector('#thread-reply-submit');
|
||||||
|
let inFlight = false;
|
||||||
|
|
||||||
|
const setBusy = (busy) => {
|
||||||
|
inFlight = !!busy;
|
||||||
|
submitEl.disabled = inFlight;
|
||||||
|
if (textEl) textEl.disabled = inFlight;
|
||||||
|
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||||
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
root.querySelector('#thread-reply-cancel').addEventListener('click', close);
|
root.querySelector('#thread-reply-cancel')?.addEventListener('click', close);
|
||||||
root.querySelector('#thread-reply-submit').addEventListener('click', async () => {
|
root.querySelector('#thread-reply-submit')?.addEventListener('click', async () => {
|
||||||
|
if (inFlight) return;
|
||||||
|
|
||||||
const text = String(textEl?.value || '').trim();
|
const text = String(textEl?.value || '').trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
errorEl.textContent = 'Введите текст ответа.';
|
errorEl.textContent = 'Введите текст ответа.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(text);
|
await onSubmit(text);
|
||||||
close();
|
close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setBusy(false);
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -184,32 +212,48 @@ function openReplyModal({ onSubmit }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNodeCard(node, heading, handlers) {
|
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack thread-node-card';
|
card.className = 'card stack thread-node-card';
|
||||||
|
|
||||||
const author = node?.authorLogin || 'автор';
|
const author = node?.authorLogin || 'автор';
|
||||||
const bch = node?.authorBlockchainName || '-';
|
|
||||||
const blockNo = node?.messageRef?.blockNumber ?? '?';
|
|
||||||
const text = resolveNodeText(node) || '(пусто)';
|
const text = resolveNodeText(node) || '(пусто)';
|
||||||
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 versions = Number(node?.versionsTotal || 1);
|
const versions = Number(node?.versionsTotal || 1);
|
||||||
|
|
||||||
card.innerHTML = `
|
const headingEl = document.createElement('strong');
|
||||||
<strong class="thread-node-heading">${heading}</strong>
|
headingEl.className = 'thread-node-heading';
|
||||||
<p class="thread-node-meta">${author} (${bch}) - #${blockNo}</p>
|
headingEl.textContent = heading;
|
||||||
<p class="thread-node-body">${text}</p>
|
|
||||||
<p class="thread-node-stats">Лайки: ${likes}, ответы: ${replies}, версий: ${versions}</p>
|
const meta = document.createElement('p');
|
||||||
|
meta.className = 'thread-node-meta';
|
||||||
|
meta.innerHTML = `
|
||||||
|
<span class="author-line-login">${author}</span>
|
||||||
|
<span class="author-line-num">· #${localNumber}</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const body = document.createElement('p');
|
||||||
|
body.className = 'thread-node-body';
|
||||||
|
body.textContent = text;
|
||||||
|
|
||||||
|
const stats = document.createElement('p');
|
||||||
|
stats.className = 'thread-node-stats';
|
||||||
|
stats.textContent = `Лайки: ${likes}, ответы: ${replies}, версий: ${versions}`;
|
||||||
|
|
||||||
|
card.append(headingEl, meta, body, stats);
|
||||||
|
|
||||||
const target = buildTargetFromNode(node);
|
const target = buildTargetFromNode(node);
|
||||||
if (!target || !handlers) return card;
|
if (!target || !handlers) return card;
|
||||||
|
|
||||||
|
const refKey = messageRefKey(target);
|
||||||
|
if (refKey) card.dataset.messageKey = refKey;
|
||||||
|
|
||||||
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
||||||
|
|
||||||
const actionKey = makeReactionActionKey(target);
|
const actionKey = makeReactionActionKey(target);
|
||||||
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
||||||
|
|
||||||
const isLiked = getMessageReactionState(target) === 'liked';
|
const isLiked = getMessageReactionState(target) === 'liked';
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
@ -221,8 +265,11 @@ function renderNodeCard(node, heading, handlers) {
|
|||||||
if (isLiked) likeButton.classList.add('is-liked');
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async () => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
|
likeButton.disabled = true;
|
||||||
|
likeButton.textContent = 'Выполняется...';
|
||||||
try {
|
try {
|
||||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -239,7 +286,8 @@ function renderNodeCard(node, heading, handlers) {
|
|||||||
replyButton.type = 'button';
|
replyButton.type = 'button';
|
||||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||||
replyButton.textContent = 'Ответить';
|
replyButton.textContent = 'Ответить';
|
||||||
replyButton.addEventListener('click', () => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
openReplyModal({
|
openReplyModal({
|
||||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||||
});
|
});
|
||||||
@ -250,20 +298,21 @@ function renderNodeCard(node, heading, handlers) {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDescendants(items, handlers, depth = 0) {
|
function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'stack';
|
wrap.className = 'stack';
|
||||||
|
|
||||||
const normalized = Array.isArray(items) ? items : [];
|
const normalized = Array.isArray(items) ? items : [];
|
||||||
normalized.forEach((branch, index) => {
|
normalized.forEach((branch, index) => {
|
||||||
try {
|
try {
|
||||||
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers);
|
const nodeNumber = nextNumber();
|
||||||
|
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, 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);
|
||||||
|
|
||||||
if (Array.isArray(branch?.children) && branch.children.length) {
|
if (Array.isArray(branch?.children) && branch.children.length) {
|
||||||
wrap.append(renderDescendants(branch.children, handlers, depth + 1));
|
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
||||||
@ -273,10 +322,44 @@ function renderDescendants(items, handlers, depth = 0) {
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyPendingScroll(screen, routeKey) {
|
||||||
|
const target = pendingThreadScroll.get(routeKey);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const doScroll = () => {
|
||||||
|
if (target === '__LAST_REPLY__') {
|
||||||
|
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
|
||||||
|
const last = cards[cards.length - 1];
|
||||||
|
if (last) {
|
||||||
|
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
pendingThreadScroll.delete(routeKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = screen.querySelector(`[data-message-key="${target}"]`);
|
||||||
|
if (node) {
|
||||||
|
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
pendingThreadScroll.delete(routeKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(doScroll, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkeleton(screen) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'stack';
|
||||||
|
wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard());
|
||||||
|
screen.append(wrap);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
const selector = parseThreadSelector(route);
|
const selector = parseThreadSelector(route);
|
||||||
const backRoute = buildBackRoute(selector);
|
const backRoute = buildBackRoute(selector);
|
||||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||||
|
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack channels-screen channels-screen--thread';
|
screen.className = 'stack channels-screen channels-screen--thread';
|
||||||
@ -300,9 +383,7 @@ export function render({ navigate, route }) {
|
|||||||
const next = render({ navigate, route });
|
const next = render({ navigate, route });
|
||||||
current.replaceWith(next);
|
current.replaceWith(next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logThreadRuntimeError('rerender', error, {
|
logThreadRuntimeError('rerender', error, { routeHash: window.location.hash });
|
||||||
routeHash: window.location.hash,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -323,19 +404,16 @@ export function render({ navigate, route }) {
|
|||||||
return { login, storagePwd };
|
return { login, storagePwd };
|
||||||
};
|
};
|
||||||
|
|
||||||
const rereadThread = async () => {
|
|
||||||
if (!selector) return;
|
|
||||||
await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
onToggleLike: async (target, action) => {
|
onToggleLike: async (target, action) => {
|
||||||
const actionKey = makeReactionActionKey(target);
|
const actionKey = makeReactionActionKey(target);
|
||||||
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
|
||||||
if (pendingReactionActions.has(actionKey)) return;
|
if (pendingReactionActions.has(actionKey)) return;
|
||||||
|
|
||||||
|
const previousReaction = getMessageReactionState(target);
|
||||||
|
const nextReaction = action === 'unlike' ? 'unliked' : 'liked';
|
||||||
|
|
||||||
pendingReactionActions.add(actionKey);
|
pendingReactionActions.add(actionKey);
|
||||||
rerender();
|
|
||||||
try {
|
try {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
if (action === 'unlike') {
|
if (action === 'unlike') {
|
||||||
@ -343,24 +421,24 @@ export function render({ navigate, route }) {
|
|||||||
} else {
|
} else {
|
||||||
await authService.addBlockLike({ login, storagePwd, message: target });
|
await authService.addBlockLike({ login, storagePwd, message: target });
|
||||||
}
|
}
|
||||||
await rereadThread();
|
|
||||||
showStatus('');
|
setMessageReactionState(target, nextReaction);
|
||||||
|
softHaptic(10);
|
||||||
|
rerender();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logThreadRuntimeError('toggle_like', error, {
|
setMessageReactionState(target, previousReaction || 'unliked');
|
||||||
action,
|
rerender();
|
||||||
targetBlockchainName: target?.blockchainName || '',
|
|
||||||
targetBlockNumber: target?.blockNumber,
|
|
||||||
});
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pendingReactionActions.delete(actionKey);
|
pendingReactionActions.delete(actionKey);
|
||||||
rerender();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReply: async (target, textValue) => {
|
onReply: async (target, textValue) => {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
|
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
|
||||||
await rereadThread();
|
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
|
||||||
|
softHaptic(15);
|
||||||
|
showToast('Ответ отправлен');
|
||||||
showStatus('');
|
showStatus('');
|
||||||
rerender();
|
rerender();
|
||||||
},
|
},
|
||||||
@ -376,7 +454,7 @@ export function render({ navigate, route }) {
|
|||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Тред',
|
title: 'Тред',
|
||||||
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
screen.append(userIndicator, channelIndicator, statusBox);
|
screen.append(userIndicator, channelIndicator, statusBox);
|
||||||
|
|
||||||
@ -388,15 +466,12 @@ export function render({ navigate, route }) {
|
|||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = document.createElement('div');
|
const skeleton = renderSkeleton(screen);
|
||||||
loading.className = 'card meta-muted';
|
|
||||||
loading.textContent = 'Загрузка треда...';
|
|
||||||
screen.append(loading);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
|
const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
|
||||||
loading.remove();
|
skeleton.remove();
|
||||||
|
|
||||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||||
const focus = payload?.focus || null;
|
const focus = payload?.focus || null;
|
||||||
@ -407,6 +482,12 @@ export function render({ navigate, route }) {
|
|||||||
summary.textContent = `Предки: ${ancestors.length}, ответы: ${descendants.length}`;
|
summary.textContent = `Предки: ${ancestors.length}, ответы: ${descendants.length}`;
|
||||||
screen.append(summary);
|
screen.append(summary);
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const nextNumber = () => {
|
||||||
|
seq += 1;
|
||||||
|
return seq;
|
||||||
|
};
|
||||||
|
|
||||||
if (ancestors.length) {
|
if (ancestors.length) {
|
||||||
const ancestorsWrap = document.createElement('div');
|
const ancestorsWrap = document.createElement('div');
|
||||||
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||||
@ -415,7 +496,7 @@ export function render({ navigate, route }) {
|
|||||||
title.textContent = 'Предыдущие сообщения';
|
title.textContent = 'Предыдущие сообщения';
|
||||||
ancestorsWrap.append(title);
|
ancestorsWrap.append(title);
|
||||||
ancestors.forEach((node, index) => {
|
ancestors.forEach((node, index) => {
|
||||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers));
|
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
||||||
});
|
});
|
||||||
screen.append(ancestorsWrap);
|
screen.append(ancestorsWrap);
|
||||||
}
|
}
|
||||||
@ -426,7 +507,7 @@ export function render({ navigate, route }) {
|
|||||||
const title = document.createElement('h3');
|
const title = document.createElement('h3');
|
||||||
title.className = 'section-title';
|
title.className = 'section-title';
|
||||||
title.textContent = 'Текущее сообщение';
|
title.textContent = 'Текущее сообщение';
|
||||||
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers));
|
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers, nextNumber()));
|
||||||
screen.append(focusWrap);
|
screen.append(focusWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -438,7 +519,7 @@ export function render({ navigate, route }) {
|
|||||||
descendantsWrap.append(descendantsTitle);
|
descendantsWrap.append(descendantsTitle);
|
||||||
|
|
||||||
if (descendants.length) {
|
if (descendants.length) {
|
||||||
descendantsWrap.append(renderDescendants(descendants, handlers));
|
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
||||||
} else {
|
} else {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'card meta-muted';
|
empty.className = 'card meta-muted';
|
||||||
@ -447,8 +528,9 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
screen.append(descendantsWrap);
|
screen.append(descendantsWrap);
|
||||||
|
applyPendingScroll(screen, routeKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loading.remove();
|
skeleton.remove();
|
||||||
const failed = document.createElement('div');
|
const failed = document.createElement('div');
|
||||||
failed.className = 'card meta-muted';
|
failed.className = 'card meta-muted';
|
||||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||||
@ -458,4 +540,3 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { channelPosts, channels } from '../mock-data.js';
|
|
||||||
import {
|
import {
|
||||||
addLocalChannelPost,
|
|
||||||
authService,
|
authService,
|
||||||
getLocalChannelPosts,
|
|
||||||
getMessageReactionState,
|
getMessageReactionState,
|
||||||
setMessageReactionState,
|
setMessageReactionState,
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
import {
|
||||||
|
animatePress,
|
||||||
|
createSkeletonCard,
|
||||||
|
showToast,
|
||||||
|
softHaptic,
|
||||||
|
} from '../services/channels-ux.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
||||||
|
|
||||||
const ZERO64 = '0'.repeat(64);
|
|
||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
|
const pendingScrollByRoute = new Map();
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
@ -55,6 +58,49 @@ function makeReactionActionKey(messageRef) {
|
|||||||
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
return `${login}|${blockchainName}|${blockNumber}|${blockHash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function messageRefKey(messageRef) {
|
||||||
|
const blockchainName = String(messageRef?.blockchainName || '').trim();
|
||||||
|
const blockNumber = Number(messageRef?.blockNumber);
|
||||||
|
const blockHash = normalizeMessageHash(messageRef?.blockHash);
|
||||||
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return '';
|
||||||
|
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelDescriptionParamKey(selector) {
|
||||||
|
const owner = String(selector?.ownerBlockchainName || '').trim();
|
||||||
|
const rootNo = Number(selector?.channelRootBlockNumber);
|
||||||
|
const rootHash = normalizeRouteHash(selector?.channelRootBlockHash);
|
||||||
|
if (!owner || !Number.isFinite(rootNo)) return '';
|
||||||
|
return `channel_desc:${owner}:${rootNo}:${rootHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDescriptionOverride(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return { hasOverride: false, description: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = String(payload?.value ?? payload?.param_value ?? '').trim();
|
||||||
|
if (!rawValue && !Number(payload?.time_ms || payload?.timeMs || 0)) {
|
||||||
|
return { hasOverride: false, description: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
return { hasOverride: true, description: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawValue);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
const value = typeof parsed.v === 'string' ? parsed.v : '';
|
||||||
|
return { hasOverride: true, description: value.trim() };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// legacy raw string value
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hasOverride: true, description: rawValue };
|
||||||
|
}
|
||||||
|
|
||||||
function buildSelectorFromRoute(route, channelId) {
|
function buildSelectorFromRoute(route, channelId) {
|
||||||
const params = route?.params || {};
|
const params = route?.params || {};
|
||||||
|
|
||||||
@ -78,13 +124,6 @@ function buildSelectorFromRoute(route, channelId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function localPostsKey(selector, channelId) {
|
|
||||||
if (selector?.ownerBlockchainName && selector?.channelRootBlockNumber != null) {
|
|
||||||
return `${selector.ownerBlockchainName}:${selector.channelRootBlockNumber}`;
|
|
||||||
}
|
|
||||||
return channelId || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildThreadRoute(messageRef, selector) {
|
function buildThreadRoute(messageRef, selector) {
|
||||||
if (!messageRef || !selector) return '';
|
if (!messageRef || !selector) return '';
|
||||||
return [
|
return [
|
||||||
@ -126,54 +165,22 @@ function resolveMessageText(message) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapApiMessageToPost(message, selector) {
|
function openAboutChannelModal(channel) {
|
||||||
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
const root = document.getElementById('modal-root');
|
||||||
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
root.innerHTML = `
|
||||||
const messageBch = String(message?.authorBlockchainName || selector?.ownerBlockchainName || '').trim();
|
<div class="modal" id="about-channel-modal">
|
||||||
const hasRef = !!(messageBch && blockNumber != null && blockHash);
|
<div class="modal-card stack">
|
||||||
const resolvedText = resolveMessageText(message);
|
<h3 class="modal-title">О канале</h3>
|
||||||
const messageRef = hasRef
|
<p><strong>${channel.displayName || channel.name}</strong></p>
|
||||||
? {
|
<p class="meta-muted">${channel.description || 'Описание не задано.'}</p>
|
||||||
blockchainName: messageBch,
|
<button class="secondary-btn" id="about-channel-close" type="button">Закрыть</button>
|
||||||
blockNumber,
|
</div>
|
||||||
blockHash,
|
</div>
|
||||||
}
|
`;
|
||||||
: null;
|
|
||||||
|
|
||||||
if (messageRef) {
|
root.querySelector('#about-channel-close')?.addEventListener('click', () => {
|
||||||
setMessageReactionState(messageRef, message?.likedByMe === true ? 'liked' : 'unliked');
|
root.innerHTML = '';
|
||||||
}
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${message?.authorLogin || 'автор'} - #${blockNumber ?? '?'}`,
|
|
||||||
body: resolvedText || '(пусто)',
|
|
||||||
likesCount: Number(message?.likesCount || 0),
|
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
|
||||||
messageRef,
|
|
||||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMockChannel(channelId) {
|
|
||||||
const fallback = channels[0] || {
|
|
||||||
id: 'ch0',
|
|
||||||
name: 'Неизвестный канал',
|
|
||||||
description: 'Описание отсутствует',
|
|
||||||
ownerName: 'неизвестно',
|
|
||||||
ownerLogin: '',
|
|
||||||
displayName: 'неизвестно/Неизвестный канал',
|
|
||||||
};
|
|
||||||
const channel = channels.find((c) => c.id === channelId) || fallback;
|
|
||||||
return {
|
|
||||||
channel,
|
|
||||||
posts: [
|
|
||||||
...(channelPosts[channel.id] || []).map((post) => ({ title: post.title, body: post.body })),
|
|
||||||
...getLocalChannelPosts(channelId),
|
|
||||||
],
|
|
||||||
isOwnChannel: channel.ownerLogin === '@shine.alex',
|
|
||||||
selector: null,
|
|
||||||
localKey: channelId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReplyModal({ onSubmit }) {
|
function openReplyModal({ onSubmit }) {
|
||||||
@ -194,22 +201,38 @@ function openReplyModal({ onSubmit }) {
|
|||||||
|
|
||||||
const textEl = root.querySelector('#reply-text');
|
const textEl = root.querySelector('#reply-text');
|
||||||
const errorEl = root.querySelector('#reply-error');
|
const errorEl = root.querySelector('#reply-error');
|
||||||
|
const submitEl = root.querySelector('#reply-submit');
|
||||||
|
let inFlight = false;
|
||||||
|
|
||||||
|
const setBusy = (busy) => {
|
||||||
|
inFlight = !!busy;
|
||||||
|
submitEl.disabled = inFlight;
|
||||||
|
if (textEl) textEl.disabled = inFlight;
|
||||||
|
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||||
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
root.querySelector('#reply-cancel').addEventListener('click', close);
|
root.querySelector('#reply-cancel')?.addEventListener('click', close);
|
||||||
root.querySelector('#reply-submit').addEventListener('click', async () => {
|
submitEl?.addEventListener('click', async () => {
|
||||||
|
if (inFlight) return;
|
||||||
|
|
||||||
const text = String(textEl?.value || '').trim();
|
const text = String(textEl?.value || '').trim();
|
||||||
if (!text) {
|
if (!text) {
|
||||||
errorEl.textContent = 'Введите текст ответа.';
|
errorEl.textContent = 'Введите текст ответа.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit(text);
|
await onSubmit(text);
|
||||||
close();
|
close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setBusy(false);
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
errorEl.textContent = toUserMessage(error, 'Не удалось отправить ответ.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -223,7 +246,7 @@ function openAddMessageModal({ channelName, onSubmit }) {
|
|||||||
<div class="modal" id="channel-message-modal">
|
<div class="modal" id="channel-message-modal">
|
||||||
<div class="modal-card stack">
|
<div class="modal-card stack">
|
||||||
<h3 class="modal-title">Новое сообщение в канале</h3>
|
<h3 class="modal-title">Новое сообщение в канале</h3>
|
||||||
<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="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">
|
||||||
@ -236,25 +259,38 @@ function openAddMessageModal({ channelName, onSubmit }) {
|
|||||||
|
|
||||||
const textEl = root.querySelector('#channel-message-text');
|
const textEl = root.querySelector('#channel-message-text');
|
||||||
const errorEl = root.querySelector('#channel-message-error');
|
const errorEl = root.querySelector('#channel-message-error');
|
||||||
|
const submitEl = root.querySelector('#channel-message-submit');
|
||||||
|
let inFlight = false;
|
||||||
|
|
||||||
|
const setBusy = (busy) => {
|
||||||
|
inFlight = !!busy;
|
||||||
|
submitEl.disabled = inFlight;
|
||||||
|
if (textEl) textEl.disabled = inFlight;
|
||||||
|
submitEl.textContent = inFlight ? 'Отправляем...' : 'Отправить';
|
||||||
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
root.querySelector('#channel-message-cancel').addEventListener('click', close);
|
root.querySelector('#channel-message-cancel')?.addEventListener('click', close);
|
||||||
root.querySelector('#channel-message-submit').addEventListener('click', async () => {
|
submitEl?.addEventListener('click', async () => {
|
||||||
|
if (inFlight) return;
|
||||||
|
|
||||||
const body = String(textEl?.value || '').trim();
|
const body = String(textEl?.value || '').trim();
|
||||||
if (!body) {
|
if (!body) {
|
||||||
errorEl.textContent = 'Введите текст сообщения.';
|
errorEl.textContent = 'Введите текст сообщения.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
errorEl.textContent = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit({
|
await onSubmit(body);
|
||||||
title: `${state.session.login || 'вы'} - сейчас`,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
close();
|
close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
setBusy(false);
|
||||||
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
|
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -262,114 +298,116 @@ function openAddMessageModal({ channelName, onSubmit }) {
|
|||||||
if (textEl) textEl.focus();
|
if (textEl) textEl.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
|
function openEditDescriptionModal({ initialValue = '', onSubmit }) {
|
||||||
const card = document.createElement('article');
|
const root = document.getElementById('modal-root');
|
||||||
card.className = 'card stack channel-message-card';
|
root.innerHTML = `
|
||||||
|
<div class="modal" id="channel-edit-description-modal">
|
||||||
const stats = document.createElement('p');
|
<div class="modal-card stack">
|
||||||
stats.className = 'channel-message-stats';
|
<h3 class="modal-title">Описание канала</h3>
|
||||||
stats.textContent = `Лайки: ${post.likesCount || 0}, ответы: ${post.repliesCount || 0}`;
|
<textarea id="channel-description-text" class="input" rows="5" maxlength="400" placeholder="Коротко о канале, до 200 байт UTF-8"></textarea>
|
||||||
|
<div class="meta-muted" id="channel-description-counter">0 / 200 байт</div>
|
||||||
card.innerHTML = `<strong class="channel-message-title">${post.title}</strong><p class="channel-message-body">${post.body}</p>`;
|
<div class="meta-muted inline-error" id="channel-description-error"></div>
|
||||||
card.append(stats);
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" id="channel-description-cancel" type="button">Отмена</button>
|
||||||
if (!post.messageRef || !selector) return card;
|
<button class="primary-btn" id="channel-description-submit" type="button">Сохранить</button>
|
||||||
|
</div>
|
||||||
const actionKey = makeReactionActionKey(post.messageRef);
|
</div>
|
||||||
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
</div>
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'channel-message-actions';
|
|
||||||
|
|
||||||
const likeButton = document.createElement('button');
|
|
||||||
likeButton.type = 'button';
|
|
||||||
likeButton.className = 'secondary-btn channel-action-like';
|
|
||||||
const isLiked = post.reactionState === 'liked';
|
|
||||||
if (isLiked) likeButton.classList.add('is-liked');
|
|
||||||
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
|
||||||
likeButton.disabled = isPending;
|
|
||||||
likeButton.addEventListener('click', async () => {
|
|
||||||
if (isPending) return;
|
|
||||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like');
|
|
||||||
});
|
|
||||||
|
|
||||||
const replyButton = document.createElement('button');
|
|
||||||
replyButton.type = 'button';
|
|
||||||
replyButton.className = 'secondary-btn channel-action-reply';
|
|
||||||
replyButton.textContent = 'Ответить';
|
|
||||||
replyButton.addEventListener('click', () => {
|
|
||||||
openReplyModal({
|
|
||||||
onSubmit: async (text) => onReply(post.messageRef, text),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const openThreadButton = document.createElement('button');
|
|
||||||
openThreadButton.type = 'button';
|
|
||||||
openThreadButton.className = 'secondary-btn channel-action-thread';
|
|
||||||
openThreadButton.textContent = 'Открыть тред';
|
|
||||||
openThreadButton.addEventListener('click', () => {
|
|
||||||
const route = buildThreadRoute(post.messageRef, selector);
|
|
||||||
if (route) navigate(route);
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.append(likeButton, replyButton, openThreadButton);
|
|
||||||
card.append(actions);
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBody(screen, navigate, channelData, handlers) {
|
|
||||||
const head = document.createElement('div');
|
|
||||||
head.className = 'card channel-head-card';
|
|
||||||
head.innerHTML = `
|
|
||||||
<strong class="channel-head-title">${channelData.channel.displayName || channelData.channel.name}</strong>
|
|
||||||
<p class="channel-head-meta">${channelData.channel.description}</p>
|
|
||||||
<p class="channel-head-meta">Владелец: ${channelData.channel.ownerName}</p>
|
|
||||||
<p class="channel-note">Состояние лайка обновляется после подтверждённого reread с сервера.</p>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const actionButton = document.createElement('button');
|
const textEl = root.querySelector('#channel-description-text');
|
||||||
actionButton.className = channelData.isOwnChannel
|
const counterEl = root.querySelector('#channel-description-counter');
|
||||||
? 'primary-btn channel-main-action'
|
const errorEl = root.querySelector('#channel-description-error');
|
||||||
: 'destructive-btn channel-main-action';
|
const submitEl = root.querySelector('#channel-description-submit');
|
||||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
|
const cancelEl = root.querySelector('#channel-description-cancel');
|
||||||
let followLimit = null;
|
|
||||||
|
|
||||||
const feed = document.createElement('div');
|
let inFlight = false;
|
||||||
feed.className = 'stack channel-feed';
|
|
||||||
|
|
||||||
channelData.posts.forEach((post) => {
|
const compute = () => {
|
||||||
feed.append(renderPostCard(post, {
|
const value = String(textEl?.value || '').replace(/\s+/g, ' ').trim();
|
||||||
navigate,
|
const bytes = new TextEncoder().encode(value).length;
|
||||||
selector: channelData.selector,
|
const ok = bytes <= 200;
|
||||||
onToggleLike: handlers.onToggleLike,
|
return {
|
||||||
onReply: handlers.onReply,
|
value,
|
||||||
}));
|
bytes,
|
||||||
|
ok,
|
||||||
|
error: ok ? '' : 'Описание слишком длинное: максимум 200 байт UTF-8.',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBusy = (busy) => {
|
||||||
|
inFlight = !!busy;
|
||||||
|
submitEl.disabled = inFlight;
|
||||||
|
cancelEl.disabled = inFlight;
|
||||||
|
if (textEl) textEl.disabled = inFlight;
|
||||||
|
submitEl.textContent = inFlight ? 'Сохраняем...' : 'Сохранить';
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
root.innerHTML = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateValidation = () => {
|
||||||
|
const check = compute();
|
||||||
|
counterEl.textContent = `${check.bytes} / 200 байт`;
|
||||||
|
errorEl.textContent = check.error;
|
||||||
|
submitEl.disabled = inFlight || !check.ok;
|
||||||
|
return check;
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelEl?.addEventListener('click', close);
|
||||||
|
textEl?.addEventListener('input', updateValidation);
|
||||||
|
submitEl?.addEventListener('click', async () => {
|
||||||
|
if (inFlight) return;
|
||||||
|
const check = updateValidation();
|
||||||
|
if (!check.ok) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
errorEl.textContent = '';
|
||||||
|
try {
|
||||||
|
await onSubmit(check.value);
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
setBusy(false);
|
||||||
|
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить описание.');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (channelData.isOwnChannel) {
|
if (textEl) {
|
||||||
actionButton.addEventListener('click', () => {
|
textEl.value = String(initialValue || '');
|
||||||
openAddMessageModal({
|
textEl.focus();
|
||||||
channelName: channelData.channel.name,
|
}
|
||||||
onSubmit: async (post) => handlers.onAddPost(post),
|
updateValidation();
|
||||||
});
|
}
|
||||||
});
|
|
||||||
} else {
|
function mapApiMessageToPost(message, selector, localNumber) {
|
||||||
followLimit = document.createElement('p');
|
const blockNumber = toSafeInt(message?.messageRef?.blockNumber);
|
||||||
followLimit.className = 'channel-note';
|
const blockHash = normalizeMessageHash(message?.messageRef?.blockHash);
|
||||||
followLimit.textContent = 'Отписка удаляет только эту подписку на канал.';
|
const messageBch = String(message?.authorBlockchainName || selector?.ownerBlockchainName || '').trim();
|
||||||
actionButton.addEventListener('click', handlers.onUnfollowChannel);
|
const hasRef = !!(messageBch && blockNumber != null && blockHash);
|
||||||
|
|
||||||
|
const resolvedText = resolveMessageText(message);
|
||||||
|
const messageRef = hasRef
|
||||||
|
? {
|
||||||
|
blockchainName: messageBch,
|
||||||
|
blockNumber,
|
||||||
|
blockHash,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (messageRef) {
|
||||||
|
setMessageReactionState(messageRef, message?.likedByMe === true ? 'liked' : 'unliked');
|
||||||
}
|
}
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
return {
|
||||||
backButton.className = 'secondary-btn channel-back-btn';
|
localNumber,
|
||||||
backButton.textContent = 'Назад к каналам';
|
authorLogin: message?.authorLogin || 'автор',
|
||||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
body: resolvedText || '(пусто)',
|
||||||
|
likesCount: Number(message?.likesCount || 0),
|
||||||
if (followLimit) {
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
screen.append(head, followLimit, actionButton, feed, backButton);
|
messageRef,
|
||||||
return;
|
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||||
}
|
};
|
||||||
screen.append(head, actionButton, feed, backButton);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFromApi(route, channelId) {
|
async function loadFromApi(route, channelId) {
|
||||||
@ -379,23 +417,36 @@ async function loadFromApi(route, channelId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
|
const payload = await authService.getChannelMessages(selector, 200, 'asc', state.session.login);
|
||||||
const localKey = localPostsKey(selector, channelId);
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
const posts = [
|
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
||||||
...(payload.messages || []).map((message) => mapApiMessageToPost(message, selector)),
|
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||||
...getLocalChannelPosts(localKey),
|
|
||||||
];
|
const readDescription = async () => {
|
||||||
|
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
|
||||||
|
const paramKey = channelDescriptionParamKey(selector);
|
||||||
|
if (!ownerLogin || !paramKey) return sourceDescription;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const paramPayload = await authService.getUserParam(ownerLogin, paramKey);
|
||||||
|
const override = parseDescriptionOverride(paramPayload);
|
||||||
|
return override.hasOverride ? override.description : sourceDescription;
|
||||||
|
} catch {
|
||||||
|
return sourceDescription;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedDescription = await readDescription();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: {
|
channel: {
|
||||||
name: payload.channel?.channelName || 'неизвестный канал',
|
name: payload.channel?.channelName || 'неизвестный канал',
|
||||||
displayName: `${payload.channel?.ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
displayName: `${ownerLogin || 'неизвестно'}/${payload.channel?.channelName || 'неизвестный канал'}`,
|
||||||
description: `bch=${payload.channel?.ownerBlockchainName || selector.ownerBlockchainName}`,
|
description: resolvedDescription,
|
||||||
ownerName: payload.channel?.ownerLogin || 'неизвестно',
|
ownerName: ownerLogin || 'неизвестно',
|
||||||
},
|
},
|
||||||
posts,
|
posts,
|
||||||
isOwnChannel: (payload.channel?.ownerLogin || '').toLowerCase() === (state.session.login || '').toLowerCase(),
|
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
|
||||||
selector,
|
selector,
|
||||||
localKey,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -423,39 +474,261 @@ function renderLoadError(screen, navigate, message, onRetry) {
|
|||||||
screen.append(card);
|
screen.append(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDemoFallback(screen, navigate, channelId, error) {
|
function renderDemoFallback(screen, navigate, error) {
|
||||||
const info = document.createElement('div');
|
const info = document.createElement('div');
|
||||||
info.className = 'card stack';
|
info.className = 'card stack';
|
||||||
info.innerHTML = `
|
info.innerHTML = `
|
||||||
<strong>Включен демо-режим</strong>
|
<strong>Включен демо-режим</strong>
|
||||||
<p class="meta-muted">Данные канала с сервера недоступны. Показан мок-канал, потому что включен channelsDemo.</p>
|
<p class="meta-muted">Данные канала с сервера недоступны. Показан демо-контент.</p>
|
||||||
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
|
<p class="meta-muted">${toUserMessage(error, 'Ошибка API/WS')}</p>
|
||||||
`;
|
`;
|
||||||
screen.append(info);
|
screen.append(info);
|
||||||
|
|
||||||
renderBody(screen, navigate, findMockChannel(channelId || 'ch1'), {
|
const back = document.createElement('button');
|
||||||
onToggleLike: async () => {},
|
back.className = 'secondary-btn';
|
||||||
onReply: async () => {},
|
back.textContent = 'Назад к каналам';
|
||||||
onAddPost: async (post) => {
|
back.addEventListener('click', () => navigate('channels-list'));
|
||||||
addLocalChannelPost(channelId || 'ch1', post);
|
screen.append(back);
|
||||||
},
|
}
|
||||||
onUnfollowChannel: () => {},
|
|
||||||
|
function applyPendingScroll(screen, routeKey) {
|
||||||
|
const target = pendingScrollByRoute.get(routeKey);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
const doScroll = () => {
|
||||||
|
if (target === '__LAST__') {
|
||||||
|
const cards = screen.querySelectorAll('[data-message-key]');
|
||||||
|
const last = cards[cards.length - 1];
|
||||||
|
if (last) {
|
||||||
|
last.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
pendingScrollByRoute.delete(routeKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = screen.querySelector(`[data-message-key="${target}"]`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
pendingScrollByRoute.delete(routeKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(doScroll, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'card stack channel-message-card';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'channel-message-title author-line';
|
||||||
|
|
||||||
|
const loginEl = document.createElement('span');
|
||||||
|
loginEl.className = 'author-line-login';
|
||||||
|
loginEl.textContent = post.authorLogin;
|
||||||
|
|
||||||
|
const numberEl = document.createElement('span');
|
||||||
|
numberEl.className = 'author-line-num';
|
||||||
|
numberEl.textContent = `· #${post.localNumber}`;
|
||||||
|
|
||||||
|
title.append(loginEl, numberEl);
|
||||||
|
|
||||||
|
const body = document.createElement('p');
|
||||||
|
body.className = 'channel-message-body';
|
||||||
|
body.textContent = post.body;
|
||||||
|
|
||||||
|
const stats = document.createElement('p');
|
||||||
|
stats.className = 'channel-message-stats';
|
||||||
|
stats.textContent = `Лайки: ${post.likesCount || 0}, ответы: ${post.repliesCount || 0}`;
|
||||||
|
|
||||||
|
card.append(title, body, stats);
|
||||||
|
|
||||||
|
const refKey = messageRefKey(post.messageRef);
|
||||||
|
if (refKey) {
|
||||||
|
card.dataset.messageKey = refKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post.messageRef || !selector) return card;
|
||||||
|
|
||||||
|
const actionKey = makeReactionActionKey(post.messageRef);
|
||||||
|
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'channel-message-actions';
|
||||||
|
|
||||||
|
const likeButton = document.createElement('button');
|
||||||
|
likeButton.type = 'button';
|
||||||
|
likeButton.className = 'secondary-btn channel-action-like';
|
||||||
|
const isLiked = post.reactionState === 'liked';
|
||||||
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
|
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
||||||
|
likeButton.disabled = isPending;
|
||||||
|
likeButton.addEventListener('click', async (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
if (isPending) return;
|
||||||
|
likeButton.disabled = true;
|
||||||
|
likeButton.textContent = 'Выполняется...';
|
||||||
|
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const replyButton = document.createElement('button');
|
||||||
|
replyButton.type = 'button';
|
||||||
|
replyButton.className = 'secondary-btn channel-action-reply';
|
||||||
|
replyButton.textContent = 'Ответить';
|
||||||
|
replyButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openReplyModal({
|
||||||
|
onSubmit: async (text) => onReply(post.messageRef, text),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openThreadButton = document.createElement('button');
|
||||||
|
openThreadButton.type = 'button';
|
||||||
|
openThreadButton.className = 'secondary-btn channel-action-thread';
|
||||||
|
openThreadButton.textContent = 'Открыть тред';
|
||||||
|
openThreadButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
const route = buildThreadRoute(post.messageRef, selector);
|
||||||
|
if (route) navigate(route);
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(likeButton, replyButton, openThreadButton);
|
||||||
|
card.append(actions);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||||
|
const head = document.createElement('div');
|
||||||
|
head.className = 'card channel-head-card';
|
||||||
|
|
||||||
|
const title = document.createElement('strong');
|
||||||
|
title.className = 'channel-head-title';
|
||||||
|
title.textContent = channelData.channel.displayName || channelData.channel.name;
|
||||||
|
|
||||||
|
const description = document.createElement('p');
|
||||||
|
description.className = 'channel-head-description';
|
||||||
|
const hasDescription = !!String(channelData.channel.description || '').trim();
|
||||||
|
if (hasDescription) {
|
||||||
|
description.textContent = channelData.channel.description;
|
||||||
|
} else if (channelData.isOwnChannel) {
|
||||||
|
description.textContent = 'Описание пока не задано.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = document.createElement('p');
|
||||||
|
owner.className = 'channel-head-meta';
|
||||||
|
owner.textContent = `Владелец: ${channelData.channel.ownerName}`;
|
||||||
|
|
||||||
|
const headActions = document.createElement('div');
|
||||||
|
headActions.className = 'row';
|
||||||
|
const aboutButton = document.createElement('button');
|
||||||
|
aboutButton.type = 'button';
|
||||||
|
aboutButton.className = 'secondary-btn small-btn';
|
||||||
|
aboutButton.textContent = 'О канале';
|
||||||
|
aboutButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openAboutChannelModal(channelData.channel);
|
||||||
|
});
|
||||||
|
headActions.append(aboutButton);
|
||||||
|
|
||||||
|
if (channelData.isOwnChannel) {
|
||||||
|
const editButton = document.createElement('button');
|
||||||
|
editButton.type = 'button';
|
||||||
|
editButton.className = 'secondary-btn small-btn';
|
||||||
|
editButton.textContent = '✎';
|
||||||
|
editButton.title = 'Редактировать описание';
|
||||||
|
editButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openEditDescriptionModal({
|
||||||
|
initialValue: channelData.channel.description || '',
|
||||||
|
onSubmit: async (nextValue) => handlers.onEditDescription(nextValue),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
headActions.append(editButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDescription) {
|
||||||
|
const moreButton = document.createElement('button');
|
||||||
|
moreButton.type = 'button';
|
||||||
|
moreButton.className = 'channel-head-more';
|
||||||
|
moreButton.textContent = 'ещё';
|
||||||
|
moreButton.addEventListener('click', () => {
|
||||||
|
description.classList.toggle('is-expanded');
|
||||||
|
moreButton.textContent = description.classList.contains('is-expanded') ? 'скрыть' : 'ещё';
|
||||||
|
});
|
||||||
|
headActions.append(moreButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
head.append(title);
|
||||||
|
if (hasDescription || channelData.isOwnChannel) head.append(description);
|
||||||
|
head.append(owner, headActions);
|
||||||
|
|
||||||
|
const actionButton = document.createElement('button');
|
||||||
|
actionButton.className = channelData.isOwnChannel
|
||||||
|
? 'primary-btn channel-main-action'
|
||||||
|
: 'destructive-btn channel-main-action';
|
||||||
|
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
|
||||||
|
|
||||||
|
const feed = document.createElement('div');
|
||||||
|
feed.className = 'stack channel-feed';
|
||||||
|
|
||||||
|
if (channelData.posts.length) {
|
||||||
|
channelData.posts.forEach((post) => {
|
||||||
|
feed.append(renderPostCard(post, {
|
||||||
|
navigate,
|
||||||
|
selector: channelData.selector,
|
||||||
|
onToggleLike: handlers.onToggleLike,
|
||||||
|
onReply: handlers.onReply,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'card meta-muted';
|
||||||
|
empty.textContent = 'Ждем ваших начинаний';
|
||||||
|
feed.append(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelData.isOwnChannel) {
|
||||||
|
actionButton.addEventListener('click', (event) => {
|
||||||
|
animatePress(event.currentTarget);
|
||||||
|
openAddMessageModal({
|
||||||
|
channelName: channelData.channel.name,
|
||||||
|
onSubmit: async (bodyText) => handlers.onAddPost(bodyText),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
actionButton.addEventListener('click', handlers.onUnfollowChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backButton = document.createElement('button');
|
||||||
|
backButton.className = 'secondary-btn channel-back-btn';
|
||||||
|
backButton.textContent = 'Назад к каналам';
|
||||||
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
|
screen.append(head, actionButton, feed, backButton);
|
||||||
|
applyPendingScroll(screen, routeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkeleton(screen) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'stack';
|
||||||
|
wrap.append(createSkeletonCard(), createSkeletonCard(), createSkeletonCard());
|
||||||
|
screen.append(wrap);
|
||||||
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render({ navigate, route }) {
|
export function render({ navigate, route }) {
|
||||||
const channelId = route.params.channelId || '';
|
const channelId = route.params.channelId || '';
|
||||||
const routeSelector = buildSelectorFromRoute(route, channelId);
|
const routeSelector = buildSelectorFromRoute(route, channelId);
|
||||||
|
const routeKey = `${routeSelector?.ownerBlockchainName || ''}:${routeSelector?.channelRootBlockNumber || ''}:${routeSelector?.channelRootBlockHash || ''}`;
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack channels-screen channels-screen--channel';
|
screen.className = 'stack channels-screen channels-screen--channel';
|
||||||
|
|
||||||
const fallbackName = channels.find((c) => c.id === channelId)?.name || 'Канал';
|
|
||||||
const titleFromIndex = state.channelsIndex[channelId]?.channel?.channelName;
|
const titleFromIndex = state.channelsIndex[channelId]?.channel?.channelName;
|
||||||
const ownerFromIndex = state.channelsIndex[channelId]?.channel?.ownerLogin;
|
const ownerFromIndex = state.channelsIndex[channelId]?.channel?.ownerLogin;
|
||||||
const titleFromIndexDisplay = (ownerFromIndex && titleFromIndex) ? `${ownerFromIndex}/${titleFromIndex}` : titleFromIndex;
|
const titleFromIndexDisplay = (ownerFromIndex && titleFromIndex) ? `${ownerFromIndex}/${titleFromIndex}` : titleFromIndex;
|
||||||
const titleFromRoute = route.params.ownerBlockchainName ? String(route.params.ownerBlockchainName) : '';
|
const titleFromRoute = route.params.ownerBlockchainName ? String(route.params.ownerBlockchainName) : '';
|
||||||
const headerTitle = `Канал: ${titleFromIndexDisplay || titleFromRoute || fallbackName}`;
|
const headerTitle = `Канал: ${titleFromIndexDisplay || titleFromRoute || 'Канал'}`;
|
||||||
|
|
||||||
const userIndicator = document.createElement('div');
|
const userIndicator = document.createElement('div');
|
||||||
userIndicator.className = 'card channels-user-chip';
|
userIndicator.className = 'card channels-user-chip';
|
||||||
@ -465,13 +738,6 @@ export function render({ navigate, route }) {
|
|||||||
statusBox.className = 'card status-line is-unavailable channels-status';
|
statusBox.className = 'card status-line is-unavailable channels-status';
|
||||||
statusBox.style.display = 'none';
|
statusBox.style.display = 'none';
|
||||||
|
|
||||||
const rerender = () => {
|
|
||||||
const current = document.querySelector('section.channels-screen--channel');
|
|
||||||
if (!current) return;
|
|
||||||
const next = render({ navigate, route });
|
|
||||||
current.replaceWith(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showStatus = (message) => {
|
const showStatus = (message) => {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
statusBox.style.display = 'none';
|
statusBox.style.display = 'none';
|
||||||
@ -482,6 +748,13 @@ export function render({ navigate, route }) {
|
|||||||
statusBox.style.display = '';
|
statusBox.style.display = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rerender = () => {
|
||||||
|
const current = document.querySelector('section.channels-screen--channel');
|
||||||
|
if (!current) return;
|
||||||
|
const next = render({ navigate, route });
|
||||||
|
current.replaceWith(next);
|
||||||
|
};
|
||||||
|
|
||||||
const requireSigningSession = () => {
|
const requireSigningSession = () => {
|
||||||
const login = state.session.login;
|
const login = state.session.login;
|
||||||
const storagePwd = state.session.storagePwdInMemory;
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
@ -491,10 +764,6 @@ export function render({ navigate, route }) {
|
|||||||
return { login, storagePwd };
|
return { login, storagePwd };
|
||||||
};
|
};
|
||||||
|
|
||||||
const rereadChannel = async () => {
|
|
||||||
await loadFromApi(route, channelId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onToggleLike = async (messageRef, action) => {
|
const onToggleLike = async (messageRef, action) => {
|
||||||
const actionKey = makeReactionActionKey(messageRef);
|
const actionKey = makeReactionActionKey(messageRef);
|
||||||
if (!actionKey) {
|
if (!actionKey) {
|
||||||
@ -502,8 +771,10 @@ export function render({ navigate, route }) {
|
|||||||
}
|
}
|
||||||
if (pendingReactionActions.has(actionKey)) return;
|
if (pendingReactionActions.has(actionKey)) return;
|
||||||
|
|
||||||
|
const previousReaction = getMessageReactionState(messageRef);
|
||||||
|
const nextReaction = action === 'unlike' ? 'unliked' : 'liked';
|
||||||
pendingReactionActions.add(actionKey);
|
pendingReactionActions.add(actionKey);
|
||||||
rerender();
|
|
||||||
try {
|
try {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
if (action === 'unlike') {
|
if (action === 'unlike') {
|
||||||
@ -511,22 +782,31 @@ export function render({ navigate, route }) {
|
|||||||
} else {
|
} else {
|
||||||
await authService.addBlockLike({ login, storagePwd, message: messageRef });
|
await authService.addBlockLike({ login, storagePwd, message: messageRef });
|
||||||
}
|
}
|
||||||
await rereadChannel();
|
setMessageReactionState(messageRef, nextReaction);
|
||||||
showStatus('');
|
softHaptic(10);
|
||||||
|
rerender();
|
||||||
|
} catch (error) {
|
||||||
|
setMessageReactionState(messageRef, previousReaction || 'unliked');
|
||||||
|
rerender();
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
pendingReactionActions.delete(actionKey);
|
pendingReactionActions.delete(actionKey);
|
||||||
rerender();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onReply = async (messageRef, text) => {
|
const onReply = async (messageRef, text) => {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
|
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
|
||||||
await rereadChannel();
|
|
||||||
|
const scrollTarget = messageRefKey(messageRef);
|
||||||
|
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
|
||||||
|
|
||||||
|
softHaptic(15);
|
||||||
|
showToast('Ответ отправлен');
|
||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddPost = async (post) => {
|
const onAddPost = async (bodyText) => {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
|
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
|
||||||
throw new Error('Идентификатор канала не готов.');
|
throw new Error('Идентификатор канала не готов.');
|
||||||
@ -536,9 +816,31 @@ export function render({ navigate, route }) {
|
|||||||
login,
|
login,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
channel: routeSelector,
|
channel: routeSelector,
|
||||||
text: post?.body || '',
|
text: bodyText,
|
||||||
});
|
});
|
||||||
await rereadChannel();
|
|
||||||
|
pendingScrollByRoute.set(routeKey, '__LAST__');
|
||||||
|
softHaptic(15);
|
||||||
|
showToast('Сообщение отправлено');
|
||||||
|
rerender();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditDescription = async (descriptionText) => {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
const selector = routeSelector;
|
||||||
|
const param = channelDescriptionParamKey(selector);
|
||||||
|
if (!param) throw new Error('Идентификатор канала не готов для обновления описания.');
|
||||||
|
|
||||||
|
const value = JSON.stringify({ v: String(descriptionText || '').trim() });
|
||||||
|
await authService.addBlockUserParam({
|
||||||
|
login,
|
||||||
|
storagePwd,
|
||||||
|
param,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
softHaptic(10);
|
||||||
|
showToast('Описание канала сохранено');
|
||||||
rerender();
|
rerender();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -546,23 +848,21 @@ export function render({ navigate, route }) {
|
|||||||
renderHeader({
|
renderHeader({
|
||||||
title: headerTitle,
|
title: headerTitle,
|
||||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
screen.append(userIndicator, statusBox);
|
screen.append(userIndicator, statusBox);
|
||||||
|
|
||||||
const loading = document.createElement('div');
|
const skeleton = renderSkeleton(screen);
|
||||||
loading.className = 'card meta-muted';
|
|
||||||
loading.textContent = 'Загрузка канала...';
|
|
||||||
screen.append(loading);
|
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const apiData = await loadFromApi(route, channelId);
|
const apiData = await loadFromApi(route, channelId);
|
||||||
loading.remove();
|
skeleton.remove();
|
||||||
renderBody(screen, navigate, apiData, {
|
renderBody(screen, navigate, routeKey, apiData, {
|
||||||
onToggleLike: async (messageRef, action) => {
|
onToggleLike: async (messageRef, action) => {
|
||||||
try {
|
try {
|
||||||
await onToggleLike(messageRef, action);
|
await onToggleLike(messageRef, action);
|
||||||
|
showStatus('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showStatus(toUserMessage(error, action === 'unlike' ? 'Не удалось убрать лайк.' : 'Не удалось поставить лайк.'));
|
showStatus(toUserMessage(error, action === 'unlike' ? 'Не удалось убрать лайк.' : 'Не удалось поставить лайк.'));
|
||||||
}
|
}
|
||||||
@ -575,15 +875,24 @@ export function render({ navigate, route }) {
|
|||||||
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
|
throw new Error(toUserMessage(error, 'Не удалось отправить ответ.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAddPost: async (post) => {
|
onAddPost: async (bodyText) => {
|
||||||
try {
|
try {
|
||||||
await onAddPost(post);
|
await onAddPost(bodyText);
|
||||||
showStatus('');
|
showStatus('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUnfollowChannel: async () => {
|
onEditDescription: async (descriptionText) => {
|
||||||
|
try {
|
||||||
|
await onEditDescription(descriptionText);
|
||||||
|
showStatus('');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUnfollowChannel: async (event) => {
|
||||||
|
animatePress(event?.currentTarget);
|
||||||
try {
|
try {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
const { login, storagePwd } = requireSigningSession();
|
||||||
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
|
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
|
||||||
@ -597,6 +906,8 @@ export function render({ navigate, route }) {
|
|||||||
unfollow: true,
|
unfollow: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
softHaptic(15);
|
||||||
|
showToast('Отписка от канала выполнена');
|
||||||
navigate('channels-list');
|
navigate('channels-list');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
|
||||||
@ -604,9 +915,9 @@ export function render({ navigate, route }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loading.remove();
|
skeleton.remove();
|
||||||
if (isChannelsDemoMode()) {
|
if (isChannelsDemoMode()) {
|
||||||
renderDemoFallback(screen, navigate, channelId, error);
|
renderDemoFallback(screen, navigate, error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
|
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,10 @@
|
|||||||
import { clearStartHint, state } from '../state.js';
|
import { clearStartHint } from '../state.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
|
export const pageMeta = { id: 'start-view', title: 'Старт', showAppChrome: false };
|
||||||
|
|
||||||
export function render({ navigate }) {
|
export function render({ navigate }) {
|
||||||
|
clearStartHint();
|
||||||
|
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'auth-screen stack';
|
screen.className = 'auth-screen stack';
|
||||||
|
|
||||||
@ -37,30 +39,6 @@ export function render({ navigate }) {
|
|||||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||||
|
|
||||||
actions.append(loginButton, registerButton, settingsButton);
|
actions.append(loginButton, registerButton, settingsButton);
|
||||||
|
screen.append(logo, title, actions);
|
||||||
screen.append(logo, title);
|
|
||||||
|
|
||||||
const help = document.createElement('div');
|
|
||||||
help.className = 'card auth-status-card';
|
|
||||||
help.innerHTML = `
|
|
||||||
<strong>Локальный тест SHiNE</strong>
|
|
||||||
<p class="meta-muted" style="margin-top:6px;">
|
|
||||||
1) Локально: <code>?localWsPort=7071</code>; через tunnel: <code>?wsUrl=wss://.../ws</code>.<br />
|
|
||||||
2) Зарегистрируйте пользователя A, затем пользователя B.<br />
|
|
||||||
3) Войдите под A, создайте 2 канала и сообщения.<br />
|
|
||||||
4) Войдите под B и проверьте каналы, лайк/анлайк и подписки/отписки.
|
|
||||||
</p>
|
|
||||||
`;
|
|
||||||
screen.append(help);
|
|
||||||
|
|
||||||
if (state.startHint) {
|
|
||||||
const notice = document.createElement('div');
|
|
||||||
notice.className = 'card auth-status-card';
|
|
||||||
notice.textContent = state.startHint;
|
|
||||||
screen.append(notice);
|
|
||||||
clearStartHint();
|
|
||||||
}
|
|
||||||
|
|
||||||
screen.append(actions);
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ 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;
|
||||||
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
|
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
|
||||||
|
const CREATE_CHANNEL_BODY_VERSION = 2;
|
||||||
|
|
||||||
function normalizeServerUrl(url) {
|
function normalizeServerUrl(url) {
|
||||||
const value = (url || '').trim();
|
const value = (url || '').trim();
|
||||||
@ -77,6 +78,17 @@ function opError(op, response) {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLegacyCreateChannelFormatError(error) {
|
||||||
|
const code = String(error?.code || '').trim().toUpperCase();
|
||||||
|
const text = String(error?.message || '').toLowerCase();
|
||||||
|
if (code === 'BAD_BLOCK_FORMAT') return true;
|
||||||
|
return (
|
||||||
|
text.includes('unknown body type/version') ||
|
||||||
|
text.includes('unknown tech body type/version/subtype') ||
|
||||||
|
text.includes('bad_block_format')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function makeClientInfo() {
|
function makeClientInfo() {
|
||||||
const ua = navigator.userAgent || 'unknown';
|
const ua = navigator.userAgent || 'unknown';
|
||||||
return ua.slice(0, 50);
|
return ua.slice(0, 50);
|
||||||
@ -267,6 +279,50 @@ function makeCreateChannelBodyBytes({ lineCode, prevLineNumber, prevLineHashHex,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeChannelDescription(value) {
|
||||||
|
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||||||
|
const bytes = utf8Bytes(text);
|
||||||
|
if (bytes.length > 200) {
|
||||||
|
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCreateChannelBodyV2Bytes({
|
||||||
|
lineCode,
|
||||||
|
prevLineNumber,
|
||||||
|
prevLineHashHex,
|
||||||
|
thisLineNumber,
|
||||||
|
channelName,
|
||||||
|
channelDescription = '',
|
||||||
|
}) {
|
||||||
|
const check = validateChannelDisplayName(channelName);
|
||||||
|
if (!check.ok) throw new Error(channelNameErrorText(check.code));
|
||||||
|
const cleanName = check.normalized;
|
||||||
|
const cleanDescription = normalizeChannelDescription(channelDescription);
|
||||||
|
|
||||||
|
const nameBytes = utf8Bytes(cleanName);
|
||||||
|
if (nameBytes.length < 1 || nameBytes.length > 255) {
|
||||||
|
throw new Error('Channel name must be 1..255 bytes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptionBytes = utf8Bytes(cleanDescription);
|
||||||
|
if (descriptionBytes.length > 200) {
|
||||||
|
throw new Error('Описание канала слишком длинное: максимум 200 символов.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return concatBytes(
|
||||||
|
int32Bytes(lineCode),
|
||||||
|
int32Bytes(prevLineNumber),
|
||||||
|
hexToBytes(normalizeHex32(prevLineHashHex)),
|
||||||
|
int32Bytes(thisLineNumber),
|
||||||
|
int8Byte(nameBytes.length),
|
||||||
|
nameBytes,
|
||||||
|
int16Bytes(descriptionBytes.length),
|
||||||
|
descriptionBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
function makeTextPostBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, text }) {
|
||||||
const message = String(text || '').trim();
|
const message = String(text || '').trim();
|
||||||
if (!message) throw new Error('Message text is required');
|
if (!message) throw new Error('Message text is required');
|
||||||
@ -828,7 +884,7 @@ export class AuthService {
|
|||||||
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
|
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async addBlockCreateChannel({ login, channelName, storagePwd }) {
|
async addBlockCreateChannel({ login, channelName, channelDescription = '', storagePwd }) {
|
||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = (login || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Missing login');
|
if (!cleanLogin) throw new Error('Missing login');
|
||||||
|
|
||||||
@ -866,7 +922,17 @@ export class AuthService {
|
|||||||
thisLineNumber = createdChannels.length + 1;
|
thisLineNumber = createdChannels.length + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyBytes = makeCreateChannelBodyBytes({
|
const submitCreate = async (useV2) => {
|
||||||
|
const bodyBytes = useV2
|
||||||
|
? makeCreateChannelBodyV2Bytes({
|
||||||
|
lineCode: 0,
|
||||||
|
prevLineNumber,
|
||||||
|
prevLineHashHex,
|
||||||
|
thisLineNumber,
|
||||||
|
channelName: cleanChannelName,
|
||||||
|
channelDescription,
|
||||||
|
})
|
||||||
|
: makeCreateChannelBodyBytes({
|
||||||
lineCode: 0,
|
lineCode: 0,
|
||||||
prevLineNumber,
|
prevLineNumber,
|
||||||
prevLineHashHex,
|
prevLineHashHex,
|
||||||
@ -874,17 +940,29 @@ export class AuthService {
|
|||||||
channelName: cleanChannelName,
|
channelName: cleanChannelName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = await this.addBlockSigned({
|
return this.addBlockSigned({
|
||||||
login: cleanLogin,
|
login: cleanLogin,
|
||||||
storagePwd,
|
storagePwd,
|
||||||
msgType: MSG_TYPE_TECH,
|
msgType: MSG_TYPE_TECH,
|
||||||
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
|
||||||
msgVersion: 1,
|
msgVersion: useV2 ? CREATE_CHANNEL_BODY_VERSION : 1,
|
||||||
bodyBytes,
|
bodyBytes,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
let usedLegacyDescriptionFallback = false;
|
||||||
|
try {
|
||||||
|
payload = await submitCreate(true);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isLegacyCreateChannelFormatError(error)) throw error;
|
||||||
|
payload = await submitCreate(false);
|
||||||
|
usedLegacyDescriptionFallback = true;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
|
usedLegacyDescriptionFallback,
|
||||||
channel: {
|
channel: {
|
||||||
ownerBlockchainName: blockchainName,
|
ownerBlockchainName: blockchainName,
|
||||||
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
channelRootBlockNumber: Number(payload?.serverLastGlobalNumber),
|
||||||
|
|||||||
@ -7,6 +7,11 @@ export function normalizeChannelDisplayName(value) {
|
|||||||
return String(value).trim().replace(/\s+/g, ' ');
|
return String(value).trim().replace(/\s+/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeChannelDescription(value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value).trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
export function toCanonicalChannelSlug(value) {
|
export function toCanonicalChannelSlug(value) {
|
||||||
const normalized = normalizeChannelDisplayName(value);
|
const normalized = normalizeChannelDisplayName(value);
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
@ -76,4 +81,10 @@ export function channelNameErrorText(code) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function channelDescriptionErrorText(value) {
|
||||||
|
const normalized = normalizeChannelDescription(value);
|
||||||
|
if (new TextEncoder().encode(normalized).length > 200) {
|
||||||
|
return 'Описание слишком длинное: максимум 200 байт UTF-8.';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|||||||
170
shine-UI/js/services/channels-ux.js
Normal file
170
shine-UI/js/services/channels-ux.js
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
const TOAST_HOST_ID = 'shine-toast-host';
|
||||||
|
|
||||||
|
const rtf = (() => {
|
||||||
|
try {
|
||||||
|
return new Intl.RelativeTimeFormat('ru', { numeric: 'auto' });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickUnit(seconds) {
|
||||||
|
const abs = Math.abs(seconds);
|
||||||
|
if (abs < 60) return ['second', Math.round(seconds)];
|
||||||
|
const minutes = seconds / 60;
|
||||||
|
if (Math.abs(minutes) < 60) return ['minute', Math.round(minutes)];
|
||||||
|
const hours = minutes / 60;
|
||||||
|
if (Math.abs(hours) < 24) return ['hour', Math.round(hours)];
|
||||||
|
const days = hours / 24;
|
||||||
|
if (Math.abs(days) < 30) return ['day', Math.round(days)];
|
||||||
|
const months = days / 30;
|
||||||
|
if (Math.abs(months) < 12) return ['month', Math.round(months)];
|
||||||
|
const years = months / 12;
|
||||||
|
return ['year', Math.round(years)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(timestampMs) {
|
||||||
|
const ts = toNumber(timestampMs);
|
||||||
|
if (!ts) return '—';
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const diffSeconds = (ts - now) / 1000;
|
||||||
|
const ageSeconds = now >= ts ? (now - ts) / 1000 : 0;
|
||||||
|
const ageHours = ageSeconds / 3600;
|
||||||
|
|
||||||
|
if (ageHours <= 10) {
|
||||||
|
const [unit, value] = pickUnit(diffSeconds);
|
||||||
|
if (rtf) return rtf.format(value, unit);
|
||||||
|
|
||||||
|
const absValue = Math.abs(value);
|
||||||
|
const suffix = value <= 0 ? 'назад' : 'через';
|
||||||
|
const labels = {
|
||||||
|
second: 'сек',
|
||||||
|
minute: 'мин',
|
||||||
|
hour: 'ч',
|
||||||
|
day: 'д',
|
||||||
|
month: 'мес',
|
||||||
|
year: 'г',
|
||||||
|
};
|
||||||
|
return `${suffix} ${absValue} ${labels[unit] || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dt = new Date(ts);
|
||||||
|
const nowDt = new Date(now);
|
||||||
|
const formatter = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
...(dt.getFullYear() !== nowDt.getFullYear() ? { year: 'numeric' } : {}),
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
return formatter.format(dt);
|
||||||
|
} catch {
|
||||||
|
return new Date(ts).toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureToastHost() {
|
||||||
|
let host = document.getElementById(TOAST_HOST_ID);
|
||||||
|
if (host) return host;
|
||||||
|
|
||||||
|
host = document.createElement('div');
|
||||||
|
host.id = TOAST_HOST_ID;
|
||||||
|
host.className = 'toast-host';
|
||||||
|
document.body.append(host);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showToast(message, { kind = 'success', timeoutMs = 2500 } = {}) {
|
||||||
|
const text = String(message || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const host = ensureToastHost();
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast--${kind}`;
|
||||||
|
toast.textContent = text;
|
||||||
|
host.append(toast);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toast.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
toast.classList.remove('is-visible');
|
||||||
|
toast.classList.add('is-hiding');
|
||||||
|
setTimeout(() => toast.remove(), 220);
|
||||||
|
};
|
||||||
|
|
||||||
|
setTimeout(hide, Math.max(1200, Number(timeoutMs) || 2500));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function softHaptic(duration = 15) {
|
||||||
|
try {
|
||||||
|
if (navigator?.vibrate) navigator.vibrate(Math.max(5, Math.min(30, Number(duration) || 15)));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function animatePress(el) {
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('is-springing');
|
||||||
|
// force reflow
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
el.offsetWidth;
|
||||||
|
el.classList.add('is-springing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_NOTIF_KEY = 'shine-channels-notify-v1';
|
||||||
|
|
||||||
|
export function readChannelNotificationsState() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CHANNEL_NOTIF_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeChannelNotificationsState(nextState) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CHANNEL_NOTIF_KEY, JSON.stringify(nextState || {}));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeAuthorLabel(login, localNumber) {
|
||||||
|
const cleanLogin = String(login || 'автор');
|
||||||
|
const n = Number(localNumber);
|
||||||
|
if (!Number.isFinite(n) || n < 1) return cleanLogin;
|
||||||
|
return `${cleanLogin} · #${n}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSkeletonCard(className = '') {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `card skeleton-card ${className}`.trim();
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="skeleton-line w-40"></div>
|
||||||
|
<div class="skeleton-line w-90"></div>
|
||||||
|
<div class="skeleton-line w-70"></div>
|
||||||
|
`;
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeChannelDescription(value) {
|
||||||
|
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
|
||||||
|
if (!text) return '';
|
||||||
|
const chars = Array.from(text);
|
||||||
|
if (chars.length <= 200) return text;
|
||||||
|
return chars.slice(0, 200).join('');
|
||||||
|
}
|
||||||
@ -1216,6 +1216,10 @@ textarea.input {
|
|||||||
color: #f5daa0;
|
color: #f5daa0;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-row-description {
|
.channel-row-description {
|
||||||
@ -1231,6 +1235,10 @@ textarea.input {
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-row-owner {
|
.channel-row-owner {
|
||||||
@ -1519,3 +1527,371 @@ textarea.input {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Channels UX Stabilization ===== */
|
||||||
|
.toast-host {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(18px + env(safe-area-inset-bottom));
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 60;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
min-width: min(88vw, 320px);
|
||||||
|
max-width: min(92vw, 420px);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 1px solid rgba(223, 188, 110, 0.45);
|
||||||
|
color: #f2dca8;
|
||||||
|
background: rgba(10, 14, 23, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 16px 30px rgba(1, 6, 12, 0.55);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.is-hiding {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.toast--error {
|
||||||
|
color: #ffd8e0;
|
||||||
|
border-color: rgba(234, 122, 150, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(100deg, rgba(180, 151, 80, 0.16), rgba(228, 192, 109, 0.45), rgba(180, 151, 80, 0.16));
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: channels-shimmer 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line.w-40 { width: 40%; }
|
||||||
|
.skeleton-line.w-70 { width: 70%; }
|
||||||
|
.skeleton-line.w-90 { width: 90%; }
|
||||||
|
|
||||||
|
@keyframes channels-shimmer {
|
||||||
|
0% { background-position: 210% 0; }
|
||||||
|
100% { background-position: -10% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(188, 152, 79, 0.34);
|
||||||
|
background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-tab-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(122, 151, 210, 0.26);
|
||||||
|
background: rgba(18, 33, 62, 0.55);
|
||||||
|
color: #b8c9ec;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-tab-btn.is-active {
|
||||||
|
border-color: rgba(218, 183, 100, 0.48);
|
||||||
|
color: #f4d99e;
|
||||||
|
background: linear-gradient(160deg, rgba(193, 154, 76, 0.22), rgba(18, 33, 62, 0.64));
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-empty-state {
|
||||||
|
border: 1px dashed rgba(199, 164, 90, 0.36);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
color: #b7c6e8;
|
||||||
|
background: rgba(10, 18, 34, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-empty-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #d9b56d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row {
|
||||||
|
position: relative;
|
||||||
|
grid-template-columns: 46px minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-main {
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-description {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-row-controls {
|
||||||
|
display: grid;
|
||||||
|
justify-items: end;
|
||||||
|
align-content: start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-menu-trigger {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(185, 154, 83, 0.42);
|
||||||
|
background: rgba(14, 25, 48, 0.82);
|
||||||
|
color: #efd9a4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-menu-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 52px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 25;
|
||||||
|
width: min(240px, 70vw);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(206, 170, 90, 0.38);
|
||||||
|
background: rgba(10, 14, 23, 0.8);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
box-shadow: 0 16px 32px rgba(1, 5, 12, 0.62);
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-menu-item {
|
||||||
|
border: 1px solid rgba(125, 154, 212, 0.34);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(17, 31, 56, 0.72);
|
||||||
|
color: #e6efff;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-menu-item.destructive {
|
||||||
|
border-color: rgba(235, 120, 147, 0.46);
|
||||||
|
color: #ffdfe7;
|
||||||
|
background: rgba(84, 20, 38, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-menu-item:disabled {
|
||||||
|
opacity: 0.68;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-menu-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
color: #d9e6ff;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-toggle-btn {
|
||||||
|
width: 48px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(146, 171, 225, 0.45);
|
||||||
|
background: rgba(27, 43, 75, 0.84);
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-toggle-btn::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d9e6ff;
|
||||||
|
transition: transform 0.24s cubic-bezier(.2,.9,.3,1.15), background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-toggle-btn.is-on {
|
||||||
|
background: rgba(211, 173, 92, 0.34);
|
||||||
|
border-color: rgba(219, 182, 101, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-toggle-btn.is-on::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
background: #f4dca6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-head-description {
|
||||||
|
margin: 0;
|
||||||
|
color: #c8d7f6;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-head-description.is-expanded {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-head-more {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #efcf8b;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
width: fit-content;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-line {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-line-login {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #f3dbab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-line-num {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #95a8d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-message-card.is-own-new,
|
||||||
|
.thread-node-card.is-own-new {
|
||||||
|
box-shadow: 0 0 0 1px rgba(217, 180, 97, 0.5), 0 12px 24px rgba(2, 8, 16, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-springing {
|
||||||
|
animation: spring-tap 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spring-tap {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
30% { transform: scale(0.95); }
|
||||||
|
60% { transform: scale(1.06); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.channel-message-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread-node-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-action-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Channels cleanup overrides ===== */
|
||||||
|
.screen-content.channels-scroll-clean {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen-content.channels-scroll-clean::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-tabs--sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 8;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-list-content {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 42vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-list-body-fade {
|
||||||
|
animation: channels-fade-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes channels-fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-menu-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 45;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-menu-wrap--portal {
|
||||||
|
position: fixed;
|
||||||
|
right: auto;
|
||||||
|
top: auto;
|
||||||
|
z-index: 46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channels-search-suggest {
|
||||||
|
max-height: 172px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid rgba(150, 174, 224, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(9, 18, 35, 0.94);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-search-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid rgba(132, 162, 224, 0.28);
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgba(16, 30, 56, 0.78);
|
||||||
|
color: #e5eeff;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-search-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-search-item:hover {
|
||||||
|
border-color: rgba(216, 178, 95, 0.52);
|
||||||
|
color: #f3dca8;
|
||||||
|
}
|
||||||
|
|||||||
@ -3,8 +3,7 @@ package blockchain;
|
|||||||
import blockchain.body.*;
|
import blockchain.body.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Парсер body выбирает класс по header: type/subType/version,
|
* Parser for body record by header type/subType/version.
|
||||||
* потому что bodyBytes больше НЕ содержат type/subType/version.
|
|
||||||
*/
|
*/
|
||||||
public final class BodyRecordParser {
|
public final class BodyRecordParser {
|
||||||
|
|
||||||
@ -15,25 +14,26 @@ public final class BodyRecordParser {
|
|||||||
|
|
||||||
int t = type & 0xFFFF;
|
int t = type & 0xFFFF;
|
||||||
int v = version & 0xFFFF;
|
int v = version & 0xFFFF;
|
||||||
|
int st = subType & 0xFFFF;
|
||||||
|
|
||||||
|
// TECH supports Header v1 and CreateChannel v1/v2.
|
||||||
|
if (t == (CreateChannelBody.TYPE & 0xFFFF)) {
|
||||||
|
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF) && v == (HeaderBody.VER & 0xFFFF)) {
|
||||||
|
return new HeaderBody(subType, version, bodyBytes).check();
|
||||||
|
}
|
||||||
|
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)
|
||||||
|
&& (v == (CreateChannelBody.VER & 0xFFFF) || v == (CreateChannelBody.VER2 & 0xFFFF))) {
|
||||||
|
return new CreateChannelBody(subType, version, bodyBytes).check();
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int key = (t << 16) | v;
|
int key = (t << 16) | v;
|
||||||
|
|
||||||
BodyRecord r = switch (key) {
|
BodyRecord r = switch (key) {
|
||||||
case HeaderBody.KEY -> {
|
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF)) {
|
|
||||||
yield new HeaderBody(subType, version, bodyBytes);
|
|
||||||
}
|
|
||||||
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)) {
|
|
||||||
yield new CreateChannelBody(subType, version, bodyBytes);
|
|
||||||
}
|
|
||||||
throw new IllegalArgumentException("Unknown TECH subType for type=0 ver=1: subType=" + st);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TEXT type=1 ver=1: выбираем класс по subType
|
|
||||||
case TextBody.KEY -> {
|
case TextBody.KEY -> {
|
||||||
int st = subType & 0xFFFF;
|
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
if (st == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
|| st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
yield new TextLineBody(subType, version, bodyBytes);
|
yield new TextLineBody(subType, version, bodyBytes);
|
||||||
@ -53,7 +53,7 @@ public final class BodyRecordParser {
|
|||||||
|
|
||||||
default -> throw new IllegalArgumentException(String.format(
|
default -> throw new IllegalArgumentException(String.format(
|
||||||
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
"Unknown body type/version from header: type=%d ver=%d subType=%d",
|
||||||
t, v, (subType & 0xFFFF)
|
t, v, st
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -6,56 +6,54 @@ import java.nio.ByteBuffer;
|
|||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.regex.Pattern;
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CreateChannelBody — TECH сообщение создания канала.
|
* TECH body for create channel.
|
||||||
*
|
*
|
||||||
* type=0, ver=1 (в заголовке блока)
|
* v1 body bytes:
|
||||||
* subType=MsgSubType.TECH_CREATE_CHANNEL (=1)
|
* [4] lineCode
|
||||||
*
|
|
||||||
* Это сообщение идёт по ТЕХ-ЛИНИИ (hasLine):
|
|
||||||
* - prevLineNumber/hash указывают на предыдущее TECH-сообщение (HEADER или прошлый CREATE_CHANNEL)
|
|
||||||
* - thisLineNumber: 1,2,3... (тех-нумерация)
|
|
||||||
*
|
|
||||||
* bodyBytes (BigEndian), новый формат line-prefix:
|
|
||||||
* [4] lineCode (для TECH линии обычно 0)
|
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
* [4] thisLineNumber
|
* [4] thisLineNumber
|
||||||
* [1] channelNameLen (uint8)
|
* [1] channelNameLen
|
||||||
* [N] channelName UTF-8 (^[A-Za-z0-9_]+$)
|
* [N] channelName UTF-8
|
||||||
*
|
*
|
||||||
* Важно:
|
* v2 body bytes:
|
||||||
* - канал "0" зарезервирован (создаётся по умолчанию от HEADER), создавать его нельзя.
|
* [4] lineCode
|
||||||
|
* [4] prevLineNumber
|
||||||
|
* [32] prevLineHash32
|
||||||
|
* [4] thisLineNumber
|
||||||
|
* [1] channelNameLen
|
||||||
|
* [N] channelName UTF-8
|
||||||
|
* [2] channelDescriptionLen
|
||||||
|
* [M] channelDescription UTF-8 (0..200 bytes)
|
||||||
*/
|
*/
|
||||||
public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
||||||
|
|
||||||
public static final short TYPE = 0;
|
public static final short TYPE = 0;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
public static final short VER2 = 2;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
public static final int KEY_V2 = ((TYPE & 0xFFFF) << 16) | (VER2 & 0xFFFF);
|
||||||
|
|
||||||
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
|
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
|
||||||
|
|
||||||
private static final byte[] ZERO32 = new byte[32];
|
private static final byte[] ZERO32 = new byte[32];
|
||||||
private static final int MIN_NAME_LENGTH = 3;
|
|
||||||
private static final int MAX_NAME_LENGTH = 32;
|
private static final int MAX_NAME_LENGTH = 32;
|
||||||
private static final Pattern ALLOWED_NAME_PATTERN =
|
private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
|
||||||
Pattern.compile("^[\\p{IsLatin}\\p{IsCyrillic}0-9 _-]+$");
|
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // из header
|
public final short version;
|
||||||
|
|
||||||
// line
|
|
||||||
public final int lineCode;
|
public final int lineCode;
|
||||||
public final int prevLineNumber;
|
public final int prevLineNumber;
|
||||||
public final byte[] prevLineHash32; // 32
|
public final byte[] prevLineHash32;
|
||||||
public final int thisLineNumber;
|
public final int thisLineNumber;
|
||||||
|
|
||||||
// payload
|
|
||||||
public final String channelName;
|
public final String channelName;
|
||||||
|
public final String channelDescription;
|
||||||
|
|
||||||
public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
|
public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
@ -63,14 +61,14 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
int ver = this.version & 0xFFFF;
|
||||||
throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + (this.version & 0xFFFF));
|
if (ver != (VER & 0xFFFF) && ver != (VER2 & 0xFFFF)) {
|
||||||
|
throw new IllegalArgumentException("CreateChannelBody version must be 1 or 2, got=" + ver);
|
||||||
}
|
}
|
||||||
if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
|
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
|
||||||
// минимум: lineCode(4) + line(4+32+4) + nameLen(1) + name(1)
|
|
||||||
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
|
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
|
||||||
throw new IllegalArgumentException("CreateChannelBody too short");
|
throw new IllegalArgumentException("CreateChannelBody too short");
|
||||||
}
|
}
|
||||||
@ -78,7 +76,6 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
this.lineCode = bb.getInt();
|
this.lineCode = bb.getInt();
|
||||||
|
|
||||||
this.prevLineNumber = bb.getInt();
|
this.prevLineNumber = bb.getInt();
|
||||||
|
|
||||||
this.prevLineHash32 = new byte[32];
|
this.prevLineHash32 = new byte[32];
|
||||||
@ -88,16 +85,44 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int nameLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
|
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
|
||||||
if (bb.remaining() != nameLen) {
|
if (bb.remaining() < nameLen) {
|
||||||
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
|
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] nameBytes = new byte[nameLen];
|
||||||
bb.get(nameBytes);
|
bb.get(nameBytes);
|
||||||
|
|
||||||
this.channelName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.channelName = new String(nameBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
if (ver == (VER2 & 0xFFFF)) {
|
||||||
|
if (bb.remaining() < 2) {
|
||||||
|
throw new IllegalArgumentException("CreateChannelBody v2 missing channelDescriptionLen");
|
||||||
|
}
|
||||||
|
|
||||||
|
int descriptionLen = Short.toUnsignedInt(bb.getShort());
|
||||||
|
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||||
|
}
|
||||||
|
if (bb.remaining() != descriptionLen) {
|
||||||
|
throw new IllegalArgumentException("CreateChannelBody v2 tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptionLen == 0) {
|
||||||
|
this.channelDescription = "";
|
||||||
|
} else {
|
||||||
|
byte[] descriptionBytes = new byte[descriptionLen];
|
||||||
|
bb.get(descriptionBytes);
|
||||||
|
this.channelDescription = normalizeDescription(new String(descriptionBytes, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
if (bb.remaining() != 0) {
|
||||||
|
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.channelDescription = "";
|
||||||
|
if (bb.remaining() != 0) {
|
||||||
|
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public CreateChannelBody(int lineCode,
|
public CreateChannelBody(int lineCode,
|
||||||
@ -105,11 +130,30 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
byte[] prevLineHash32,
|
byte[] prevLineHash32,
|
||||||
int thisLineNumber,
|
int thisLineNumber,
|
||||||
String channelName) {
|
String channelName) {
|
||||||
|
this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, "", VER);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateChannelBody(int lineCode,
|
||||||
|
int prevLineNumber,
|
||||||
|
byte[] prevLineHash32,
|
||||||
|
int thisLineNumber,
|
||||||
|
String channelName,
|
||||||
|
String channelDescription) {
|
||||||
|
this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, channelDescription, VER2);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreateChannelBody(int lineCode,
|
||||||
|
int prevLineNumber,
|
||||||
|
byte[] prevLineHash32,
|
||||||
|
int thisLineNumber,
|
||||||
|
String channelName,
|
||||||
|
String channelDescription,
|
||||||
|
short version) {
|
||||||
Objects.requireNonNull(channelName, "channelName == null");
|
Objects.requireNonNull(channelName, "channelName == null");
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
|
|
||||||
this.subType = SUBTYPE;
|
this.subType = SUBTYPE;
|
||||||
this.version = VER;
|
this.version = version;
|
||||||
|
|
||||||
this.lineCode = lineCode;
|
this.lineCode = lineCode;
|
||||||
this.prevLineNumber = prevLineNumber;
|
this.prevLineNumber = prevLineNumber;
|
||||||
@ -117,32 +161,42 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
this.thisLineNumber = thisLineNumber;
|
this.thisLineNumber = thisLineNumber;
|
||||||
|
|
||||||
this.channelName = channelName;
|
this.channelName = channelName;
|
||||||
|
this.channelDescription = channelDescription == null ? "" : channelDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public CreateChannelBody check() {
|
public CreateChannelBody check() {
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
|
|
||||||
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF))
|
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
|
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
|
||||||
|
}
|
||||||
|
|
||||||
String normalizedName = normalizeDisplayName(channelName);
|
String normalizedName = normalizeDisplayName(channelName);
|
||||||
if (normalizedName.isEmpty())
|
if (normalizedName.isEmpty()) {
|
||||||
throw new IllegalArgumentException("channelName is blank");
|
throw new IllegalArgumentException("channelName is blank");
|
||||||
int cpLen = normalizedName.codePointCount(0, normalizedName.length());
|
}
|
||||||
// Backward compatibility for historical blocks:
|
|
||||||
// strict create-channel rules are enforced in AddBlock handler (ChannelNameRules),
|
|
||||||
// but parser-level check must allow legacy channel names during bootstrap/replay.
|
|
||||||
if (cpLen > MAX_NAME_LENGTH)
|
|
||||||
throw new IllegalArgumentException("channelName length must be <=32");
|
|
||||||
|
|
||||||
// tech-line: prev обязателен (минимум HEADER=0)
|
int cpLen = normalizedName.codePointCount(0, normalizedName.length());
|
||||||
if (prevLineNumber < 0)
|
if (cpLen > MAX_NAME_LENGTH) {
|
||||||
|
throw new IllegalArgumentException("channelName length must be <=32");
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedDescription = normalizeDescription(channelDescription);
|
||||||
|
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (descUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevLineNumber < 0) {
|
||||||
throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
|
throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
|
||||||
if (prevLineHash32 == null || prevLineHash32.length != 32)
|
}
|
||||||
|
if (prevLineHash32 == null || prevLineHash32.length != 32) {
|
||||||
throw new IllegalArgumentException("prevLineHash32 invalid");
|
throw new IllegalArgumentException("prevLineHash32 invalid");
|
||||||
if (thisLineNumber <= 0)
|
}
|
||||||
|
if (thisLineNumber <= 0) {
|
||||||
throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
|
throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
|
||||||
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@ -152,17 +206,28 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
return value.trim().replaceAll("\\s+", " ");
|
return value.trim().replaceAll("\\s+", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeDescription(String value) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return value.trim().replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] nameUtf8 = channelName.getBytes(StandardCharsets.UTF_8);
|
byte[] nameUtf8 = normalizeDisplayName(channelName).getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
||||||
throw new IllegalArgumentException("channelName utf8 len must be 1..255");
|
throw new IllegalArgumentException("channelName utf8 len must be 1..255");
|
||||||
|
}
|
||||||
|
|
||||||
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length;
|
boolean isV2 = (version & 0xFFFF) == (VER2 & 0xFFFF);
|
||||||
|
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
|
||||||
|
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
|
||||||
|
}
|
||||||
|
|
||||||
|
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + (isV2 ? 2 + descriptionUtf8.length : 0);
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
bb.putInt(lineCode);
|
bb.putInt(lineCode);
|
||||||
|
|
||||||
bb.putInt(prevLineNumber);
|
bb.putInt(prevLineNumber);
|
||||||
bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
|
bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
|
||||||
bb.putInt(thisLineNumber);
|
bb.putInt(thisLineNumber);
|
||||||
@ -170,12 +235,27 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
|
|||||||
bb.put((byte) nameUtf8.length);
|
bb.put((byte) nameUtf8.length);
|
||||||
bb.put(nameUtf8);
|
bb.put(nameUtf8);
|
||||||
|
|
||||||
|
if (isV2) {
|
||||||
|
bb.putShort((short) (descriptionUtf8.length & 0xFFFF));
|
||||||
|
if (descriptionUtf8.length > 0) {
|
||||||
|
bb.put(descriptionUtf8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasLine ====================== */
|
@Override
|
||||||
@Override public int lineCode() { return lineCode; }
|
public int lineCode() { return lineCode; }
|
||||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
|
||||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
@Override
|
||||||
@Override public int lineSeq() { return thisLineNumber; }
|
public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] prevLineBlockHash32() {
|
||||||
|
return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int lineSeq() { return thisLineNumber; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -373,6 +373,7 @@ public final class DatabaseInitializer {
|
|||||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||||
slug TEXT NOT NULL PRIMARY KEY,
|
slug TEXT NOT NULL PRIMARY KEY,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
|
channel_description TEXT NOT NULL DEFAULT '',
|
||||||
owner_login TEXT NOT NULL,
|
owner_login TEXT NOT NULL,
|
||||||
owner_bch_name TEXT NOT NULL,
|
owner_bch_name TEXT NOT NULL,
|
||||||
channel_root_block_number INTEGER NOT NULL,
|
channel_root_block_number INTEGER NOT NULL,
|
||||||
|
|||||||
@ -86,6 +86,7 @@ public final class SqliteDbController {
|
|||||||
rebuildConnectionsStateTable(st);
|
rebuildConnectionsStateTable(st);
|
||||||
}
|
}
|
||||||
ensureChannelNamesStateTable(st);
|
ensureChannelNamesStateTable(st);
|
||||||
|
ensureChannelNamesDescriptionColumn(c, st);
|
||||||
ensureConnectionsIndexes(st);
|
ensureConnectionsIndexes(st);
|
||||||
ensureReactionsIndexes(st);
|
ensureReactionsIndexes(st);
|
||||||
ensureChannelNamesIndexes(st);
|
ensureChannelNamesIndexes(st);
|
||||||
@ -179,6 +180,7 @@ public final class SqliteDbController {
|
|||||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
CREATE TABLE IF NOT EXISTS channel_names_state (
|
||||||
slug TEXT NOT NULL PRIMARY KEY,
|
slug TEXT NOT NULL PRIMARY KEY,
|
||||||
display_name TEXT NOT NULL,
|
display_name TEXT NOT NULL,
|
||||||
|
channel_description TEXT NOT NULL DEFAULT '',
|
||||||
owner_login TEXT NOT NULL,
|
owner_login TEXT NOT NULL,
|
||||||
owner_bch_name TEXT NOT NULL,
|
owner_bch_name TEXT NOT NULL,
|
||||||
channel_root_block_number INTEGER NOT NULL,
|
channel_root_block_number INTEGER NOT NULL,
|
||||||
@ -188,6 +190,24 @@ public final class SqliteDbController {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureChannelNamesDescriptionColumn(Connection c, Statement st) throws SQLException {
|
||||||
|
boolean hasDescription = false;
|
||||||
|
try (Statement probe = c.createStatement();
|
||||||
|
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
|
||||||
|
while (rs.next()) {
|
||||||
|
String name = rs.getString("name");
|
||||||
|
if ("channel_description".equalsIgnoreCase(name)) {
|
||||||
|
hasDescription = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDescription) {
|
||||||
|
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_description TEXT NOT NULL DEFAULT ''");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
|
private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug
|
||||||
|
|||||||
@ -51,21 +51,23 @@ public final class ChannelNameStateDAO {
|
|||||||
INSERT INTO channel_names_state (
|
INSERT INTO channel_names_state (
|
||||||
slug,
|
slug,
|
||||||
display_name,
|
display_name,
|
||||||
|
channel_description,
|
||||||
owner_login,
|
owner_login,
|
||||||
owner_bch_name,
|
owner_bch_name,
|
||||||
channel_root_block_number,
|
channel_root_block_number,
|
||||||
channel_root_block_hash,
|
channel_root_block_hash,
|
||||||
created_at_ms
|
created_at_ms
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, entry.getSlug());
|
ps.setString(1, entry.getSlug());
|
||||||
ps.setString(2, entry.getDisplayName());
|
ps.setString(2, entry.getDisplayName());
|
||||||
ps.setString(3, entry.getOwnerLogin());
|
ps.setString(3, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
|
||||||
ps.setString(4, entry.getOwnerBlockchainName());
|
ps.setString(4, entry.getOwnerLogin());
|
||||||
ps.setInt(5, entry.getChannelRootBlockNumber());
|
ps.setString(5, entry.getOwnerBlockchainName());
|
||||||
ps.setBytes(6, entry.getChannelRootBlockHash());
|
ps.setInt(6, entry.getChannelRootBlockNumber());
|
||||||
ps.setLong(7, entry.getCreatedAtMs());
|
ps.setBytes(7, entry.getChannelRootBlockHash());
|
||||||
|
ps.setLong(8, entry.getCreatedAtMs());
|
||||||
ps.executeUpdate();
|
ps.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import java.util.Arrays;
|
|||||||
public class ChannelNameStateEntry {
|
public class ChannelNameStateEntry {
|
||||||
private String slug;
|
private String slug;
|
||||||
private String displayName;
|
private String displayName;
|
||||||
|
private String channelDescription;
|
||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
private String ownerBlockchainName;
|
private String ownerBlockchainName;
|
||||||
private int channelRootBlockNumber;
|
private int channelRootBlockNumber;
|
||||||
@ -27,6 +28,14 @@ public class ChannelNameStateEntry {
|
|||||||
this.displayName = displayName;
|
this.displayName = displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getChannelDescription() {
|
||||||
|
return channelDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChannelDescription(String channelDescription) {
|
||||||
|
this.channelDescription = channelDescription;
|
||||||
|
}
|
||||||
|
|
||||||
public String getOwnerLogin() {
|
public String getOwnerLogin() {
|
||||||
return ownerLogin;
|
return ownerLogin;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -267,6 +267,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
channelNameStateEntry = new ChannelNameStateEntry();
|
channelNameStateEntry = new ChannelNameStateEntry();
|
||||||
channelNameStateEntry.setSlug(slug);
|
channelNameStateEntry.setSlug(slug);
|
||||||
channelNameStateEntry.setDisplayName(normalizedName);
|
channelNameStateEntry.setDisplayName(normalizedName);
|
||||||
|
channelNameStateEntry.setChannelDescription(
|
||||||
|
createChannelBody.channelDescription == null
|
||||||
|
? ""
|
||||||
|
: ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription)
|
||||||
|
);
|
||||||
channelNameStateEntry.setOwnerLogin(login);
|
channelNameStateEntry.setOwnerLogin(login);
|
||||||
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
channelNameStateEntry.setOwnerBlockchainName(blockchainName);
|
||||||
channelNameStateEntry.setChannelRootBlockNumber(block.blockNumber);
|
channelNameStateEntry.setChannelRootBlockNumber(block.blockNumber);
|
||||||
|
|||||||
@ -76,9 +76,11 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
|
|
||||||
final String displayName;
|
final String displayName;
|
||||||
final String slug;
|
final String slug;
|
||||||
|
final String channelDescription;
|
||||||
try {
|
try {
|
||||||
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
|
||||||
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
slug = ChannelNameRules.toCanonicalSlug(displayName);
|
||||||
|
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
|
||||||
} catch (Exception badName) {
|
} catch (Exception badName) {
|
||||||
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
|
||||||
continue;
|
continue;
|
||||||
@ -94,6 +96,7 @@ public final class ChannelNamesStateBootstrapper {
|
|||||||
ChannelNameStateEntry entry = new ChannelNameStateEntry();
|
ChannelNameStateEntry entry = new ChannelNameStateEntry();
|
||||||
entry.setSlug(slug);
|
entry.setSlug(slug);
|
||||||
entry.setDisplayName(displayName);
|
entry.setDisplayName(displayName);
|
||||||
|
entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
|
||||||
entry.setOwnerLogin(ownerLogin);
|
entry.setOwnerLogin(ownerLogin);
|
||||||
entry.setOwnerBlockchainName(ownerBch);
|
entry.setOwnerBlockchainName(ownerBch);
|
||||||
entry.setChannelRootBlockNumber(blockNumber);
|
entry.setChannelRootBlockNumber(blockNumber);
|
||||||
|
|||||||
@ -212,6 +212,46 @@ final class ChannelsReadSupport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
|
if (rootNumber == 0) return "";
|
||||||
|
|
||||||
|
// Preferred source: persisted state (fast path, works for CreateChannelBody v2).
|
||||||
|
String stateSql = """
|
||||||
|
SELECT channel_description
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name = ? AND channel_root_block_number = ?
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(stateSql)) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, rootNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException ignored) {
|
||||||
|
// keep compatibility for environments where table schema is older/corrupted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: parse root block directly.
|
||||||
|
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerBch);
|
||||||
|
ps.setInt(2, rootNumber);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) return "";
|
||||||
|
byte[] bytes = rs.getBytes("block_bytes");
|
||||||
|
BchBlockEntry e = new BchBlockEntry(bytes);
|
||||||
|
BodyRecord body = e.body;
|
||||||
|
if (body instanceof CreateChannelBody ccb) return ccb.channelDescription == null ? "" : ccb.channelDescription;
|
||||||
|
return "";
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static boolean isLikedByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException {
|
static boolean isLikedByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException {
|
||||||
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
|
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -54,6 +54,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
channel.setOwnerBlockchainName(ownerBch);
|
channel.setOwnerBlockchainName(ownerBch);
|
||||||
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
|
||||||
channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode));
|
channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode));
|
||||||
|
channel.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, ownerBch, lineCode));
|
||||||
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
|
||||||
rootRef.setBlockNumber(lineCode);
|
rootRef.setBlockNumber(lineCode);
|
||||||
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
|
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
|
||||||
|
|||||||
@ -64,6 +64,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
channelRef.setOwnerLogin(key.ownerLogin);
|
channelRef.setOwnerLogin(key.ownerLogin);
|
||||||
channelRef.setOwnerBlockchainName(key.ownerBch);
|
channelRef.setOwnerBlockchainName(key.ownerBch);
|
||||||
channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber));
|
channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber));
|
||||||
|
channelRef.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, key.ownerBch, key.rootNumber));
|
||||||
channelRef.setPersonal(key.rootNumber == 0);
|
channelRef.setPersonal(key.rootNumber == 0);
|
||||||
|
|
||||||
Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
|
Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
private String ownerBlockchainName;
|
private String ownerBlockchainName;
|
||||||
private String channelName;
|
private String channelName;
|
||||||
|
private String channelDescription;
|
||||||
private BlockRef channelRoot;
|
private BlockRef channelRoot;
|
||||||
|
|
||||||
public String getOwnerLogin() { return ownerLogin; }
|
public String getOwnerLogin() { return ownerLogin; }
|
||||||
@ -30,6 +31,9 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public String getChannelName() { return channelName; }
|
public String getChannelName() { return channelName; }
|
||||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||||
|
|
||||||
|
public String getChannelDescription() { return channelDescription; }
|
||||||
|
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||||
|
|
||||||
public BlockRef getChannelRoot() { return channelRoot; }
|
public BlockRef getChannelRoot() { return channelRoot; }
|
||||||
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,6 +42,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
private String ownerBlockchainName;
|
private String ownerBlockchainName;
|
||||||
private String channelName;
|
private String channelName;
|
||||||
|
private String channelDescription;
|
||||||
private boolean personal;
|
private boolean personal;
|
||||||
private BlockRef channelRoot;
|
private BlockRef channelRoot;
|
||||||
|
|
||||||
@ -54,6 +55,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public String getChannelName() { return channelName; }
|
public String getChannelName() { return channelName; }
|
||||||
public void setChannelName(String channelName) { this.channelName = channelName; }
|
public void setChannelName(String channelName) { this.channelName = channelName; }
|
||||||
|
|
||||||
|
public String getChannelDescription() { return channelDescription; }
|
||||||
|
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
|
||||||
|
|
||||||
public boolean isPersonal() { return personal; }
|
public boolean isPersonal() { return personal; }
|
||||||
public void setPersonal(boolean personal) { this.personal = personal; }
|
public void setPersonal(boolean personal) { this.personal = personal; }
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user