SHA256
Каналы: новый роутинг, поиск, вход-возврат, удаление просмотров и документация
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user