SHA256
Channels UI + read/unread + unique views + style polish
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 || 'ошибка сети'}`;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user