diff --git a/VERSION.properties b/VERSION.properties index 06de6164..b57baa38 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.11 +client.version=1.5.12 server.version=1.4.9 diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 1e23888e..3530fa0d 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -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 = ` + + `; + 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 = ` + + `; + 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 = ` + ${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')} + #${escapeHtml(post.localNumber || '—')} +

${escapeHtml(String(parsed.text || '').trim() || 'Без текста')}

+ `; + 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); diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 43271ddc..76c1d417 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -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), diff --git a/shine-UI/js/services/attachment-format.js b/shine-UI/js/services/attachment-format.js index 4167e332..67291f92 100644 --- a/shine-UI/js/services/attachment-format.js +++ b/shine-UI/js/services/attachment-format.js @@ -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) => { diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 59dcf394..9387a337 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -2305,12 +2305,21 @@ textarea.input { touch-action: pan-y; } +.message-attachment-carousel-viewport.has-landscape-media { + align-items: start; + min-height: 0; +} + .message-attachment-slide { align-items: stretch; display: grid; min-height: 12rem; } +.message-attachment-slide.has-landscape-media { + min-height: 0; +} + .message-attachment-arrow { align-items: center; background: rgba(15, 23, 42, 0.62); @@ -2363,6 +2372,10 @@ textarea.input { position: relative; } +.message-attachment-media-frame.is-landscape { + min-height: 0; +} + .message-attachment-media { display: block; height: min(52vh, 20rem); @@ -2371,6 +2384,10 @@ textarea.input { width: 100%; } +.message-attachment-media.is-landscape { + height: auto; +} + .message-attachment-play { align-items: center; background: rgba(15, 23, 42, 0.72); @@ -3960,6 +3977,14 @@ textarea.input { margin-bottom: 6px; } +.channels-screen--channel .channel-message-card.is-diary-entry { + width: min(100%, 28rem); + max-width: min(84vw, 28rem); + padding: 16px 16px 15px; + justify-self: center; + align-self: center; +} + .channel-message-top { display: flex; align-items: center; @@ -4084,6 +4109,11 @@ textarea.input { box-shadow: 0 0 38px rgba(181, 136, 42, 0.14); } +.channels-screen .channel-message-card.is-focus-flash { + border-color: rgba(255, 214, 117, 0.5); + box-shadow: 0 0 0 1px rgba(255, 214, 117, 0.28), 0 0 34px rgba(255, 214, 117, 0.16); +} + .channel-message-body { color: #ffffff; line-height: 1.5; @@ -5460,6 +5490,58 @@ textarea.input { color: #d9e6ff; } +.channels-screen--channel .page-header .channel-header-entrypoint-btn { + border: 1px solid rgba(224, 190, 117, 0.38); + background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34)); + color: #f1d99c; + border-radius: 10px; + min-height: 36px; + padding: 6px 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + transform: translateY(-40%); +} + +.entrypoint-history-list { + display: grid; + gap: 10px; + max-height: min(58vh, 30rem); + overflow: auto; +} + +.entrypoint-history-item { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + background: rgba(17, 24, 39, 0.72); + color: #eef3ff; + display: grid; + gap: 4px; + padding: 12px 13px; + text-align: left; +} + +.entrypoint-history-item strong { + color: #f1d99c; + font-size: 13px; +} + +.entrypoint-history-item span { + color: rgba(188, 208, 244, 0.74); + font-size: 12px; +} + +.entrypoint-history-item p { + margin: 0; + color: #f5f8ff; + font-size: 13px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + .channel-head-actions .secondary-btn { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.12);