Channels UI + read/unread + unique views + style polish

This commit is contained in:
DrygMira
2026-04-23 13:36:01 +03:00
parent 78d6124f2a
commit c0dfa6c7ab
25 changed files with 2369 additions and 145 deletions
+3 -2
View File
@@ -1,4 +1,4 @@
import { resolveToolbarActive } from '../router.js';
import { resolveToolbarActive } from '../router.js';
import { state } from '../state.js';
const ITEMS = [
@@ -31,7 +31,8 @@ export function renderToolbar(currentPageId, navigate) {
const btn = document.createElement('button');
const isProfile = item.pageId === 'profile-view';
const isMessages = item.pageId === 'messages-list';
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}`;
const isNetwork = item.pageId === 'network-view';
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}`;
if (isProfile) {
btn.innerHTML = `
<span>${item.icon}</span>
+120 -24
View File
@@ -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';
+405 -18
View File
@@ -9,6 +9,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
import {
animatePress,
createSkeletonCard,
formatRelativeTime,
longPressFeel,
shareOrCopyLink,
showToast,
softHaptic,
} from '../services/channels-ux.js';
@@ -17,6 +20,10 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map();
const revealedCountersByRoute = new Map();
const seenFlushTimersByRoute = new Map();
const seenPendingByRoute = new Map();
const firstUnreadJumpByRoute = new Map();
function isChannelsDemoMode() {
try {
@@ -66,6 +73,59 @@ function messageRefKey(messageRef) {
return `${blockchainName}:${blockNumber}:${blockHash}`;
}
function parseMessageRefKey(key) {
const raw = String(key || '').trim();
if (!raw) return null;
const parts = raw.split(':');
if (parts.length !== 3) return null;
const blockNumber = Number(parts[1]);
const blockHash = normalizeMessageHash(parts[2]);
if (!parts[0] || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
return {
blockchainName: parts[0],
blockNumber,
blockHash,
};
}
function blockRefToMessageKey(blockRef, fallbackBch = '') {
const blockNumber = toSafeInt(blockRef?.blockNumber);
const blockHash = normalizeMessageHash(blockRef?.blockHash);
const blockchainName = String(fallbackBch || '').trim();
if (!blockchainName || blockNumber == null || !blockHash) return '';
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 channelDescriptionParamKey(selector) {
const owner = String(selector?.ownerBlockchainName || '').trim();
const rootNo = Number(selector?.channelRootBlockNumber);
@@ -165,6 +225,43 @@ function resolveMessageText(message) {
);
}
function toTimestampMs(...candidates) {
for (const candidate of candidates) {
if (candidate == null) continue;
if (typeof candidate === 'number' && Number.isFinite(candidate) && candidate > 0) {
return candidate > 1e12 ? Math.round(candidate) : Math.round(candidate * 1000);
}
if (typeof candidate === 'string') {
const trimmed = candidate.trim();
if (!trimmed) continue;
const asNum = Number(trimmed);
if (Number.isFinite(asNum) && asNum > 0) {
return asNum > 1e12 ? Math.round(asNum) : Math.round(asNum * 1000);
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
}
return 0;
}
function resolveMessageTimestampMs(message) {
return toTimestampMs(
message?.messageTimeMs,
message?.message_time_ms,
message?.timeMs,
message?.time_ms,
message?.timestampMs,
message?.timestamp_ms,
message?.createdAtMs,
message?.created_at_ms,
message?.messageTime,
message?.createdAt,
message?.created_at,
message?.timestamp,
);
}
function openAboutChannelModal(channel) {
const root = document.getElementById('modal-root');
root.innerHTML = `
@@ -405,6 +502,9 @@ function mapApiMessageToPost(message, selector, localNumber) {
body: resolvedText || '(пусто)',
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
viewCount: Number(message?.viewCount || 0),
seenByMe: message?.seenByMe === true,
timestampMs: resolveMessageTimestampMs(message),
messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
};
@@ -420,6 +520,8 @@ async function loadFromApi(route, channelId) {
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName);
const unreadFromPayload = Number(payload.unreadCount || 0);
const readDescription = async () => {
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
@@ -445,6 +547,10 @@ async function loadFromApi(route, channelId) {
ownerName: ownerLogin || 'неизвестно',
},
posts,
unreadCount: Number.isFinite(unreadFromPayload)
? Math.max(0, unreadFromPayload)
: posts.filter((post) => post.seenByMe !== true).length,
firstUnreadKey,
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
selector,
};
@@ -516,13 +622,29 @@ function applyPendingScroll(screen, routeKey) {
setTimeout(doScroll, 20);
}
function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
function renderPostCard(post, {
navigate,
routeKey,
selector,
onToggleLike,
onReply,
onShare,
}) {
const card = document.createElement('article');
card.className = 'card stack channel-message-card';
const topRow = document.createElement('div');
topRow.className = 'channel-message-top';
const avatar = document.createElement('div');
avatar.className = 'channel-message-avatar';
avatar.textContent = String(post.authorLogin || 'A').trim().charAt(0).toUpperCase() || 'A';
const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author';
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;
@@ -531,22 +653,41 @@ function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
numberEl.className = 'author-line-num';
numberEl.textContent = `· #${post.localNumber}`;
const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time';
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
title.append(loginEl, numberEl);
authorBlock.append(title, timestamp);
topRow.append(avatar, authorBlock);
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}`;
const views = document.createElement('p');
views.className = 'channel-message-views';
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
card.append(title, body, stats);
card.append(topRow, body, views);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
card.dataset.messageKey = refKey;
}
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
if (!countersVisible) {
card.classList.remove('is-counters-visible');
} else {
card.classList.add('is-counters-visible');
}
const revealCounters = () => {
if (!refKey) return;
revealCounter(routeKey, refKey);
card.classList.add('is-counters-visible');
};
card.addEventListener('click', revealCounters);
if (!post.messageRef || !selector) return card;
@@ -558,25 +699,36 @@ function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'secondary-btn channel-action-like';
likeButton.className = 'channel-action-item channel-action-like';
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.textContent = isPending ? 'Выполняется...' : (isLiked ? 'Убрать лайк' : 'Лайк');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">✦</span>
<span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
<span class="channel-action-counter">${post.likesCount || 0}</span>
`;
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 = 'Выполняется...';
const labelEl = likeButton.querySelector('.channel-action-label');
if (labelEl) labelEl.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.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">⟳</span>
<span class="channel-action-label">Отразить</span>
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
openReplyModal({
onSubmit: async (text) => onReply(post.messageRef, text),
});
@@ -584,15 +736,35 @@ function renderPostCard(post, { navigate, selector, onToggleLike, onReply }) {
const openThreadButton = document.createElement('button');
openThreadButton.type = 'button';
openThreadButton.className = 'secondary-btn channel-action-thread';
openThreadButton.textContent = 'Открыть тред';
openThreadButton.className = 'channel-action-item channel-action-thread';
openThreadButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">#</span>
<span class="channel-action-label">Тред</span>
<span class="channel-action-counter">${post.repliesCount || 0}</span>
`;
openThreadButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
const route = buildThreadRoute(post.messageRef, selector);
if (route) navigate(route);
});
actions.append(likeButton, replyButton, openThreadButton);
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'channel-action-item channel-action-share';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span>
<span class="channel-action-label">Транслировать</span>
`;
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
revealCounters();
const route = buildThreadRoute(post.messageRef, selector);
await onShare(route);
});
actions.append(likeButton, replyButton, openThreadButton, shareButton);
card.append(actions);
return card;
}
@@ -648,15 +820,38 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
const unreadDivider = document.createElement('div');
unreadDivider.className = 'channels-unread-divider';
unreadDivider.textContent = 'Непрочитанные сообщения';
const unreadJump = document.createElement('button');
unreadJump.type = 'button';
unreadJump.className = 'channels-unread-jump';
unreadJump.innerHTML = `
<span class="channels-unread-jump-icon" aria-hidden="true">↓</span>
<span class="channels-unread-jump-badge"></span>
`;
const unreadBadge = unreadJump.querySelector('.channels-unread-jump-badge');
const postsByKey = new Map();
const unreadKeys = new Set();
let seenFlushInFlight = false;
let seenObserver = null;
if (channelData.posts.length) {
channelData.posts.forEach((post) => {
feed.append(renderPostCard(post, {
const row = renderPostCard(post, {
navigate,
routeKey,
selector: channelData.selector,
onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply,
}));
onShare: handlers.onShare,
});
const key = messageRefKey(post.messageRef);
if (key) {
postsByKey.set(key, post);
if (post.seenByMe !== true) unreadKeys.add(key);
}
feed.append(row);
});
} else {
const empty = document.createElement('div');
@@ -665,6 +860,102 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
feed.append(empty);
}
const syncUnreadState = () => {
unreadKeys.clear();
postsByKey.forEach((post, key) => {
if (post.seenByMe !== true) unreadKeys.add(key);
});
};
const updateUnreadJump = () => {
const unreadCount = unreadKeys.size;
unreadJump.classList.toggle('is-visible', unreadCount > 0);
unreadJump.hidden = unreadCount <= 0;
if (unreadBadge) unreadBadge.textContent = unreadCount > 0 ? String(unreadCount) : '';
};
const mountUnreadDivider = () => {
unreadDivider.remove();
if (!unreadKeys.size) return;
const firstUnread = channelData.posts.find((post) => {
const key = messageRefKey(post.messageRef);
return key && unreadKeys.has(key);
});
const firstUnreadKey = messageRefKey(firstUnread?.messageRef);
if (!firstUnreadKey) return;
const target = feed.querySelector(`[data-message-key="${firstUnreadKey}"]`);
if (target) {
feed.insertBefore(unreadDivider, target);
}
};
const routePending = (() => {
let bucket = seenPendingByRoute.get(routeKey);
if (!bucket) {
bucket = new Set();
seenPendingByRoute.set(routeKey, bucket);
}
return bucket;
})();
const scheduleSeenFlush = () => {
const oldTimer = seenFlushTimersByRoute.get(routeKey);
if (oldTimer) clearTimeout(oldTimer);
const timer = setTimeout(async () => {
seenFlushTimersByRoute.delete(routeKey);
if (seenFlushInFlight) return;
const pendingKeys = [...routePending].filter((key) => {
const post = postsByKey.get(key);
return !!post && post.seenByMe !== true;
});
if (!pendingKeys.length) return;
const refs = pendingKeys
.map((key) => parseMessageRefKey(key))
.filter(Boolean);
if (!refs.length) return;
pendingKeys.forEach((key) => routePending.delete(key));
seenFlushInFlight = true;
try {
await handlers.onMarkSeenBatch(refs);
refs.forEach((ref) => {
const key = messageRefKey(ref);
const post = key ? postsByKey.get(key) : null;
if (post) post.seenByMe = true;
});
syncUnreadState();
mountUnreadDivider();
updateUnreadJump();
} catch (error) {
refs.forEach((ref) => {
const key = messageRefKey(ref);
if (!key) return;
const node = feed.querySelector(`[data-message-key="${key}"]`);
if (node) seenObserver?.observe(node);
});
handlers.onSeenError?.(error);
} finally {
seenFlushInFlight = false;
if (routePending.size) scheduleSeenFlush();
}
}, 220);
seenFlushTimersByRoute.set(routeKey, timer);
};
unreadJump.addEventListener('click', () => {
const unreadPosts = channelData.posts.filter((post) => {
const key = messageRefKey(post.messageRef);
return key && unreadKeys.has(key);
});
const targetPost = unreadPosts.length ? unreadPosts[unreadPosts.length - 1] : channelData.posts[channelData.posts.length - 1];
const key = messageRefKey(targetPost?.messageRef);
if (!key) return;
const target = feed.querySelector(`[data-message-key="${key}"]`);
target?.scrollIntoView({ behavior: 'smooth', block: 'end' });
});
if (channelData.isOwnChannel) {
actionButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
@@ -682,8 +973,58 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
screen.append(head, actionButton, feed, backButton);
screen.append(head, actionButton, feed, backButton, unreadJump);
if (state.session.login && channelData.selector && channelData.posts.length) {
seenObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return;
const key = String(entry.target?.dataset?.messageKey || '').trim();
if (!key) return;
const post = postsByKey.get(key);
if (!post || post.seenByMe === true) return;
routePending.add(key);
seenObserver?.unobserve(entry.target);
});
if (routePending.size) scheduleSeenFlush();
}, {
root: document.getElementById('app-screen') || null,
threshold: [0.6],
});
feed.querySelectorAll('[data-message-key]').forEach((node) => {
const key = String(node.dataset.messageKey || '').trim();
if (key && unreadKeys.has(key)) seenObserver?.observe(node);
});
}
syncUnreadState();
mountUnreadDivider();
updateUnreadJump();
const firstUnreadCandidate = channelData.firstUnreadKey
|| (() => {
const first = channelData.posts.find((post) => post.seenByMe !== true);
return messageRefKey(first?.messageRef);
})();
if (firstUnreadCandidate) {
const previous = firstUnreadJumpByRoute.get(routeKey);
if (previous !== firstUnreadCandidate) {
pendingScrollByRoute.set(routeKey, firstUnreadCandidate);
firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate);
}
} else {
firstUnreadJumpByRoute.delete(routeKey);
}
applyPendingScroll(screen, routeKey);
return () => {
seenObserver?.disconnect();
const timer = seenFlushTimersByRoute.get(routeKey);
if (timer) clearTimeout(timer);
seenFlushTimersByRoute.delete(routeKey);
seenPendingByRoute.delete(routeKey);
};
}
function renderSkeleton(screen) {
@@ -776,6 +1117,38 @@ export function render({ navigate, route }) {
rerender();
};
const onMarkSeenBatch = async (refs) => {
if (!Array.isArray(refs) || !refs.length) return;
const login = String(state.session.login || '').trim();
if (!login || !routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) return;
await authService.markChannelMessagesSeen({
login,
channel: {
ownerBlockchainName: routeSelector.ownerBlockchainName,
channelRootBlockNumber: routeSelector.channelRootBlockNumber,
channelRootBlockHash: routeSelector.channelRootBlockHash,
},
messages: refs,
});
};
const onShare = async (routePath) => {
try {
const routeToShare = String(routePath || '').trim();
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
const result = await shareOrCopyLink({
title: 'SHiNE · Каналы',
text: 'Тред из канала SHiNE',
url: buildAbsoluteRouteUrl(routeToShare),
});
if (result === 'copied') showToast('Ссылка скопирована');
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
}
};
const onAddPost = async (bodyText) => {
const { login, storagePwd } = requireSigningSession();
if (!routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) {
@@ -824,11 +1197,13 @@ export function render({ navigate, route }) {
const skeleton = renderSkeleton(screen);
let cleanupSeenTracking = null;
(async () => {
try {
const apiData = await loadFromApi(route, channelId);
skeleton.remove();
renderBody(screen, navigate, routeKey, apiData, {
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
onToggleLike: async (messageRef, action) => {
try {
await onToggleLike(messageRef, action);
@@ -853,6 +1228,7 @@ export function render({ navigate, route }) {
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
}
},
onShare: onShare,
onEditDescription: async (descriptionText) => {
try {
await onEditDescription(descriptionText);
@@ -883,6 +1259,16 @@ export function render({ navigate, route }) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
}
},
onMarkSeenBatch: async (refs) => {
try {
await onMarkSeenBatch(refs);
} catch (error) {
throw new Error(toUserMessage(error, 'Не удалось отметить сообщения как прочитанные.'));
}
},
onSeenError: (error) => {
showStatus(toUserMessage(error, 'Не удалось обновить статус прочтения.'));
},
});
} catch (error) {
skeleton.remove();
@@ -896,6 +1282,7 @@ export function render({ navigate, route }) {
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
};
return screen;
+38 -24
View File
@@ -416,6 +416,7 @@ function mapMockGroups() {
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
notificationsEnabled: false,
messagesCount: Number(channel.messagesCount || 0),
unreadCount: 0,
lastMessageAt: 0,
ownerName: String(channel.ownerName || 'неизвестно'),
channelName: String(channel.channelName || channel.title || ''),
@@ -470,6 +471,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
channelDescription,
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0),
tabCategory,
isOwnChannel: isOwn,
@@ -479,6 +481,20 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
};
}
function isSyntheticDefaultChannel(row) {
if (!row || !row.isOwnChannel) return false;
const name = String(row.channelName || '').trim();
if (name !== '0') return false;
const hasDescription = Boolean(String(row.channelDescription || '').trim());
const hasMessages = Number(row.messagesCount || 0) > 0;
const hasTimestamp = Number(row.lastMessageAt || 0) > 0;
const preview = String(row.messagePreview || '').trim();
const hasCustomPreview = preview && preview !== 'Ждем ваших начинаний';
return !hasDescription && !hasMessages && !hasTimestamp && !hasCustomPreview;
}
function pullCreateSuccessFlash() {
try {
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
@@ -491,7 +507,9 @@ function pullCreateSuccessFlash() {
function mapApiFeed(feed, notificationsState) {
const index = {};
const ownChannels = (feed?.ownedChannels || []).map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
const ownChannels = (feed?.ownedChannels || [])
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState))
.filter((row) => !isSyntheticDefaultChannel(row));
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index, notificationsState));
@@ -508,22 +526,7 @@ function toListModel(groups) {
function renderEmptyState(activeTab, navigate) {
const wrap = document.createElement('div');
wrap.className = 'channels-empty-state channels-empty-state--compact';
const text = document.createElement('div');
text.className = 'meta-muted';
text.textContent = 'В этом разделе нет сообщений';
wrap.append(text);
if (activeTab === 'my') {
const cta = document.createElement('button');
cta.type = 'button';
cta.className = 'secondary-btn';
cta.textContent = 'Создать первый канал';
cta.addEventListener('click', () => navigate('add-channel-view'));
wrap.append(cta);
}
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
return wrap;
}
@@ -767,7 +770,7 @@ function renderChannelMain(channel, activeTab) {
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
const meta = document.createElement('p');
meta.className = 'channel-row-owner';
meta.className = 'channel-row-owner channel-counter-meta';
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
main.append(author, title, preview, meta);
@@ -790,7 +793,7 @@ function renderChannelMain(channel, activeTab) {
preview.textContent = channel.messagePreview || 'Ждем ваших начинаний';
const meta = document.createElement('p');
meta.className = 'channel-row-owner';
meta.className = 'channel-row-owner channel-counter-meta';
meta.textContent = `Сообщений: ${channel.messagesCount || 0}`;
main.prepend(title);
@@ -818,6 +821,8 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
filtered.forEach((channel) => {
const row = document.createElement('article');
row.className = 'channel-row';
const countersVisible = listState.revealedCounters.has(channel.id);
row.classList.toggle('is-counters-visible', countersVisible);
const avatar = document.createElement('div');
avatar.className = 'avatar';
@@ -835,6 +840,7 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
menuButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(menuButton);
listState.revealedCounters.add(channel.id);
if (listState.openMenuId === channel.id) {
closeChannelMenu(listState);
@@ -859,7 +865,9 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
const count = document.createElement('span');
count.className = 'unread channel-row-count';
count.textContent = String(channel.messagesCount || 0);
const unreadCount = Number(channel.unreadCount || 0);
count.textContent = unreadCount > 0 ? String(unreadCount) : '';
count.classList.toggle('is-empty', unreadCount <= 0);
controls.append(menuButton, time, count);
@@ -871,12 +879,13 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
container.append(list);
}
function updateBottomCta({ button, listState, navigate, onReload }) {
function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = false }) {
const tab = listState.activeTab;
const baseClass = `primary-btn channels-bottom-action${isTabEmpty && tab !== 'my' ? ' is-empty-lift' : ''}`;
if (tab === 'subscriptions') {
button.textContent = 'Подписаться на канал';
button.className = 'primary-btn channels-bottom-action';
button.className = baseClass;
button.onclick = () => openSimpleSubscribeModal({
kind: 'channel',
kindLabel: 'Подписка на канал',
@@ -888,7 +897,7 @@ function updateBottomCta({ button, listState, navigate, onReload }) {
if (tab === 'authors') {
button.textContent = 'Подписаться на автора';
button.className = 'primary-btn channels-bottom-action';
button.className = baseClass;
button.onclick = () => openSimpleSubscribeModal({
kind: 'user',
kindLabel: 'Подписка на автора',
@@ -899,7 +908,7 @@ function updateBottomCta({ button, listState, navigate, onReload }) {
}
button.textContent = 'Создать канал';
button.className = 'primary-btn channels-bottom-action';
button.className = baseClass;
button.onclick = () => navigate('add-channel-view');
}
@@ -948,6 +957,7 @@ export function render({ navigate }) {
activeTab: 'my',
openMenuId: null,
notificationsState,
revealedCounters: new Set(),
channels: [],
menuCleanup: null,
};
@@ -964,6 +974,8 @@ export function render({ navigate }) {
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
const rerenderList = () => {
const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab);
tabItems.forEach((tab) => {
const btn = tabs.querySelector(`[data-tab="${tab.key}"]`);
if (btn) btn.classList.toggle('is-active', tab.key === listState.activeTab);
@@ -984,6 +996,7 @@ export function render({ navigate }) {
listState,
navigate,
onReload: reloadFeed,
isTabEmpty,
});
};
@@ -1019,6 +1032,7 @@ export function render({ navigate }) {
listState,
navigate,
onReload: reloadFeed,
isTabEmpty: true,
});
loadFeedAndRender({ screen, listState, contentEl, navigate });
+6 -6
View File
@@ -67,7 +67,7 @@ export function render({ navigate, route }) {
};
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack dm-screen dm-chat-screen';
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
screen.append(
@@ -124,16 +124,16 @@ export function render({ navigate, route }) {
}
const wrap = document.createElement('div');
wrap.className = 'chat-wrap';
wrap.className = 'chat-wrap dm-chat-wrap';
const log = document.createElement('div');
log.className = 'messages-log';
log.className = 'messages-log dm-messages-log';
const form = document.createElement('form');
form.className = 'chat-input';
form.className = 'chat-input dm-chat-input';
form.innerHTML = `
<input class="input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
<button class="primary-btn" type="submit">Отправить</button>
<input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
<button class="primary-btn dm-send-btn" type="submit">Отправить</button>
`;
form.addEventListener('submit', async (event) => {
+7 -7
View File
@@ -5,10 +5,10 @@ export const pageMeta = { id: 'contact-search-view', title: 'Поиск конт
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack dm-screen dm-search-screen';
const input = document.createElement('input');
input.className = 'input';
input.className = 'input dm-input';
input.type = 'text';
input.name = 'contact';
input.placeholder = 'Введите начало логина';
@@ -16,14 +16,14 @@ export function render({ navigate }) {
input.maxLength = 80;
const resultsCard = document.createElement('section');
resultsCard.className = 'card stack';
resultsCard.className = 'card stack dm-dialog-card';
resultsCard.hidden = true;
const status = document.createElement('p');
status.className = 'meta-muted';
const resultsList = document.createElement('div');
resultsList.className = 'stack';
resultsList.className = 'stack dm-list';
const renderResults = (matches, query) => {
resultsList.innerHTML = '';
@@ -43,7 +43,7 @@ export function render({ navigate }) {
matches.forEach((login) => {
const row = document.createElement('article');
row.className = 'list-item';
row.className = 'list-item dm-dialog-card';
row.innerHTML = `
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
<div>
@@ -60,7 +60,7 @@ export function render({ navigate }) {
};
const searchButton = document.createElement('button');
searchButton.className = 'primary-btn';
searchButton.className = 'primary-btn dm-send-btn';
searchButton.type = 'button';
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', async () => {
@@ -84,7 +84,7 @@ export function render({ navigate }) {
controls.append(searchButton);
const formCard = document.createElement('section');
formCard.className = 'card stack';
formCard.className = 'card stack dm-dialog-card';
formCard.append(input, controls);
resultsCard.append(status, resultsList);
+6 -16
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import {
getChatMessages,
@@ -13,7 +13,7 @@ export const pageMeta = { id: 'messages-list', title: 'Личные сообще
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack dm-screen dm-list-screen';
screen.append(
renderHeader({
@@ -24,14 +24,11 @@ export function render({ navigate }) {
);
const list = document.createElement('div');
list.className = 'stack';
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка списка сообщений...';
list.className = 'stack dm-list';
function renderRow(item) {
const row = document.createElement('article');
row.className = 'list-item';
row.className = 'list-item dm-dialog-card';
row.innerHTML = `
<div class="avatar">${item.initials}</div>
<div>
@@ -56,6 +53,7 @@ export function render({ navigate }) {
const contacts = relations.outContacts || [];
setContacts(contacts);
list.innerHTML = '';
const contactRows = contacts.map((login) => {
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
const chat = getChatMessages(login);
@@ -100,19 +98,13 @@ export function render({ navigate }) {
empty.className = 'card meta-muted';
empty.textContent = 'Пока нет ни контактов, ни сообщений';
list.append(empty);
status.className = 'status-line is-available';
status.textContent = 'Нет диалогов.';
return;
}
rows.forEach((item) => list.append(renderRow(item)));
status.className = 'status-line is-available';
status.textContent = `Загружено диалогов: ${rows.length}`;
} catch (error) {
if (isSessionInvalidError(error)) {
list.innerHTML = '';
status.className = 'status-line is-unavailable';
status.textContent = 'Сессия устарела.';
const card = document.createElement('div');
card.className = 'card stack';
@@ -145,12 +137,10 @@ export function render({ navigate }) {
fail.className = 'card meta-muted';
fail.textContent = `Не удалось загрузить сообщения: ${error.message || 'unknown'}`;
list.append(fail);
status.className = 'status-line is-unavailable';
status.textContent = 'Список недоступен.';
}
}
screen.append(status, list);
screen.append(list);
loadList();
return screen;
}
+2 -2
View File
@@ -19,7 +19,7 @@ function renderList(container) {
export function render() {
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack notifications-screen';
screen.append(renderHeader({ title: 'Уведомления' }));
@@ -31,7 +31,7 @@ export function render() {
`;
const list = document.createElement('div');
list.className = 'stack';
list.className = 'stack notifications-list';
renderList(list);
tabs.querySelectorAll('.tab-btn').forEach((btn) => {
+4 -4
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import { profile } from '../mock-data.js';
import { authService, state } from '../state.js';
import {
@@ -83,7 +83,7 @@ export function render({ navigate }) {
const login = state.session.login || profile.login;
const screen = document.createElement('section');
screen.className = 'stack';
screen.className = 'stack profile-screen';
screen.append(
renderHeader({
@@ -561,8 +561,8 @@ export function render({ navigate }) {
updateTogglesUi();
updateGenderUi();
status.className = 'status-line is-available';
status.textContent = 'Актуальные параметры загружены.';
status.className = 'status-line';
status.textContent = '';
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось загрузить параметры: ${error.message || 'ошибка сети'}`;
+11
View File
@@ -756,6 +756,17 @@ export class AuthService {
return response.payload || {};
}
async markChannelMessagesSeen({ login, channel, messages }) {
const cleanLogin = String(login || '').trim();
const refs = Array.isArray(messages) ? messages : [];
const payload = { channel, messages: refs };
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('MarkChannelMessagesSeen', payload);
if (response.status !== 200) throw opError('MarkChannelMessagesSeen', response);
return response.payload || {};
}
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login for AddBlock');
+52
View File
@@ -168,3 +168,55 @@ export function normalizeChannelDescription(value) {
if (chars.length <= 200) return text;
return chars.slice(0, 200).join('');
}
function fallbackCopyText(text) {
const ta = document.createElement('textarea');
ta.value = String(text || '');
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.append(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
}
export async function shareOrCopyLink({ title = '', text = '', url = '' }) {
const link = String(url || '').trim();
if (!link) {
throw new Error('Ссылка для передачи не подготовлена.');
}
const payload = { title: String(title || '').trim(), text: String(text || '').trim(), url: link };
if (navigator?.share) {
try {
await navigator.share(payload);
return 'shared';
} catch (error) {
if (error?.name === 'AbortError') return 'cancelled';
// fallback to copy path
}
}
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(link);
return 'copied';
}
if (fallbackCopyText(link)) {
return 'copied';
}
throw new Error('Не удалось передать ссылку.');
}
export async function longPressFeel(el, delayMs = 130) {
const node = el instanceof Element ? el : null;
if (node) node.classList.add('is-long-press');
await new Promise((resolve) => setTimeout(resolve, Math.max(60, Number(delayMs) || 130)));
if (node) node.classList.remove('is-long-press');
}