Доработать оглавление канала и отображение дневника

This commit is contained in:
AidarKC
2026-08-10 03:05:23 +04:00
parent 67d63256c2
commit 37e8ea27d0
5 changed files with 260 additions and 18 deletions
+155 -13
View File
@@ -53,7 +53,7 @@ const MSG_SUBTYPE_STATUS_IN_STUDY = 130;
const MSG_SUBTYPE_STATUS_ABANDONED = 140;
const MSG_SUBTYPE_STATUS_COMPLETED = 150;
const DIARY_CHANNEL_NAME = 'diary';
const DIARY_CHANNEL_DISPLAY_NAME = 'Личный дневник';
const DIARY_CHANNEL_DISPLAY_NAME = 'Дневник';
const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map();
@@ -319,6 +319,10 @@ function getChannelMessageTypeMeta(msgSubType) {
}
}
function isEntrypointSubType(msgSubType) {
return Number(msgSubType || 0) === MSG_SUBTYPE_TEXT_ENTRYPOINT;
}
function isDiarySelector(selector) {
return String(selector?.channelName || '').trim().toLowerCase() === DIARY_CHANNEL_NAME;
}
@@ -340,9 +344,9 @@ function isStatusActionSubType(msgSubType) {
function getStatusActionTypeMeta(statusSubType, targetMsgSubType = 0) {
const status = Number(statusSubType || 0);
const target = Number(targetMsgSubType || 0);
if (status === MSG_SUBTYPE_STATUS_DONE_ONCE && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Выполнено' };
if (status === MSG_SUBTYPE_STATUS_LEARNED && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Изучено' };
if (status === MSG_SUBTYPE_STATUS_SERVICE_PASSED && target === MSG_SUBTYPE_TEXT_SERVICE) return { label: 'Пройдено' };
if (status === MSG_SUBTYPE_STATUS_DONE_ONCE && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Выполнил упражнение' };
if (status === MSG_SUBTYPE_STATUS_LEARNED && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Изучил упражнение' };
if (status === MSG_SUBTYPE_STATUS_SERVICE_PASSED && target === MSG_SUBTYPE_TEXT_SERVICE) return { label: 'Пройденная процедура' };
if (status === MSG_SUBTYPE_STATUS_INTERESTED) return { label: 'Заинтересовался' };
if (status === MSG_SUBTYPE_STATUS_STARTED) return { label: 'Начал' };
if (status === MSG_SUBTYPE_STATUS_IN_STUDY) return { label: 'Изучаю' };
@@ -833,6 +837,100 @@ function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
});
}
function sortPostsByTimeDesc(posts = []) {
return [...posts].sort((a, b) => {
const byTime = Number(b?.timestampMs || 0) - Number(a?.timestampMs || 0);
if (byTime !== 0) return byTime;
return Number(b?.messageRef?.blockNumber || 0) - Number(a?.messageRef?.blockNumber || 0);
});
}
function getEntrypointPosts(posts = []) {
return sortPostsByTimeDesc((Array.isArray(posts) ? posts : []).filter((post) => isEntrypointSubType(post?.msgSubType)));
}
function flashAndScrollToMessage(messageRef) {
const key = messageRefKey(messageRef);
if (!key) return false;
const cards = Array.from(document.querySelectorAll('.channel-message-card[data-message-key]'));
const target = cards.find((card) => card.dataset.messageKey === key);
if (!target) return false;
target.classList.remove('is-focus-flash');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => target.classList.add('is-focus-flash'), 60);
window.setTimeout(() => target.classList.remove('is-focus-flash'), 1800);
return true;
}
function openEntrypointMenuModal({ onShowHistory }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-entrypoint-menu-modal">
<div class="modal-card stack">
<h3 class="modal-title">Оглавление канала</h3>
<div class="stack">
<button class="channel-menu-item channel-status-action-item" id="channel-entrypoint-history" type="button">Просмотреть историю изменений оглавления</button>
</div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-entrypoint-close" type="button">Закрыть</button>
</div>
</div>
</div>
`;
root.querySelector('#channel-entrypoint-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
root.querySelector('#channel-entrypoint-history')?.addEventListener('click', () => {
root.innerHTML = '';
if (typeof onShowHistory === 'function') onShowHistory();
});
}
function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect }) {
const root = document.getElementById('modal-root');
const items = getEntrypointPosts(posts);
const cleanTitle = String(channelTitle || '').trim() || 'канала';
root.innerHTML = `
<div class="modal" id="channel-entrypoint-history-modal">
<div class="modal-card stack">
<h3 class="modal-title">История оглавления</h3>
<p class="meta-muted">Показаны все версии оглавления канала ${escapeHtml(cleanTitle)}</p>
<div class="entrypoint-history-list" id="entrypoint-history-list"></div>
<button class="secondary-btn" id="entrypoint-history-close" type="button">Закрыть</button>
</div>
</div>
`;
const list = root.querySelector('#entrypoint-history-list');
if (list) {
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
list.append(empty);
} else {
items.forEach((post) => {
const parsed = parseMessageAttachments(post.body);
const item = document.createElement('button');
item.type = 'button';
item.className = 'entrypoint-history-item';
item.innerHTML = `
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
<span>#${escapeHtml(post.localNumber || '—')}</span>
<p>${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}</p>
`;
item.addEventListener('click', () => {
root.innerHTML = '';
if (typeof onSelect === 'function') onSelect(post);
});
list.append(item);
});
}
}
root.querySelector('#entrypoint-history-close')?.addEventListener('click', () => {
root.innerHTML = '';
});
}
function openRepostModal({ navigate, channels = [], onSubmit }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
@@ -1129,7 +1227,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
messageRef,
rawMessage: message,
msgSubType,
isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING,
isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING && !isStatusAction,
isStatusAction,
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
? {
@@ -1173,7 +1271,7 @@ async function loadFromApi(route, channelId) {
let selector = buildSelectorFromRoute(route, channelId);
if (selector?.ownerBlockchainName && selector?.channelName && isDiarySelector(selector)) {
if (!isAuthorized) {
throw new Error('Личный дневник доступен только после входа.');
throw new Error('Дневник доступен только после входа.');
}
const diaryPayload = await authService.getPersonalDiary(currentSessionLogin, 400, 'asc');
const diaryMessages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : [];
@@ -1192,7 +1290,7 @@ async function loadFromApi(route, channelId) {
channel: {
name: diaryPayload?.channel?.channelName || DIARY_CHANNEL_NAME,
displayTitle: String(diaryPayload?.channel?.displayName || DIARY_CHANNEL_DISPLAY_NAME).trim(),
displayName: DIARY_CHANNEL_DISPLAY_NAME,
displayName: 'Дневника',
description: String(diaryPayload?.channel?.channelDescription || '').trim(),
avaAr: '',
avaSha256: '',
@@ -1482,6 +1580,7 @@ function renderPostCard(post, {
onReply,
onRating,
onStatusAction,
onOpenEntrypointMenu,
onRepost,
onShare,
onEdit,
@@ -1491,6 +1590,7 @@ function renderPostCard(post, {
const card = document.createElement('article');
card.className = 'card stack channel-message-card';
if (post.isRating) card.classList.add('is-rating');
if (selector && isDiarySelector(selector)) card.classList.add('is-diary-entry');
const authorTile = document.createElement('button');
authorTile.type = 'button';
@@ -1544,7 +1644,6 @@ function renderPostCard(post, {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'channel-message-type-button';
if (!typeMeta.actionable) typeButton.classList.add('is-static');
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
@@ -1552,7 +1651,14 @@ function renderPostCard(post, {
animatePress(event.currentTarget);
onStatusAction(post);
});
} else if (isEntrypointSubType(post.msgSubType) && typeof onOpenEntrypointMenu === 'function') {
typeButton.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
onOpenEntrypointMenu(post);
});
} else {
typeButton.classList.add('is-static');
typeButton.disabled = true;
}
headRow.append(typeButton);
@@ -1597,7 +1703,7 @@ function renderPostCard(post, {
ratingBadge.textContent = 'Оценка';
card.append(ratingBadge);
}
if (post.isStatusAction) {
if (post.isStatusAction && selector && isDiarySelector(selector)) {
const statusBadge = document.createElement('span');
statusBadge.className = 'channel-message-kind-badge channel-message-kind-badge--status';
statusBadge.textContent = getStatusActionTypeMeta(post.msgSubType, post.targetMsgSubType).label;
@@ -1848,6 +1954,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
onReply: handlers.onReply,
onRating: handlers.onRating,
onStatusAction: handlers.onStatusAction,
onOpenEntrypointMenu: handlers.onOpenEntrypointMenu,
onRepost: handlers.onRepost,
onShare: handlers.onShare,
onEdit: handlers.onEdit,
@@ -1862,7 +1969,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = channelData.isDiary
? 'К сожалению, у вас пока еще ничего нет в личном дневнике.'
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
: 'Ждем ваших начинаний';
feed.append(empty);
}
@@ -1928,13 +2035,20 @@ export function render({ navigate, route }) {
const header = renderHeader({
title: '',
leftAction: { label: '<', onClick: () => navigateBack() },
rightActions: [{ label: 'Канал: ...', onClick: () => {} }],
rightActions: [
{ label: 'Канал: ...', className: 'channel-header-route-btn', onClick: () => {} },
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
],
});
const channelHeaderButton = header.querySelector('.header-actions .icon-btn');
const channelHeaderButton = header.querySelector('.header-actions .channel-header-route-btn');
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
if (channelHeaderButton) {
channelHeaderButton.classList.add('channel-header-route-btn');
channelHeaderButton.disabled = true;
}
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
}
const rerender = () => {
const current = document.querySelector('section.channels-screen--channel');
@@ -2179,6 +2293,18 @@ export function render({ navigate, route }) {
const apiData = await loadFromApi(route, channelId);
activeSelector = apiData?.selector || null;
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts);
const openEntrypointHistory = () => {
openEntrypointHistoryModal({
channelTitle: titleLabel,
posts: apiData?.posts,
onSelect: (post) => {
if (!flashAndScrollToMessage(post?.messageRef)) {
showToast('Не удалось найти запись оглавления в ленте');
}
},
});
};
if (channelHeaderButton) {
channelHeaderButton.textContent = titleLabel;
channelHeaderButton.disabled = false;
@@ -2193,6 +2319,17 @@ export function render({ navigate, route }) {
});
};
}
if (channelEntrypointButton) {
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
channelEntrypointButton.hidden = !canShowEntrypointButton;
channelEntrypointButton.disabled = !canShowEntrypointButton;
channelEntrypointButton.onclick = () => {
const latestEntrypoint = entrypointPosts[0];
if (!latestEntrypoint?.messageRef || !flashAndScrollToMessage(latestEntrypoint.messageRef)) {
showToast('Не удалось найти актуальное оглавление');
}
};
}
skeleton.remove();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
onAddMessage: () => {
@@ -2241,6 +2378,11 @@ export function render({ navigate, route }) {
showStatus(toUserMessage(error, 'Не удалось записать действие.'));
}
},
onOpenEntrypointMenu: () => {
openEntrypointMenuModal({
onShowHistory: openEntrypointHistory,
});
},
onRepost: async (messageRef) => {
try {
await onRepost(messageRef);
+3 -3
View File
@@ -22,7 +22,7 @@ const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
const DIARY_DISPLAY_NAME = 'Личный дневник';
const DIARY_DISPLAY_NAME = 'Дневник';
function cleanChannelMessagePreview(text) {
const parsed = parseMessageAttachments(text);
@@ -730,7 +730,7 @@ function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {
avatar: 'Д',
avaAr: '',
title: DIARY_DISPLAY_NAME,
technicalLabel: 'Виртуальная лента ваших действий',
technicalLabel: 'Виртуальная лента Дневника',
channelName: DIARY_CHANNEL_NAME,
displayTitle: DIARY_DISPLAY_NAME,
channelDescription: 'История действий по упражнениям, услугам и курсам',
@@ -738,7 +738,7 @@ function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {
channelTypeVersion: 1,
messagePreview: String(lastMessage?.text || '').trim()
? cleanChannelMessagePreview(lastMessage?.text)
: 'Новая запись в личном дневнике',
: 'Новая запись в Дневнике',
messagesCount: messages.length,
unreadCount: 0,
lastMessageAt: Number(lastMessage?.createdAtMs || 0),
+19 -1
View File
@@ -278,12 +278,25 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
openAttachmentViewer({ item, url, kind });
});
const bindImageLayout = (img) => {
img.addEventListener('load', () => {
const naturalWidth = Number(img.naturalWidth || 0);
const naturalHeight = Number(img.naturalHeight || 0);
const isLandscape = naturalWidth > 0 && naturalHeight > 0 && naturalWidth > naturalHeight;
img.classList.toggle('is-landscape', isLandscape);
frame.classList.toggle('is-landscape', isLandscape);
slide.classList.toggle('has-landscape-media', isLandscape);
slide.parentElement?.classList.toggle('has-landscape-media', isLandscape);
}, { once: true });
};
if (kind === 'image') {
const img = document.createElement('img');
img.className = 'message-attachment-media';
img.src = url;
img.alt = item.name;
img.loading = 'lazy';
bindImageLayout(img);
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
frame.append(img);
} else if (previewUrl) {
@@ -292,6 +305,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
img.src = previewUrl;
img.alt = `${item.name} preview`;
img.loading = 'lazy';
bindImageLayout(img);
img.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
const play = document.createElement('span');
play.className = 'message-attachment-play';
@@ -351,6 +365,7 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
next.setAttribute('aria-label', 'Следующее вложение');
const counter = document.createElement('div');
counter.className = 'message-attachment-counter';
wrap.classList.toggle('has-single-attachment', items.length <= 1);
const render = () => {
stopVideos(wrap);
@@ -358,6 +373,8 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
const kind = getAttachmentKind(item);
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
const previewUrl = item.preview?.ar ? buildArweaveDataUrl({ gateway, txId: item.preview.ar }) : '';
viewport.classList.remove('has-landscape-media');
slide.className = 'message-attachment-slide';
slide.dataset.unavailable = '0';
slide.replaceChildren();
slide.append(kind === 'file'
@@ -365,7 +382,8 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
prev.hidden = items.length <= 1 || index <= 0;
next.hidden = items.length <= 1 || index >= items.length - 1;
counter.textContent = `${index + 1} из ${items.length}`;
counter.hidden = items.length <= 1;
counter.textContent = items.length > 1 ? `${index + 1} из ${items.length}` : '';
};
const move = (delta) => {