SHA256
UI v4: новый стиль по макету V3/V4 и доводка экранов
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки. Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги), сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов – переключатель; уведомления – список как в чатах; фото – просмотр на весь экран. Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода, второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов), иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса. Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
formatPostStamp,
|
||||
formatRelativeTime,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
import { getPreviousTrackedPath, parseRouteFromPath } from '../router.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { openChannelEditor } from '../components/channel-editor.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { createEmojiPicker } from '../components/emoji-picker.js';
|
||||
import { openShareSheet } from '../components/share-sheet.js';
|
||||
import {
|
||||
composeMessageWithAttachments,
|
||||
createAttachmentCarouselElement,
|
||||
@@ -466,7 +470,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
<span>Номер записи</span><code>${escapeHtml(details.blockNumber)}</code>
|
||||
<span>Хэш</span><code>${escapeHtml(details.blockHash)}</code>
|
||||
<span>Тип</span><code>${escapeHtml(details.msgSubType)}</code>
|
||||
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '—')}</code>
|
||||
<span>Время</span><code>${escapeHtml(details.createdAtMs ? new Date(Number(details.createdAtMs)).toLocaleString('ru-RU') : '–')}</code>
|
||||
<span>Public key</span><code>${escapeHtml(details.publicKey)}</code>
|
||||
<span>Подпись</span><code>${escapeHtml(details.signature)}</code>
|
||||
</div>
|
||||
@@ -623,7 +627,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
|
||||
const text = String(item?.text || '').trim() || 'удалено';
|
||||
row.innerHTML = `
|
||||
<strong>Версия ${index + 1}</strong>
|
||||
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '—'}</div>
|
||||
<div class="meta-muted">${ts > 0 ? new Date(ts).toLocaleString('ru-RU') : '–'}</div>
|
||||
<p class="channel-message-body">${text}</p>
|
||||
`;
|
||||
list.append(row);
|
||||
@@ -715,7 +719,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
|
||||
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '–';
|
||||
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
|
||||
authorBlock.append(title, timestamp);
|
||||
authorTile.append(avatar, authorBlock);
|
||||
@@ -961,6 +965,176 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
return card;
|
||||
}
|
||||
|
||||
// ===== Обсуждение в раскладке V4: контекст записи, плоский список комментариев, строка ввода =====
|
||||
|
||||
// Дерево ответов → плоский список в порядке чтения; у каждого — на что он отвечает
|
||||
function flattenDescendants(items, parent = null, out = []) {
|
||||
(Array.isArray(items) ? items : []).forEach((branch) => {
|
||||
if (!branch?.node) return;
|
||||
out.push({ node: branch.node, parent });
|
||||
flattenDescendants(branch.children, branch.node, out);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function nodeKey(node) {
|
||||
return messageRefKey(buildTargetFromNode(node));
|
||||
}
|
||||
|
||||
// Исходная запись сверху: две строки, «Развернуть» — только если текст правда обрезан
|
||||
function renderThreadContext(node, { onOpenPost }) {
|
||||
const parsed = parseMessageAttachments(resolveNodeText(node));
|
||||
const box = document.createElement('section');
|
||||
box.className = 'thread-context';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'thread-context__text';
|
||||
text.textContent = parsed.text || (parsed.attachments.length ? 'Запись с вложением' : '');
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'thread-context__meta';
|
||||
const time = document.createElement('span');
|
||||
time.textContent = formatPostStamp(node?.createdAtMs);
|
||||
const expand = document.createElement('button');
|
||||
expand.type = 'button';
|
||||
expand.className = 'text-btn thread-context__expand';
|
||||
expand.textContent = 'Развернуть';
|
||||
expand.hidden = true;
|
||||
expand.addEventListener('click', () => {
|
||||
const open = box.classList.toggle('is-expanded');
|
||||
expand.textContent = open ? 'Свернуть' : 'Развернуть';
|
||||
});
|
||||
const toPost = document.createElement('button');
|
||||
toPost.type = 'button';
|
||||
toPost.className = 'text-btn thread-context__open';
|
||||
toPost.innerHTML = `<span>${parsed.attachments.length ? 'Запись с фото' : 'К записи'}</span>${iconHtml('open')}`;
|
||||
toPost.addEventListener('click', onOpenPost);
|
||||
meta.append(time, expand, toPost);
|
||||
box.append(text, meta);
|
||||
// обрезан ли текст — видно только после вставки в документ
|
||||
requestAnimationFrame(() => { expand.hidden = text.scrollHeight <= text.clientHeight + 1; });
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderComment({ node, parent, focus, channelOwner, handlers }) {
|
||||
const target = buildTargetFromNode(node);
|
||||
const key = messageRefKey(target);
|
||||
const author = String(node?.authorLogin || 'автор');
|
||||
const parsed = parseMessageAttachments(resolveNodeText(node));
|
||||
const versions = Array.isArray(node?.versions) ? node.versions : [];
|
||||
const isOwn = Boolean(state.session.login) && author.toLowerCase() === String(state.session.login).toLowerCase();
|
||||
|
||||
const item = document.createElement('article');
|
||||
item.className = 'thread-comment channel-message-card';
|
||||
if (key) item.dataset.messageKey = key;
|
||||
|
||||
const person = document.createElement('div');
|
||||
person.className = 'thread-comment__person';
|
||||
const who = document.createElement('button');
|
||||
who.type = 'button';
|
||||
who.className = 'thread-comment__who';
|
||||
who.append(renderUserAvatar({ login: author, size: 'xs', className: 'thread-comment__avatar' }));
|
||||
const name = document.createElement('span');
|
||||
name.className = 'thread-comment__name';
|
||||
name.textContent = author;
|
||||
who.append(name);
|
||||
who.addEventListener('click', () => handlers.navigate(makeProfileRoute(author)));
|
||||
person.append(who);
|
||||
if (channelOwner && author.toLowerCase() === channelOwner.toLowerCase()) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'thread-comment__badge';
|
||||
badge.textContent = 'Автор';
|
||||
person.append(badge);
|
||||
}
|
||||
const time = document.createElement('time');
|
||||
time.textContent = formatPostStamp(node?.createdAtMs);
|
||||
if (node?.createdAtMs) time.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
|
||||
person.append(time);
|
||||
item.append(person);
|
||||
|
||||
// Ответ на другой комментарий — цитата в две строки; нажатие ведёт к оригиналу
|
||||
if (parent && parent !== focus) {
|
||||
const quote = document.createElement('button');
|
||||
quote.type = 'button';
|
||||
quote.className = 'thread-quote';
|
||||
const qa = document.createElement('strong');
|
||||
qa.textContent = String(parent.authorLogin || '');
|
||||
const qt = document.createElement('span');
|
||||
qt.textContent = parseMessageAttachments(resolveNodeText(parent)).text || 'Вложение';
|
||||
quote.append(qa, qt);
|
||||
quote.setAttribute('aria-label', `Ответ на комментарий ${parent.authorLogin || ''}. Перейти к нему`);
|
||||
quote.addEventListener('click', () => handlers.jumpTo(nodeKey(parent), key));
|
||||
item.append(quote);
|
||||
}
|
||||
|
||||
if (parsed.attachments.length) {
|
||||
item.append(createAttachmentCarouselElement(parsed.attachments, { gateway: state.entrySettings.arweaveServer, messageTimestampMs: node?.createdAtMs }));
|
||||
}
|
||||
const text = document.createElement('p');
|
||||
text.className = 'thread-comment__text';
|
||||
text.textContent = parsed.text;
|
||||
item.append(text);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'thread-comment__actions';
|
||||
const reply = document.createElement('button');
|
||||
reply.type = 'button';
|
||||
reply.className = 'thread-comment__reply';
|
||||
reply.textContent = 'Ответить';
|
||||
reply.addEventListener('click', () => handlers.setReplyTo(node));
|
||||
|
||||
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
||||
const liked = getMessageReactionState(target) === 'liked';
|
||||
const likes = Number(node?.likesCount || 0);
|
||||
const like = document.createElement('button');
|
||||
like.type = 'button';
|
||||
like.className = `channel-action-item thread-comment__like${liked ? ' is-liked' : ''}`;
|
||||
like.innerHTML = `${iconHtml('heart', liked)}<span>${likes || ''}</span>`;
|
||||
like.setAttribute('aria-pressed', String(liked));
|
||||
like.setAttribute('aria-label', `${liked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
|
||||
like.addEventListener('click', async () => {
|
||||
like.disabled = true;
|
||||
try { await handlers.onToggleLike(target, liked ? 'unlike' : 'like'); }
|
||||
catch (error) { handlers.onActionError(error, liked ? 'unlike' : 'like'); }
|
||||
finally { if (like.isConnected) like.disabled = false; }
|
||||
});
|
||||
|
||||
const more = document.createElement('button');
|
||||
more.type = 'button';
|
||||
more.className = 'channel-action-item thread-comment__more';
|
||||
more.innerHTML = iconHtml('more');
|
||||
more.setAttribute('aria-label', `Действия с комментарием ${author}`);
|
||||
const menuItems = [
|
||||
{ label: 'Ответить', iconHtml: iconHtml('reply'), action: () => handlers.setReplyTo(node) },
|
||||
{ label: 'Поделиться комментарием', iconHtml: iconHtml('share'), action: () => handlers.shareComment(node) },
|
||||
{ label: 'Скопировать текст', iconHtml: iconHtml('copy'), action: async () => {
|
||||
try { await navigator.clipboard.writeText(parsed.text); showToast('Текст скопирован'); }
|
||||
catch { showToast('Не удалось скопировать', { kind: 'error' }); }
|
||||
} },
|
||||
{ label: 'Данные публикации', iconHtml: iconHtml('info'), action: () => openBlockchainDetailsModal(buildBlockchainDetails({ target, authorLogin: author, timestampMs: node?.createdAtMs, text: resolveNodeText(node), raw: node, localNumber: 0, msgSubType: Number(node?.msgSubType || 0) }), { isActive: handlers.isActive }) },
|
||||
];
|
||||
if (versions.length > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions }) });
|
||||
if (isOwn) {
|
||||
menuItems.splice(1, 0, { label: 'Редактировать', iconHtml: iconHtml('edit'), action: () => openEditMessageModal({
|
||||
isActive: handlers.isActive,
|
||||
draftKey: `edit:${key}`,
|
||||
initialText: parsed.text,
|
||||
allowEmptyText: parsed.attachments.length > 0,
|
||||
onSave: async (nextText) => handlers.onEdit(target, composeMessageWithAttachments(nextText, parsed.attachments), { isChannelPost: false }),
|
||||
onDelete: async () => handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }),
|
||||
}) });
|
||||
menuItems.push({ divider: true }, { label: 'Удалить', danger: true, iconHtml: iconHtml('trash'), action: async () => {
|
||||
if (!await confirmDialog({ title: 'Удалить комментарий?', text: 'Он скроется из обсуждения. Прежние версии останутся в блокчейне.', confirmLabel: 'Удалить', danger: true })) return;
|
||||
try { await handlers.onEdit(target, '', { isChannelPost: false, isDelete: true }); }
|
||||
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить комментарий.')); }
|
||||
} });
|
||||
}
|
||||
const menu = createDropdownMenu({ anchorEl: more, items: menuItems });
|
||||
item.cleanup = () => menu.destroy();
|
||||
|
||||
actions.append(reply, like, more);
|
||||
item.append(actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderDescendants(items, handlers, nextNumber, depth = 0, parent = null) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
@@ -1025,6 +1199,124 @@ function renderSkeleton(screen) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// «Не уведомлять об ответах» — пока только на этом устройстве (серверной настройки нет, см. docs/UI-Design)
|
||||
const THREAD_MUTE_KEY = 'shine-ui-thread-muted-v1';
|
||||
function readMuted() { try { return JSON.parse(localStorage.getItem(THREAD_MUTE_KEY) || '{}') || {}; } catch { return {}; } }
|
||||
function isThreadMuted(key) { return Boolean(readMuted()[key]); }
|
||||
function setThreadMuted(key, muted) {
|
||||
const all = readMuted();
|
||||
if (muted) all[key] = Date.now(); else delete all[key];
|
||||
try { localStorage.setItem(THREAD_MUTE_KEY, JSON.stringify(all)); } catch { /* приватный режим */ }
|
||||
}
|
||||
|
||||
// Черновик и адресат ответа принадлежат конкретному обсуждению
|
||||
const threadDrafts = new Map();
|
||||
|
||||
function createThreadComposer({ draftKey, isAuthorized, onLogin, onSend }) {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'channel-composer thread-composer';
|
||||
if (!isAuthorized) {
|
||||
const login = document.createElement('button');
|
||||
login.type = 'button';
|
||||
login.className = 'primary-btn channel-main-action';
|
||||
login.textContent = 'Войти и ответить';
|
||||
login.addEventListener('click', onLogin);
|
||||
root.append(login);
|
||||
return { root, setReplyTo() {} };
|
||||
}
|
||||
const draft = threadDrafts.get(draftKey) || { text: '', replyTo: null };
|
||||
threadDrafts.set(draftKey, draft);
|
||||
|
||||
const replying = document.createElement('div');
|
||||
replying.className = 'thread-replying';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'compose-row';
|
||||
const field = document.createElement('div');
|
||||
field.className = 'compose-field';
|
||||
const input = document.createElement('textarea');
|
||||
input.rows = 1;
|
||||
input.className = 'compose-field__input';
|
||||
input.value = draft.text;
|
||||
const emojiBtn = document.createElement('button');
|
||||
emojiBtn.type = 'button';
|
||||
emojiBtn.className = 'compose-field__emoji';
|
||||
emojiBtn.setAttribute('aria-label', 'Эмодзи');
|
||||
emojiBtn.innerHTML = iconHtml('emoji');
|
||||
field.append(input, emojiBtn);
|
||||
const send = document.createElement('button');
|
||||
send.type = 'button';
|
||||
send.className = 'compose-send';
|
||||
send.setAttribute('aria-label', 'Отправить');
|
||||
send.innerHTML = iconHtml('send');
|
||||
row.append(field, send);
|
||||
const emojiSlot = document.createElement('div');
|
||||
emojiSlot.className = 'thread-composer__emoji';
|
||||
emojiSlot.hidden = true;
|
||||
root.append(replying, row, emojiSlot);
|
||||
|
||||
const sync = () => {
|
||||
draft.text = input.value;
|
||||
send.disabled = !input.value.trim();
|
||||
input.placeholder = draft.replyTo ? `Ответить ${draft.replyTo.authorLogin || ''}…` : 'Написать комментарий…';
|
||||
replying.replaceChildren();
|
||||
replying.hidden = !draft.replyTo;
|
||||
if (draft.replyTo) {
|
||||
const quote = document.createElement('div');
|
||||
quote.className = 'thread-quote';
|
||||
const a = document.createElement('strong');
|
||||
a.textContent = String(draft.replyTo.authorLogin || '');
|
||||
const t = document.createElement('span');
|
||||
t.textContent = parseMessageAttachments(resolveNodeText(draft.replyTo)).text;
|
||||
quote.append(a, t);
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'thread-replying__cancel';
|
||||
cancel.setAttribute('aria-label', 'Не отвечать на комментарий');
|
||||
cancel.innerHTML = iconHtml('close');
|
||||
cancel.addEventListener('click', () => { draft.replyTo = null; sync(); });
|
||||
replying.append(quote, cancel);
|
||||
}
|
||||
};
|
||||
input.addEventListener('input', sync);
|
||||
emojiBtn.addEventListener('click', () => {
|
||||
if (!emojiSlot.childElementCount) {
|
||||
emojiSlot.append(createEmojiPicker({ onSelect: (emoji) => {
|
||||
const at = input.selectionStart ?? input.value.length;
|
||||
input.value = `${input.value.slice(0, at)}${emoji}${input.value.slice(at)}`;
|
||||
sync();
|
||||
} }));
|
||||
}
|
||||
emojiSlot.hidden = !emojiSlot.hidden;
|
||||
});
|
||||
const submit = async () => {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
send.disabled = true;
|
||||
try {
|
||||
await onSend(text, draft.replyTo);
|
||||
draft.text = '';
|
||||
draft.replyTo = null;
|
||||
input.value = '';
|
||||
emojiSlot.hidden = true;
|
||||
} catch (error) {
|
||||
showToast(toUserMessage(error, 'Комментарий не отправлен. Текст сохранён.'), { kind: 'error' });
|
||||
} finally {
|
||||
sync();
|
||||
}
|
||||
};
|
||||
send.addEventListener('click', submit);
|
||||
bindSubmitOnPlainEnter(input, submit);
|
||||
sync();
|
||||
return {
|
||||
root,
|
||||
setReplyTo(node) {
|
||||
draft.replyTo = node;
|
||||
sync();
|
||||
input.focus();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
let selector = parseThreadSelector(route);
|
||||
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
|
||||
@@ -1042,19 +1334,31 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
threadHeaderButton.className = 'topbar-identity channel-header-route-btn';
|
||||
const threadHeaderText = document.createElement('span');
|
||||
threadHeaderText.className = 'topbar-identity__text';
|
||||
const threadHeaderTitle = document.createElement('span');
|
||||
threadHeaderTitle.className = 'channel-header-title';
|
||||
threadHeaderTitle.className = 'topbar-identity__title';
|
||||
threadHeaderTitle.textContent = 'Обсуждение';
|
||||
const threadHeaderChannel = document.createElement('span');
|
||||
threadHeaderChannel.className = 'channel-header-owner';
|
||||
threadHeaderChannel.className = 'topbar-identity__sub';
|
||||
threadHeaderChannel.textContent = '…';
|
||||
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
|
||||
threadHeaderText.append(threadHeaderTitle, threadHeaderChannel);
|
||||
threadHeaderButton.append(threadHeaderText);
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
// Меню обсуждения (V4): к записи, уведомления об ответах, поделиться записью
|
||||
let threadMenuActions = null;
|
||||
const header = createTopBar({
|
||||
center: threadHeaderButton,
|
||||
className: 'topbar--identity',
|
||||
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
actions: [{
|
||||
label: '⋯',
|
||||
ariaLabel: 'Меню обсуждения',
|
||||
className: 'thread-menu-btn',
|
||||
menu: { minWidth: 240, items: () => threadMenuActions?.() || [] },
|
||||
}],
|
||||
});
|
||||
header.classList.add('channel-thread-topbar');
|
||||
chrome?.setTopbar(header);
|
||||
@@ -1396,7 +1700,7 @@ export function render({ navigate, route, chrome }) {
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||
if (threadHeaderButton) {
|
||||
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
|
||||
threadHeaderChannel.textContent = channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle;
|
||||
threadHeaderButton.title = 'Открыть канал';
|
||||
threadHeaderButton.disabled = false;
|
||||
threadHeaderButton.onclick = (event) => {
|
||||
@@ -1409,74 +1713,114 @@ export function render({ navigate, route, chrome }) {
|
||||
}
|
||||
|
||||
clearContent();
|
||||
let localSeq = 0;
|
||||
const nextNumber = () => {
|
||||
localSeq += 1;
|
||||
return localSeq;
|
||||
const channelOwner = extractLoginFromBlockchainName(selector?.channel?.ownerBlockchainName || focus?.channelInfo?.ownerBlockchainName || '');
|
||||
const openPost = () => {
|
||||
const routeToChannel = buildChannelRouteFromThread(selector, resolvedChannelLabel);
|
||||
if (routeToChannel) navigate(routeToChannel);
|
||||
};
|
||||
const focusTarget = focus ? buildTargetFromNode(focus) : null;
|
||||
threadMenuActions = () => {
|
||||
const muted = isThreadMuted(routeKey);
|
||||
return [
|
||||
{ label: 'Перейти к записи', iconHtml: iconHtml('doc'), action: openPost },
|
||||
{ label: muted ? 'Уведомлять об ответах' : 'Не уведомлять об ответах', iconHtml: iconHtml(muted ? 'bell' : 'bell-off'), action: () => {
|
||||
setThreadMuted(routeKey, !muted);
|
||||
showToast(muted ? 'Уведомления об ответах включены' : 'Ответы в этом обсуждении не будут приходить в уведомления');
|
||||
} },
|
||||
{ label: 'Поделиться записью', iconHtml: iconHtml('share'), action: () => {
|
||||
if (!focusTarget) return;
|
||||
openShareSheet({
|
||||
title: 'Поделиться записью',
|
||||
kicker: `${resolvedChannelTitle} · запись`,
|
||||
text: parseMessageAttachments(resolveNodeText(focus)).text,
|
||||
url: buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector)),
|
||||
});
|
||||
} },
|
||||
];
|
||||
};
|
||||
|
||||
let ancestorsWrap = null;
|
||||
if (ancestors.length) {
|
||||
ancestorsWrap = document.createElement('div');
|
||||
ancestorsWrap.className = 'stack thread-block thread-block--ancestors';
|
||||
ancestors.forEach((node, index) => {
|
||||
const heading = index === 0 ? resolveChannelHeadingFromNode(node) : '';
|
||||
ancestorsWrap.append(renderNodeCard(node, heading, handlers, nextNumber()));
|
||||
});
|
||||
const ancestorsWrap = document.createElement('div');
|
||||
ancestorsWrap.className = 'thread-block thread-block--ancestors';
|
||||
ancestors.forEach((node) => ancestorsWrap.append(renderThreadContext(node, { onOpenPost: openPost })));
|
||||
screen.append(ancestorsWrap);
|
||||
}
|
||||
|
||||
let focusWrap = null;
|
||||
if (focus) {
|
||||
focusWrap = document.createElement('div');
|
||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||
const focusTitle = document.createElement('h3');
|
||||
focusTitle.className = 'section-title';
|
||||
focusTitle.textContent = 'Исходное сообщение';
|
||||
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber()));
|
||||
const composer = document.createElement('div');
|
||||
composer.className = 'channel-composer';
|
||||
const reply = document.createElement('button');
|
||||
reply.type = 'button';
|
||||
reply.className = 'channel-compose-bar';
|
||||
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
|
||||
reply.addEventListener('click', () => {
|
||||
const parsed = parseMessageAttachments(resolveNodeText(focus));
|
||||
openReplyModal({
|
||||
draftKey: `message:${messageRefKey(buildTargetFromNode(focus))}`,
|
||||
context: { author: focus.authorLogin, text: parsed.text, attachmentLabel: parsed.attachments[0]?.name },
|
||||
isActive: () => !disposed,
|
||||
onSubmit: (text) => handlers.onReply(buildTargetFromNode(focus), text),
|
||||
});
|
||||
});
|
||||
composer.append(reply);
|
||||
chrome?.setComposer(composer);
|
||||
const focusWrap = document.createElement('div');
|
||||
focusWrap.className = 'thread-block thread-block--focus';
|
||||
focusWrap.append(renderThreadContext(focus, { onOpenPost: openPost }));
|
||||
screen.append(focusWrap);
|
||||
}
|
||||
|
||||
const descendantsWrap = document.createElement('div');
|
||||
descendantsWrap.className = 'stack thread-block thread-block--replies';
|
||||
const descendantsTitle = document.createElement('h3');
|
||||
descendantsTitle.className = 'section-title';
|
||||
descendantsTitle.textContent = `Ответы · ${Math.max(0, Number(focus?.repliesCount || descendants.length))}`;
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
const comments = flattenDescendants(descendants);
|
||||
const listWrap = document.createElement('div');
|
||||
listWrap.className = 'thread-block thread-block--replies';
|
||||
const listTitle = document.createElement('h3');
|
||||
listTitle.className = 'thread-section-title';
|
||||
listTitle.textContent = `Комментарии · ${comments.length}`;
|
||||
listWrap.append(listTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
||||
// Возврат к ответу после перехода по цитате
|
||||
const returnBtn = document.createElement('button');
|
||||
returnBtn.type = 'button';
|
||||
returnBtn.className = 'thread-return';
|
||||
returnBtn.hidden = true;
|
||||
returnBtn.textContent = 'Вернуться к ответу';
|
||||
let returnKey = '';
|
||||
const highlight = (el) => {
|
||||
screen.querySelectorAll('.thread-comment.is-highlight').forEach((x) => x.classList.remove('is-highlight'));
|
||||
el?.classList.add('is-highlight');
|
||||
};
|
||||
returnBtn.addEventListener('click', () => {
|
||||
const el = listWrap.querySelector(`[data-message-key="${CSS.escape(returnKey)}"]`);
|
||||
el?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
highlight(el);
|
||||
returnBtn.hidden = true;
|
||||
});
|
||||
|
||||
const commentHandlers = {
|
||||
...handlers,
|
||||
jumpTo: (key, fromKey) => {
|
||||
const el = listWrap.querySelector(`[data-message-key="${CSS.escape(key)}"]`);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
highlight(el);
|
||||
returnKey = fromKey;
|
||||
returnBtn.hidden = false;
|
||||
},
|
||||
setReplyTo: (node) => composerApi?.setReplyTo(node),
|
||||
shareComment: (node) => openShareSheet({
|
||||
title: 'Поделиться комментарием',
|
||||
kicker: `${node.authorLogin} · комментарий`,
|
||||
text: parseMessageAttachments(resolveNodeText(node)).text,
|
||||
url: `${buildAbsoluteRouteUrl(buildThreadRouteFromTarget(focusTarget, selector))}#comment-${node?.messageRef?.blockNumber ?? ''}`,
|
||||
}),
|
||||
};
|
||||
|
||||
if (comments.length) {
|
||||
comments.forEach(({ node, parent }) => {
|
||||
try { listWrap.append(renderComment({ node, parent, focus, channelOwner, handlers: commentHandlers })); }
|
||||
catch (error) { logThreadRuntimeError('render_comment', error); }
|
||||
});
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
|
||||
descendantsWrap.append(empty);
|
||||
empty.className = 'empty-note thread-empty';
|
||||
empty.textContent = 'Пока нет комментариев. Можно начать разговор.';
|
||||
listWrap.append(empty);
|
||||
}
|
||||
screen.append(listWrap, returnBtn);
|
||||
|
||||
if (ancestorsWrap) {
|
||||
screen.append(ancestorsWrap);
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'thread-history-divider';
|
||||
screen.append(divider);
|
||||
let composerApi = null;
|
||||
if (focus) {
|
||||
composerApi = createThreadComposer({
|
||||
draftKey: routeKey,
|
||||
isAuthorized: state.session.isAuthorized,
|
||||
onLogin: () => { state.authReturnHash = window.location.pathname || '/channels'; navigate('login-view'); },
|
||||
onSend: async (text, replyTo) => handlers.onReply(replyTo ? buildTargetFromNode(replyTo) : focusTarget, text),
|
||||
});
|
||||
chrome?.setComposer(composerApi.root);
|
||||
}
|
||||
|
||||
if (focusWrap) screen.append(focusWrap);
|
||||
screen.append(descendantsWrap);
|
||||
const focusWrap = screen.querySelector('.thread-block--focus');
|
||||
|
||||
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
|
||||
const hasPendingScroll = pendingThreadScroll.has(routeKey);
|
||||
|
||||
Reference in New Issue
Block a user