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
+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;