SHA256
Channels UI + read/unread + unique views + style polish
This commit is contained in:
@@ -5,6 +5,8 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
showToast,
|
||||
softHaptic,
|
||||
} from '../services/channels-ux.js';
|
||||
@@ -13,6 +15,7 @@ export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
|
||||
|
||||
const pendingReactionActions = new Set();
|
||||
const pendingThreadScroll = new Map();
|
||||
const revealedCountersByRoute = new Map();
|
||||
|
||||
function logThreadRuntimeError(stage, error, context = {}) {
|
||||
const message = String(error?.message || error || 'thread runtime error');
|
||||
@@ -63,6 +66,36 @@ function messageRefKey(messageRef) {
|
||||
return `${blockchainName}:${blockNumber}:${blockHash}`;
|
||||
}
|
||||
|
||||
function getRevealedCounterSet(routeKey) {
|
||||
const key = String(routeKey || '').trim();
|
||||
if (!key) return new Set();
|
||||
let bucket = revealedCountersByRoute.get(key);
|
||||
if (!bucket) {
|
||||
bucket = new Set();
|
||||
revealedCountersByRoute.set(key, bucket);
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
function isCounterVisible(routeKey, counterKey) {
|
||||
const key = String(counterKey || '').trim();
|
||||
if (!key) return false;
|
||||
return getRevealedCounterSet(routeKey).has(key);
|
||||
}
|
||||
|
||||
function revealCounter(routeKey, counterKey) {
|
||||
const key = String(counterKey || '').trim();
|
||||
if (!key) return;
|
||||
getRevealedCounterSet(routeKey).add(key);
|
||||
}
|
||||
|
||||
function buildAbsoluteRouteUrl(routePath = '') {
|
||||
const cleanRoute = String(routePath || '').replace(/^#?\/?/, '');
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = `#/${cleanRoute}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function parseThreadSelector(route) {
|
||||
const params = route?.params || {};
|
||||
const blockNumber = toSafeInt(params.messageBlockNumber);
|
||||
@@ -119,6 +152,19 @@ function buildBackRoute(selector) {
|
||||
return 'channels-list';
|
||||
}
|
||||
|
||||
function buildThreadRouteFromTarget(target, selector) {
|
||||
if (!target || !selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
|
||||
return [
|
||||
'channel-thread-view',
|
||||
encodeRoutePart(target.blockchainName),
|
||||
target.blockNumber,
|
||||
normalizeRouteHash(target.blockHash),
|
||||
encodeRoutePart(selector.channel.ownerBlockchainName),
|
||||
selector.channel.rootBlockNumber,
|
||||
normalizeRouteHash(selector.channel.rootBlockHash),
|
||||
].join('/');
|
||||
}
|
||||
|
||||
function buildTargetFromNode(node) {
|
||||
const blockchainName = String(node?.authorBlockchainName || '').trim();
|
||||
const blockNumber = Number(node?.messageRef?.blockNumber);
|
||||
@@ -212,7 +258,7 @@ function openReplyModal({ onSubmit }) {
|
||||
if (textEl) textEl.focus();
|
||||
}
|
||||
|
||||
function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
function renderNodeCard(node, heading, handlers, localNumber, routeKey, options = {}) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack thread-node-card';
|
||||
|
||||
@@ -222,9 +268,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const replies = Number(node?.repliesCount || 0);
|
||||
const versions = Number(node?.versionsTotal || 1);
|
||||
|
||||
const headingEl = document.createElement('strong');
|
||||
headingEl.className = 'thread-node-heading';
|
||||
headingEl.textContent = heading;
|
||||
const headingText = String(heading || '').trim();
|
||||
if (headingText) {
|
||||
const headingEl = document.createElement('strong');
|
||||
headingEl.className = 'thread-node-heading';
|
||||
headingEl.textContent = headingText;
|
||||
card.append(headingEl);
|
||||
}
|
||||
|
||||
const meta = document.createElement('p');
|
||||
meta.className = 'thread-node-meta';
|
||||
@@ -241,12 +291,36 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
stats.className = 'thread-node-stats';
|
||||
stats.textContent = `Лайки: ${likes}, ответы: ${replies}, версий: ${versions}`;
|
||||
|
||||
card.append(headingEl, meta, body, stats);
|
||||
card.append(meta, body, stats);
|
||||
|
||||
if (options.showViews === true) {
|
||||
const views = document.createElement('p');
|
||||
views.className = 'thread-node-views';
|
||||
views.textContent = `Просмотры: ${Number(node?.viewCount || 0)}`;
|
||||
card.append(views);
|
||||
}
|
||||
|
||||
const target = buildTargetFromNode(node);
|
||||
const refKey = messageRefKey(target);
|
||||
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
|
||||
if (!countersVisible) {
|
||||
card.classList.remove('is-counters-visible');
|
||||
stats.classList.add('is-hidden');
|
||||
} else {
|
||||
card.classList.add('is-counters-visible');
|
||||
stats.classList.remove('is-hidden');
|
||||
}
|
||||
|
||||
const revealCounters = () => {
|
||||
if (!refKey) return;
|
||||
revealCounter(routeKey, refKey);
|
||||
card.classList.add('is-counters-visible');
|
||||
stats.classList.remove('is-hidden');
|
||||
};
|
||||
card.addEventListener('click', revealCounters);
|
||||
|
||||
if (!target || !handlers) return card;
|
||||
|
||||
const refKey = messageRefKey(target);
|
||||
if (refKey) card.dataset.messageKey = refKey;
|
||||
|
||||
setMessageReactionState(target, node?.likedByMe === true ? 'liked' : 'unliked');
|
||||
@@ -263,13 +337,15 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
likeButton.type = 'button';
|
||||
likeButton.className = 'secondary-btn thread-like-btn';
|
||||
if (isLiked) likeButton.classList.add('is-liked');
|
||||
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
|
||||
likeButton.textContent = isPending ? '✦ Сияние...' : '✦ Сияние';
|
||||
likeButton.disabled = isPending;
|
||||
likeButton.addEventListener('click', async (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
if (isPending) return;
|
||||
revealCounters();
|
||||
await longPressFeel(event.currentTarget, 130);
|
||||
likeButton.disabled = true;
|
||||
likeButton.textContent = 'Выполняется...';
|
||||
likeButton.textContent = 'Сияние...';
|
||||
try {
|
||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||
} catch (error) {
|
||||
@@ -285,20 +361,32 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const replyButton = document.createElement('button');
|
||||
replyButton.type = 'button';
|
||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||
replyButton.textContent = 'Ответить';
|
||||
replyButton.textContent = '⟳ Отразить';
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
revealCounters();
|
||||
openReplyModal({
|
||||
onSubmit: async (textValue) => handlers.onReply(target, textValue),
|
||||
});
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton);
|
||||
const shareButton = document.createElement('button');
|
||||
shareButton.type = 'button';
|
||||
shareButton.className = 'secondary-btn thread-share-btn';
|
||||
shareButton.textContent = '↗ Транслировать';
|
||||
shareButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
animatePress(event.currentTarget);
|
||||
revealCounters();
|
||||
await handlers.onShare(target);
|
||||
});
|
||||
|
||||
actions.append(likeButton, replyButton, shareButton);
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
function renderDescendants(items, handlers, nextNumber, routeKey, depth = 0) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'stack';
|
||||
|
||||
@@ -306,13 +394,13 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
|
||||
normalized.forEach((branch, index) => {
|
||||
try {
|
||||
const nodeNumber = nextNumber();
|
||||
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber);
|
||||
const row = renderNodeCard(branch?.node, `Ответ ${index + 1}`, handlers, nodeNumber, routeKey);
|
||||
row.classList.add('thread-node-level');
|
||||
row.style.setProperty('--depth', String(Math.min(depth, 4)));
|
||||
wrap.append(row);
|
||||
|
||||
if (Array.isArray(branch?.children) && branch.children.length) {
|
||||
wrap.append(renderDescendants(branch.children, handlers, nextNumber, depth + 1));
|
||||
wrap.append(renderDescendants(branch.children, handlers, nextNumber, routeKey, depth + 1));
|
||||
}
|
||||
} catch (error) {
|
||||
logThreadRuntimeError('render_descendants_branch', error, { depth, index });
|
||||
@@ -444,6 +532,22 @@ export function render({ navigate, route }) {
|
||||
showStatus('');
|
||||
rerender();
|
||||
},
|
||||
onShare: async (target) => {
|
||||
try {
|
||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
|
||||
const result = await shareOrCopyLink({
|
||||
title: 'SHiNE · Тред',
|
||||
text: 'Сообщение из треда SHiNE',
|
||||
url: buildAbsoluteRouteUrl(routePath),
|
||||
});
|
||||
if (result === 'copied') showToast('Ссылка скопирована');
|
||||
if (result === 'shared') showToast('Ссылка передана');
|
||||
if (result === 'copied' || result === 'shared') softHaptic(10);
|
||||
} catch (error) {
|
||||
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
|
||||
}
|
||||
},
|
||||
onActionError: (error, action) => {
|
||||
const fallback = action === 'unlike'
|
||||
? 'Не удалось убрать лайк.'
|
||||
@@ -479,11 +583,6 @@ export function render({ navigate, route }) {
|
||||
const focus = payload?.focus || null;
|
||||
const descendants = Array.isArray(payload?.descendants) ? payload.descendants : [];
|
||||
|
||||
const summary = document.createElement('div');
|
||||
summary.className = 'card thread-summary';
|
||||
summary.textContent = `Предки: ${ancestors.length}, ответы: ${descendants.length}`;
|
||||
screen.append(summary);
|
||||
|
||||
let seq = 0;
|
||||
const nextNumber = () => {
|
||||
seq += 1;
|
||||
@@ -498,7 +597,7 @@ export function render({ navigate, route }) {
|
||||
title.textContent = 'Предыдущие сообщения';
|
||||
ancestorsWrap.append(title);
|
||||
ancestors.forEach((node, index) => {
|
||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber()));
|
||||
ancestorsWrap.append(renderNodeCard(node, `Предок ${index + 1}`, handlers, nextNumber(), routeKey));
|
||||
});
|
||||
screen.append(ancestorsWrap);
|
||||
}
|
||||
@@ -506,10 +605,7 @@ export function render({ navigate, route }) {
|
||||
if (focus) {
|
||||
const focusWrap = document.createElement('div');
|
||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'section-title';
|
||||
title.textContent = 'Текущее сообщение';
|
||||
focusWrap.append(title, renderNodeCard(focus, 'Выбранное сообщение', handlers, nextNumber()));
|
||||
focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: true }));
|
||||
screen.append(focusWrap);
|
||||
}
|
||||
|
||||
@@ -521,7 +617,7 @@ export function render({ navigate, route }) {
|
||||
descendantsWrap.append(descendantsTitle);
|
||||
|
||||
if (descendants.length) {
|
||||
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
||||
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber, routeKey));
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
|
||||
Reference in New Issue
Block a user