Каналы: новый роутинг, поиск, вход-возврат, удаление просмотров и документация

This commit is contained in:
AidarKC
2026-05-08 01:15:54 +03:00
parent 6774c26ea1
commit acdd6c928b
18 changed files with 657 additions and 458 deletions
+63 -215
View File
@@ -21,9 +21,6 @@ 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 {
@@ -164,6 +161,13 @@ function parseDescriptionOverride(payload) {
function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {};
if (params.ownerLogin && params.channelName) {
return {
ownerLogin: String(params.ownerLogin || '').trim(),
channelName: String(params.channelName || '').trim(),
};
}
if (params.ownerBlockchainName) {
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
if (rootBlockNumber != null) {
@@ -186,6 +190,17 @@ function buildSelectorFromRoute(route, channelId) {
function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return '';
const ownerLogin = String(selector.ownerLogin || '').trim();
const channelName = String(selector.channelName || '').trim();
if (ownerLogin && channelName) {
return [
'channel',
encodeRoutePart(ownerLogin),
encodeRoutePart(channelName),
messageRef.blockNumber,
normalizeRouteHash(messageRef.blockHash),
].join('/');
}
return [
'channel-thread-view',
encodeRoutePart(messageRef.blockchainName),
@@ -502,8 +517,6 @@ 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) : '',
@@ -511,7 +524,25 @@ function mapApiMessageToPost(message, selector, localNumber) {
}
async function loadFromApi(route, channelId) {
const selector = buildSelectorFromRoute(route, channelId);
let selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerLogin && selector?.channelName) {
const ownerFeed = await authService.listSubscriptionsFeed(selector.ownerLogin, 1000);
const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
const channel = ownChannels.find((item) => (
String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
));
if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
throw new Error('Канал не найден.');
}
selector = {
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash),
ownerLogin: selector.ownerLogin,
channelName: selector.channelName,
};
}
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
throw new Error('Не удалось определить канал из адреса страницы.');
}
@@ -520,8 +551,6 @@ 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();
@@ -547,10 +576,6 @@ 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,
};
@@ -665,11 +690,7 @@ function renderPostCard(post, {
body.className = 'channel-message-body';
body.textContent = post.body;
const views = document.createElement('p');
views.className = 'channel-message-views';
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
card.append(topRow, body, views);
card.append(topRow, body);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
@@ -703,19 +724,23 @@ function renderPostCard(post, {
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true"></span>
<span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</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;
if (!isLiked) {
const ok = window.confirm('Поставить лайк?');
if (!ok) return;
}
revealCounters();
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
const labelEl = likeButton.querySelector('.channel-action-label');
if (labelEl) labelEl.textContent = 'Сияние...';
if (labelEl) labelEl.textContent = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
@@ -724,7 +749,7 @@ function renderPostCard(post, {
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>
<span class="channel-action-label">Ответить</span>
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
@@ -754,7 +779,7 @@ function renderPostCard(post, {
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>
<span class="channel-action-label">Отправить</span>
`;
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -816,25 +841,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
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) => {
@@ -849,7 +860,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const key = messageRefKey(post.messageRef);
if (key) {
postsByKey.set(key, post);
if (post.seenByMe !== true) unreadKeys.add(key);
}
feed.append(row);
});
@@ -860,101 +870,6 @@ 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) => {
@@ -965,7 +880,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
});
} else {
actionButton.addEventListener('click', handlers.onUnfollowChannel);
actionButton.addEventListener('click', handlers.onSubscribeChannel);
}
const backButton = document.createElement('button');
@@ -973,57 +888,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
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);
}
screen.append(head, actionButton, feed, backButton);
applyPendingScroll(screen, routeKey);
return () => {
seenObserver?.disconnect();
const timer = seenFlushTimersByRoute.get(routeKey);
if (timer) clearTimeout(timer);
seenFlushTimersByRoute.delete(routeKey);
seenPendingByRoute.delete(routeKey);
// noop
};
}
@@ -1070,7 +939,9 @@ export function render({ navigate, route }) {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
throw new Error('Сессия недействительна. Выполните вход заново.');
state.authReturnHash = window.location.hash || '#/channels-list';
navigate('login-view');
throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd };
};
@@ -1117,21 +988,6 @@ 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();
@@ -1145,7 +1001,7 @@ export function render({ navigate, route }) {
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
}
};
@@ -1237,11 +1093,14 @@ export function render({ navigate, route }) {
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
}
},
onUnfollowChannel: async (event) => {
onSubscribeChannel: async (event) => {
animatePress(event?.currentTarget);
try {
const { login, storagePwd } = requireSigningSession();
if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.');
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
if (!ok) return;
await authService.addBlockFollowChannel({
login,
@@ -1249,26 +1108,15 @@ export function render({ navigate, route }) {
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: true,
unfollow: false,
});
softHaptic(15);
showToast('Отписка от канала выполнена');
navigate('channels-list');
showToast('Подписка на канал выполнена');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
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();