diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index 7b3e907..69e9f41 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -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 = ` ${item.icon} diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 01869a6..70f2ce7 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -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'; diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index b301a68..abddf46 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -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 = ` + + ${isPending ? 'Сияние...' : 'Сияние'} + ${post.likesCount || 0} + `; 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 = ` + + Отразить + `; 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 = ` + + Тред + ${post.repliesCount || 0} + `; 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 = ` + + Транслировать + `; + 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 = ` + + + `; + 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; diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index f1716bb..db46935 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -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 }); diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index 5456654..ed2b1ca 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -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 = ` - - + + `; form.addEventListener('submit', async (event) => { diff --git a/shine-UI/js/pages/contact-search-view.js b/shine-UI/js/pages/contact-search-view.js index b7011ae..ea9a862 100644 --- a/shine-UI/js/pages/contact-search-view.js +++ b/shine-UI/js/pages/contact-search-view.js @@ -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 = `
${(login[0] || '?').toUpperCase()}
@@ -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); diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index 1cfa204..b5b9391 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -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 = `
${item.initials}
@@ -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; } diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js index 67f2a36..230937e 100644 --- a/shine-UI/js/pages/notifications-view.js +++ b/shine-UI/js/pages/notifications-view.js @@ -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) => { diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index fd1a8a2..477cebc 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -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 || 'ошибка сети'}`; diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 1f932a3..b92e8a2 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -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'); diff --git a/shine-UI/js/services/channels-ux.js b/shine-UI/js/services/channels-ux.js index 55f697f..a156440 100644 --- a/shine-UI/js/services/channels-ux.js +++ b/shine-UI/js/services/channels-ux.js @@ -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'); +} diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index e4dca4f..74c37df 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1466,6 +1466,14 @@ textarea.input { linear-gradient(180deg, rgba(4, 11, 24, 0.92), rgba(4, 11, 23, 0.4) 46%, transparent 100%); } +.channels-screen.channels-screen--channel::before { + background: + radial-gradient(circle at 14% 16%, rgba(82, 73, 184, 0.2), transparent 44%), + radial-gradient(circle at 86% 8%, rgba(57, 89, 161, 0.22), transparent 46%), + radial-gradient(circle at 54% 84%, rgba(64, 47, 138, 0.15), transparent 42%), + linear-gradient(180deg, #0a0b10 0%, #0a0b10 62%, rgba(10, 11, 16, 0.92) 100%); +} + .channels-screen .page-header { margin-bottom: 0; align-items: flex-end; @@ -1692,6 +1700,7 @@ textarea.input { radial-gradient(circle at 100% 0%, rgba(72, 106, 179, 0.22), transparent 46%); cursor: pointer; transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; + backdrop-filter: blur(10px); } .channel-row:hover { @@ -1751,6 +1760,19 @@ textarea.input { color: #bdcdeb; } +.channel-counter-meta, +.channel-counter-value { + opacity: 0; + transform: translateY(3px); + transition: opacity 0.22s ease, transform 0.22s ease; +} + +.channel-row.is-counters-visible .channel-counter-meta, +.channel-row.is-counters-visible .channel-counter-value { + opacity: 1; + transform: translateY(0); +} + .channel-row-meta { display: grid; justify-items: center; @@ -1828,46 +1850,157 @@ textarea.input { gap: 10px; } -.channel-message-card { - gap: 9px; - padding: 13px; - border-radius: 16px; - border-color: rgba(182, 149, 78, 0.3); +.channels-screen .channel-message-card { + gap: 14px; + padding: 20px; + margin-bottom: 24px; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(20, 25, 35, 0.55); + backdrop-filter: blur(24px); + box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); +} + +.channel-message-top { + display: flex; + align-items: center; + gap: 12px; +} + +.channel-message-avatar { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 17px; + font-weight: 700; + color: #f4f6ff; + background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89); +} + +.channel-message-author { + display: grid; + gap: 4px; + min-width: 0; } .channel-message-title { - font-size: 15px; - color: #f2dcab; + font-size: 20px; + color: #f5f8ff; } .channel-message-body { - color: #eef3ff; - line-height: 1.45; - font-size: 14px; + color: #ffffff; + line-height: 1.5; + font-size: 15px; white-space: pre-wrap; word-break: break-word; - border-radius: 10px; - padding: 8px 10px; - background: linear-gradient(170deg, rgba(22, 40, 73, 0.75), rgba(12, 25, 48, 0.78)); - border: 1px solid rgba(116, 141, 193, 0.26); + margin: 0; + padding: 0; + background: transparent; + border: 0; } -.channel-message-stats { +.channel-message-time { font-size: 12px; - color: #9fb2dc; + color: rgba(255, 255, 255, 0.48); +} + +.channel-message-views { + margin: 0; + font-size: 12px; + line-height: 1.35; + color: rgba(255, 255, 255, 0.58); +} + +.channel-message-stats.is-hidden, +.thread-node-stats.is-hidden { + opacity: 0; + max-height: 0; + overflow: hidden; + margin: 0; } .channel-message-actions { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + display: flex; + align-items: center; + justify-content: space-between; gap: 8px; + flex-wrap: nowrap; + overflow-x: auto; + padding-top: 14px; + border-top: 1px solid rgba(255, 255, 255, 0.06); } -.channel-message-actions .secondary-btn, -.thread-node-actions .secondary-btn { - min-height: 40px; - padding: 8px 9px; - font-size: 13px; +.channel-message-actions::-webkit-scrollbar { + width: 0; + height: 0; + display: none; +} + +.channel-action-item { + appearance: none; + border: 0; + background: transparent; + min-height: 0; + padding: 0; + margin: 0; + display: inline-flex; + align-items: center; + gap: 5px; + color: rgba(255, 255, 255, 0.55); + cursor: pointer; + transition: color 0.18s ease, text-shadow 0.18s ease, transform 0.18s ease; + white-space: nowrap; +} + +.channel-action-item:disabled { + opacity: 0.56; + cursor: not-allowed; +} + +.channel-action-item:focus-visible { + outline: none; + color: rgba(255, 220, 100, 0.9); +} + +.channel-action-item:hover, +.channel-action-item.is-liked, +.channel-action-item.is-long-press { + color: rgba(255, 220, 100, 0.9); + text-shadow: 0 0 12px rgba(255, 220, 100, 0.25); +} + +.channel-action-icon { + font-size: 14px; + line-height: 1; +} + +.channel-action-label { + font-size: 12px; + line-height: 1; + letter-spacing: 0.01em; +} + +.channel-action-counter { + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + opacity: 0; + max-width: 0; + overflow: hidden; + transform: translateX(-3px); + transition: opacity 0.2s ease, max-width 0.2s ease, transform 0.2s ease, margin-left 0.2s ease; +} + +.channels-screen .channel-message-card.is-counters-visible .channel-action-counter { + opacity: 1; + max-width: 40px; + margin-left: 2px; + transform: translateX(0); } .thread-summary { @@ -1880,6 +2013,7 @@ textarea.input { gap: 9px; border-radius: 16px; border-color: rgba(183, 150, 79, 0.3); + backdrop-filter: blur(12px); } .thread-node-heading { @@ -1909,9 +2043,16 @@ textarea.input { font-size: 12px; } +.thread-node-views { + margin: 0; + font-size: 12px; + line-height: 1.35; + color: rgba(255, 255, 255, 0.58); +} + .thread-node-actions { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } @@ -1965,19 +2106,33 @@ textarea.input { .channel-action-like.is-liked, .thread-like-btn.is-liked { - background: linear-gradient(120deg, rgba(128, 39, 56, 0.92), rgba(92, 26, 39, 0.94)); - border-color: rgba(250, 145, 165, 0.54); - color: #ffe5ec; + color: rgba(255, 220, 100, 0.9); +} + +.channel-action-like, +.thread-like-btn { + transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease; +} + +.channel-action-like.is-long-press, +.thread-like-btn.is-long-press { + transform: scale(0.96); + filter: brightness(1.08) drop-shadow(0 0 8px rgba(255, 220, 100, 0.28)); + box-shadow: none; } .channel-action-reply, .thread-reply-btn { - border-color: rgba(152, 181, 240, 0.48); + color: rgba(255, 255, 255, 0.55); } .channel-action-thread { - border-color: rgba(216, 178, 95, 0.5); - color: #f3ddac; + color: rgba(255, 255, 255, 0.55); +} + +.channel-action-share, +.thread-share-btn { + color: rgba(255, 255, 255, 0.55); } @media (max-width: 430px) { @@ -2022,6 +2177,10 @@ textarea.input { .channel-message-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .thread-node-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 365px) { @@ -2301,18 +2460,18 @@ textarea.input { } .author-line-login { - font-weight: 500; - color: #f3dbab; + font-weight: 700; + color: #f5f8ff; } .author-line-num { font-weight: 400; - color: #95a8d2; + color: rgba(255, 255, 255, 0.44); } -.channel-message-card.is-own-new, +.channels-screen .channel-message-card.is-own-new, .thread-node-card.is-own-new { - box-shadow: 0 0 0 1px rgba(217, 180, 97, 0.5), 0 12px 24px rgba(2, 8, 16, 0.46); + box-shadow: 0 0 52px rgba(88, 69, 176, 0.2), 0 12px 24px rgba(2, 8, 16, 0.46); } .is-springing { @@ -2365,6 +2524,73 @@ textarea.input { min-height: 0; } +.channels-unread-divider { + display: flex; + align-items: center; + justify-content: center; + margin: 6px 0 2px; + padding: 8px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.12); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 220, 130, 0.9); + font-size: 12px; + letter-spacing: 0.03em; +} + +.channels-unread-jump { + position: fixed; + right: 18px; + bottom: 96px; + width: 48px; + height: 48px; + border-radius: 999px; + border: 1px solid rgba(212, 175, 55, 0.45); + background: linear-gradient(160deg, rgba(16, 24, 38, 0.92), rgba(12, 18, 30, 0.86)); + color: rgba(255, 227, 150, 0.95); + box-shadow: 0 12px 26px rgba(3, 8, 18, 0.45), 0 0 20px rgba(212, 175, 55, 0.16); + display: inline-flex; + align-items: center; + justify-content: center; + z-index: 38; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.channels-unread-jump.is-visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.channels-unread-jump:hover { + box-shadow: 0 14px 30px rgba(3, 8, 18, 0.55), 0 0 24px rgba(212, 175, 55, 0.22); +} + +.channels-unread-jump-icon { + font-size: 18px; + line-height: 1; +} + +.channels-unread-jump-badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 20px; + height: 20px; + border-radius: 999px; + background: rgba(212, 175, 55, 0.95); + color: #1f1702; + font-size: 11px; + font-weight: 700; + padding: 0 5px; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 2px rgba(10, 14, 24, 0.9); +} + .channels-list-body-fade { animation: channels-fade-in 0.2s ease; } @@ -2417,3 +2643,1027 @@ textarea.input { border-color: rgba(216, 178, 95, 0.52); color: #f3dca8; } + +/* ===== Final Glass Polish (channels) ===== */ +.channels-screen { + color: rgba(255, 255, 255, 0.9); +} + +.channels-screen::before { + background: + radial-gradient(600px 600px at 100% 0%, rgba(100, 60, 200, 0.12), transparent 70%), + radial-gradient(500px 500px at 0% 100%, rgba(40, 80, 200, 0.08), transparent 70%), + linear-gradient(180deg, #0a0b10 0%, #0a0b10 100%); +} + +.channels-screen .card, +.channel-row, +.thread-node-card, +.thread-block, +.thread-summary { + background: rgba(20, 25, 35, 0.55); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 20px; + box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); +} + +.channel-row, +.channels-screen .channel-message-card, +.thread-node-card { + padding: 20px; + margin-bottom: 16px; +} + +.channels-screen .channel-message-card, +.thread-node-card, +.thread-block, +.thread-summary, +.channel-row { + border-color: rgba(255, 255, 255, 0.07); +} + +.channels-screen .channel-message-card, +.thread-node-card { + background: rgba(20, 25, 35, 0.55); +} + +.channel-message-actions, +.thread-node-actions { + display: flex !important; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: nowrap; + overflow-x: auto; + padding-top: 14px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.thread-node-actions .secondary-btn, +.channel-action-item { + display: inline-flex; + align-items: center; + gap: 5px; + background: none; + border: none; + color: rgba(255, 255, 255, 0.45); + font-size: 12px; + cursor: pointer; + padding: 0; + min-height: 0; + white-space: nowrap; +} + +.channel-action-like.is-liked, +.thread-like-btn.is-liked { + color: rgba(255, 200, 50, 0.9); +} + +.channel-action-item:hover, +.thread-node-actions .secondary-btn:hover { + color: rgba(255, 200, 50, 0.9); +} + +.channels-tabs { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.channels-tab-btn { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + color: rgba(255, 255, 255, 0.4); +} + +.channels-tab-btn.is-active { + background: transparent; + border-bottom-color: rgba(255, 200, 50, 0.9); + color: rgba(255, 200, 50, 0.9); +} + +.channel-row .avatar { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + color: #ffffff; + font-weight: 700; +} + +.channel-row-title { + color: #ffffff; + font-size: 15px; + font-weight: 700; +} + +.channel-row-message { + color: rgba(255, 255, 255, 0.5); + font-size: 13px; +} + +.channel-row-time { + color: rgba(255, 255, 255, 0.35); + font-size: 12px; + text-align: right; + width: 100%; +} + +.channel-menu-trigger { + border: none; + background: none; + color: rgba(255, 255, 255, 0.4); +} + +.channels-bottom-action, +.primary-btn.channel-main-action { + background: rgba(255, 180, 0, 0.12); + border: 1px solid rgba(255, 180, 0, 0.35); + color: rgba(255, 200, 50, 0.9); + border-radius: 14px; + padding: 14px; + width: 100%; +} + +.channel-head-card { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + margin-bottom: 6px !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} + +.channel-head-title, +.channel-head-meta { + line-height: 1.35; + font-family: inherit; +} + +.channel-head-title { + color: rgba(255, 255, 255, 0.95); + font-size: 22px; + font-weight: 700; + text-align: center; +} + +.channel-head-meta { + color: rgba(255, 255, 255, 0.4); + font-size: 12px; + font-weight: 500; + text-align: center; +} + +.channels-screen--thread .channels-user-chip { + background: transparent !important; + border: none !important; + box-shadow: none !important; + padding: 0 !important; + margin-bottom: 0 !important; + color: rgba(255, 255, 255, 0.35); + min-height: 0; + font-size: 12px; +} + +.channels-screen--thread .page-title { + color: rgba(255, 255, 255, 0.95); + font-weight: 700; +} + +.channels-screen--channel .page-header .icon-btn, +.channels-screen--thread .page-header .icon-btn { + border: none; + background: none; + color: rgba(255, 255, 255, 0.9); + box-shadow: none; +} + +.channel-head-actions .secondary-btn { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.6); + border-radius: 12px; + padding: 10px 16px; +} + +.thread-block--replies > .section-title { + color: rgba(255, 255, 255, 0.4); + font-size: 12px; + font-weight: 500; + margin: 0 0 2px; +} + +.channels-empty-state--compact .meta-muted { + color: rgba(255, 255, 255, 0.4); + text-align: center; + font-style: italic; +} + +#about-channel-modal.modal, +#reply-modal.modal, +#channel-message-modal.modal, +#channel-edit-description-modal.modal, +#channels-subscribe-modal.modal, +#thread-reply-modal.modal { + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +#about-channel-modal .modal-card, +#reply-modal .modal-card, +#channel-message-modal .modal-card, +#channel-edit-description-modal .modal-card, +#channels-subscribe-modal .modal-card, +#thread-reply-modal .modal-card { + background: rgba(15, 18, 30, 0.92); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 180, 0, 0.2); + border-radius: 20px; + padding: 24px; +} + +#about-channel-modal .modal-title, +#reply-modal .modal-title, +#channel-message-modal .modal-title, +#channel-edit-description-modal .modal-title, +#channels-subscribe-modal .modal-title, +#thread-reply-modal .modal-title { + color: rgba(255, 200, 50, 0.95); + font-size: 18px; + font-weight: 700; + margin-bottom: 16px; +} + +#about-channel-modal .input, +#reply-modal .input, +#channel-message-modal .input, +#channel-edit-description-modal .input, +#channels-subscribe-modal .input, +#thread-reply-modal .input { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + color: #ffffff; + padding: 12px 16px; + font-size: 14px; + resize: vertical; + width: 100%; +} + +#channel-edit-description-counter, +#channel-edit-description-modal #channel-description-counter { + color: rgba(255, 255, 255, 0.35); + font-size: 12px; +} + +#about-channel-modal .secondary-btn, +#reply-modal .secondary-btn, +#channel-message-modal .secondary-btn, +#channel-edit-description-modal .secondary-btn, +#channels-subscribe-modal .secondary-btn, +#thread-reply-modal .secondary-btn { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.6); + border-radius: 12px; + padding: 13px 24px; +} + +#about-channel-modal .primary-btn, +#reply-modal .primary-btn, +#channel-message-modal .primary-btn, +#channel-edit-description-modal .primary-btn, +#channels-subscribe-modal .primary-btn, +#thread-reply-modal .primary-btn { + background: rgba(255, 180, 0, 0.15); + border: 1px solid rgba(255, 180, 0, 0.4); + color: rgba(255, 200, 50, 0.95); + border-radius: 12px; + padding: 13px 24px; + font-weight: 600; +} + +/* ===== Direct Messages Glass Theme (DM-only) ===== */ +.dm-screen { + position: relative; + isolation: isolate; + background: #05070A; + color: rgba(255, 255, 255, 0.9); + min-height: 100%; + align-content: start; +} + +.dm-screen::before { + content: ""; + position: absolute; + inset: -12px -12px 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(260px 260px at 86% 10%, rgba(147, 112, 219, 0.25), transparent 72%), + radial-gradient(220px 220px at 14% 84%, rgba(147, 112, 219, 0.2), transparent 72%), + radial-gradient(220px 220px at 76% 68%, rgba(212, 175, 55, 0.16), transparent 75%), + radial-gradient(190px 190px at 26% 20%, rgba(212, 175, 55, 0.12), transparent 74%), + #05070A; + animation: dm-orbs-drift 16s ease-in-out infinite alternate; +} + +.screen-content .dm-screen { + min-height: 100%; +} + +@keyframes dm-orbs-drift { + from { transform: translate3d(0, 0, 0) scale(1); } + to { transform: translate3d(0, -8px, 0) scale(1.02); } +} + +.dm-screen .page-header { + position: sticky; + top: 0; + z-index: 12; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid rgba(212, 175, 55, 0.24); + background: rgba(10, 12, 18, 0.72); +} + +.dm-screen .page-title { + color: rgba(255, 255, 255, 0.95); +} + +.dm-dialog-card { + background: rgba(20, 25, 35, 0.4); + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.4); + border-radius: 20px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37); +} + +.dm-screen .list-item .avatar { + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; + border-radius: 50%; + border: 1px solid rgba(212, 175, 55, 0.45); + background: + radial-gradient(circle at 26% 24%, rgba(196, 165, 255, 0.95), rgba(78, 87, 197, 0.9) 58%, rgba(36, 45, 121, 0.9)); + color: #ffffff; + font-weight: 700; +} + +.dm-screen .meta-muted { + color: rgba(255, 255, 255, 0.5); +} + +.dm-status-line { + color: rgba(255, 255, 255, 0.5); +} + +.dm-screen .unread { + min-width: 26px; + height: 26px; + padding: 0 8px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(212, 175, 55, 0.5); + background: rgba(212, 175, 55, 0.22); + color: rgba(255, 200, 50, 0.95); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.32); +} + +.dm-chat-wrap { + gap: 12px; +} + +.dm-messages-log { + gap: 10px; +} + +.dm-screen .bubble { + max-width: 78%; + padding: 11px 14px; + line-height: 1.42; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32); +} + +.dm-screen .bubble.in { + justify-self: start; + border-radius: 18px 18px 18px 4px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(20, 25, 35, 0.58); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.dm-screen .bubble.out { + justify-self: end; + border-radius: 18px 18px 4px 18px; + border: 1px solid rgba(212, 175, 55, 0.38); + background: rgba(212, 175, 55, 0.16); + color: rgba(255, 236, 191, 0.96); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.dm-chat-input { + gap: 10px; +} + +.dm-screen .input, +.dm-input { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(212, 175, 55, 0.35); + border-radius: 14px; + color: rgba(255, 255, 255, 0.92); +} + +.dm-input::placeholder { + color: rgba(255, 255, 255, 0.42); +} + +.dm-send-btn { + background: rgba(212, 175, 55, 0.2); + border: 1px solid rgba(212, 175, 55, 0.45); + color: rgba(255, 217, 128, 0.98); + border-radius: 14px; + font-weight: 700; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3); +} + +/* DM messages-list status + empty block as full glass buttons */ +.dm-screen .dm-status-line { + display: block; + width: calc(100% - 40px); + margin: 2px 20px 10px; + padding: 12px 16px; + border-radius: 14px; + background: rgba(18, 24, 38, 0.42); + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.32); + color: rgba(255, 227, 154, 0.92); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} + +/* Hide "Нет диалогов." line on DM list per UI request */ +.dm-screen .dm-status-line { + display: none !important; +} + +.dm-screen .dm-status-line.is-available { + color: rgba(255, 227, 154, 0.92); +} + +.dm-screen .dm-status-line.is-unavailable { + color: rgba(255, 161, 176, 0.95); +} + +.dm-screen .dm-list > .card.meta-muted { + width: calc(100% - 40px); + margin: 0 20px; + padding: 14px 16px; + border-radius: 14px; + background: rgba(18, 24, 38, 0.42); + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.32); + color: rgba(225, 233, 248, 0.86); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); +} + +/* ===== Channels list tabs: match toolbar active button background ===== */ +.channels-screen--list .channels-tabs { + background: + radial-gradient(circle at 18% -120%, rgba(228, 186, 94, 0.28), transparent 48%), + linear-gradient(160deg, rgba(14, 25, 47, 0.98), rgba(7, 16, 34, 0.98)); + border: 1px solid rgba(197, 160, 85, 0.38); + box-shadow: 0 18px 32px rgba(2, 6, 13, 0.48); +} + +.channels-screen--list .channels-tab-btn { + color: #b8c7ea; + border-radius: 12px; + border: 1px solid transparent; + background: transparent; +} + +.channels-screen--list .channels-tab-btn.is-active { + background: + linear-gradient(145deg, rgba(220, 181, 94, 0.32), rgba(39, 66, 122, 0.3)), + rgba(20, 35, 64, 0.62); + border: 1px solid rgba(220, 183, 100, 0.44); + color: #f7e2ad; + box-shadow: inset 0 1px 0 rgba(255, 242, 204, 0.42); +} + +/* ===== SHiNE Web3 Glassmorphism polish ===== */ +.channels-screen { + color: rgba(255, 255, 255, 0.9); +} + +.channels-screen--list .channels-tabs, +.channels-screen--list .channel-row, +.channels-screen--list .channels-status, +.channels-screen--list .channels-empty-state, +.channels-screen--list .channels-bottom-action { + background: rgba(18, 24, 38, 0.4); + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.3); + border-radius: 20px; +} + +.channels-screen--list .channels-tabs, +.channels-screen--list .channel-row, +.channels-screen--list .channels-empty-state, +.channels-screen--list .channels-status, +.channels-screen--list .channels-bottom-action { + margin-left: 24px; + margin-right: 24px; +} + +.channels-screen--list .channel-row { + margin-top: 16px; + margin-bottom: 16px; +} + +.channels-screen--list .channels-tabs { + margin-top: 16px; + margin-bottom: 12px; +} + +.channels-screen--list .channels-tab-btn { + background: transparent; + border: 1px solid transparent; + color: rgba(255, 255, 255, 0.66); + text-shadow: none; +} + +.channels-screen--list .channels-tab-btn.is-active { + background: + linear-gradient(145deg, rgba(212, 175, 55, 0.22), rgba(68, 92, 171, 0.2)), + rgba(18, 24, 38, 0.44); + border: 1px solid rgba(212, 175, 55, 0.42); + color: #D4AF37; + text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); + box-shadow: inset 0 1px 0 rgba(255, 238, 197, 0.3); +} + +.toolbar { + background: rgba(18, 24, 38, 0.4); + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.3); + border-radius: 20px; +} + +.toolbar-btn.active { + background: transparent !important; + border-color: transparent !important; + box-shadow: none !important; + color: #D4AF37; +} + +.toolbar-btn.active span:first-child { + color: #D4AF37; + filter: drop-shadow(0 0 10px rgba(212, 175, 55, 0.6)); +} + +.toolbar-btn.active span:last-child { + color: #D4AF37; + text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); +} + +/* ===== Targeted UI touchups (requested) ===== */ +.channels-screen--list .channel-row .avatar, +.profile-screen .avatar.large { + position: relative; + border-radius: 50%; + border: 1px solid rgba(130, 198, 255, 0.62); + background: + radial-gradient(circle at 30% 28%, rgba(161, 204, 255, 0.95), rgba(89, 141, 220, 0.92) 46%, rgba(32, 71, 136, 0.95) 72%, rgba(20, 48, 99, 0.98) 100%); + color: #f3f8ff; + box-shadow: + inset 0 1px 0 rgba(240, 248, 255, 0.6), + inset 0 -10px 18px rgba(12, 33, 72, 0.5), + 0 0 0 2px rgba(72, 155, 248, 0.28), + 0 12px 24px rgba(8, 24, 55, 0.42); +} + +.channels-screen--list .channel-row .avatar::after, +.profile-screen .avatar.large::after { + content: ""; + position: absolute; + top: 14%; + left: 18%; + width: 42%; + height: 28%; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 72%); + pointer-events: none; +} + +.channels-screen--add .page-header .icon-btn { + width: 42px; + min-width: 42px; + height: 42px; + padding: 0; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + box-shadow: 0 10px 22px rgba(4, 10, 24, 0.34); +} + +.channels-screen--add .page-header .icon-btn:hover { + border-color: rgba(212, 175, 55, 0.44); + color: rgba(255, 215, 126, 0.95); + background: rgba(255, 180, 0, 0.1); + transform: none; +} + +.channels-screen--add #cancel-create-channel, +.channels-screen--add #submit-create-channel { + min-height: 50px; + border-radius: 14px; + font-weight: 700; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.channels-screen--add #cancel-create-channel { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.16); + color: rgba(255, 255, 255, 0.72); +} + +.channels-screen--add #submit-create-channel { + background: linear-gradient(135deg, rgba(212, 175, 55, 0.2), rgba(212, 175, 55, 0.05)); + border: 1px solid rgba(212, 175, 55, 0.42); + color: rgba(255, 213, 118, 0.95); +} + +.channels-screen--add #cancel-create-channel:hover, +.channels-screen--add #submit-create-channel:hover { + transform: none; + box-shadow: 0 0 22px rgba(212, 175, 55, 0.2), inset 0 0 8px rgba(212, 175, 55, 0.1); +} + +/* ===== Final microinteractions: breathe cards + static energy buttons ===== */ +@keyframes breatheCard { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-3px); } +} + +@keyframes glareSweep { + 0% { left: -150%; } + 100% { left: 150%; } +} + +@keyframes blurRevealMedium { + 0% { filter: blur(0px); opacity: 1; color: inherit; } + 40% { filter: blur(5px); opacity: 0; color: #D4AF37; } + 100% { filter: blur(0px); opacity: 1; color: #D4AF37; } +} + +/* 1) Minimal breathing only for content cards */ +.channels-screen .channel-message-card, +.channels-screen .thread-node-card, +.channels-screen .thread-block, +.channels-screen .thread-summary, +.channels-screen--list .channel-row { + animation: breatheCard 8s ease-in-out infinite; + position: relative; +} + +/* Keep card hover static so breathe is the only motion */ +.channels-screen .channel-message-card:hover, +.channels-screen .thread-node-card:hover, +.channels-screen .thread-block:hover, +.channels-screen .thread-summary:hover, +.channels-screen--list .channel-row:hover { + transform: none; +} + +/* 2) Static controls with energy + glass glare (no levitation) */ +.channels-screen--list .channels-tab-btn, +.channels-screen--list .channels-bottom-action, +.channels-screen .channel-main-action, +.channels-screen .channel-back-btn, +.channels-screen .channel-head-actions .secondary-btn, +.channels-screen .channel-action-item { + position: relative; + overflow: hidden; + transition: box-shadow 0.75s ease-out, color 0.28s ease, border-color 0.28s ease, background 0.28s ease; + animation: none !important; +} + +.channels-screen--list .channels-tab-btn:hover, +.channels-screen--list .channels-bottom-action:hover, +.channels-screen .channel-main-action:hover, +.channels-screen .channel-back-btn:hover, +.channels-screen .channel-head-actions .secondary-btn:hover, +.channels-screen .channel-action-item:hover { + box-shadow: 0 0 16px rgba(212, 175, 55, 0.22), inset 0 0 6px rgba(212, 175, 55, 0.07); + transform: none !important; +} + +.channels-screen--list .channels-tab-btn::after, +.channels-screen--list .channels-bottom-action::after, +.channels-screen .channel-main-action::after, +.channels-screen .channel-back-btn::after, +.channels-screen .channel-head-actions .secondary-btn::after, +.channels-screen .channel-action-item::after { + content: ""; + position: absolute; + top: 0; + left: -150%; + width: 50%; + height: 100%; + background: linear-gradient( + to right, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0) 100% + ); + transform: skewX(-25deg); + transition: none; + z-index: 1; + pointer-events: none; +} + +.channels-screen--list .channels-tab-btn:hover::after, +.channels-screen--list .channels-bottom-action:hover::after, +.channels-screen .channel-main-action:hover::after, +.channels-screen .channel-back-btn:hover::after, +.channels-screen .channel-head-actions .secondary-btn:hover::after, +.channels-screen .channel-action-item:hover::after { + animation: glareSweep 1.6s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* 3) Medium blur-reveal for stats/counters */ +.stat-blur-reveal { + animation: blurRevealMedium 1s cubic-bezier(0.4, 0, 0.2, 1) forwards; + display: inline-block; +} + +/* ===== Channels CTA proportions fix ===== */ +.channels-screen--list .channels-bottom-action { + display: block; + width: min(90%, 360px) !important; + max-width: 90%; + margin-left: auto !important; + margin-right: auto !important; + margin-top: 16px !important; + margin-bottom: 20px !important; + padding: 12px 24px !important; + box-sizing: border-box; + align-self: center; +} + +/* ===== Profile glass style (same visual family as DM) ===== */ +.profile-screen { + position: relative; + isolation: isolate; + background: #05070A; + color: rgba(255, 255, 255, 0.9); + min-height: 100%; +} + +.profile-screen::before { + content: ""; + position: absolute; + inset: -12px -12px 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(260px 260px at 86% 10%, rgba(147, 112, 219, 0.22), transparent 72%), + radial-gradient(220px 220px at 14% 84%, rgba(147, 112, 219, 0.18), transparent 72%), + radial-gradient(220px 220px at 76% 68%, rgba(212, 175, 55, 0.14), transparent 75%), + radial-gradient(190px 190px at 26% 20%, rgba(212, 175, 55, 0.1), transparent 74%), + #05070A; +} + +.profile-screen .card, +.profile-screen .profile-param-item, +.profile-screen .profile-relative-search-suggest { + background: rgba(20, 25, 35, 0.4); + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.35); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.37); +} + +.profile-screen .primary-btn { + background: rgba(212, 175, 55, 0.2); + border: 1px solid rgba(212, 175, 55, 0.45); + color: rgba(255, 217, 128, 0.98); +} + +.profile-screen .secondary-btn { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.75); +} + +/* ===== Notifications glass style ===== */ +.notifications-screen { + position: relative; + isolation: isolate; + background: #05070A; + color: rgba(255, 255, 255, 0.9); + min-height: 100%; + align-content: start; +} + +.notifications-screen::before { + content: ""; + position: absolute; + inset: -12px -12px 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(320px 320px at 86% 12%, rgba(147, 112, 219, 0.2), transparent 72%), + radial-gradient(280px 280px at 18% 82%, rgba(147, 112, 219, 0.14), transparent 72%), + radial-gradient(260px 260px at 70% 65%, rgba(212, 175, 55, 0.12), transparent 75%), + #05070A; +} + +.notifications-screen .tabs { + background: rgba(20, 25, 35, 0.5); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid rgba(212, 175, 55, 0.26); + border-radius: 16px; +} + +.notifications-screen .tab-btn { + color: rgba(255, 255, 255, 0.65); +} + +.notifications-screen .tab-btn.active { + background: rgba(255, 180, 0, 0.12); + border: 1px solid rgba(255, 180, 0, 0.28); + color: rgba(255, 200, 50, 0.92); +} + +.notifications-screen .notifications-list > .card { + background: rgba(20, 25, 35, 0.55); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + box-shadow: 0 0 60px rgba(80, 60, 180, 0.12); +} + +/* Cosmic styling for the "Связи" toolbar button */ +.toolbar-btn-network { + position: relative; + overflow: hidden; +} + +.toolbar-btn-network::before { + content: ""; + position: absolute; + inset: 6px; + border-radius: 10px; + pointer-events: none; + opacity: 0.42; + background: + radial-gradient(circle at 24% 24%, rgba(112, 170, 255, 0.35), transparent 56%), + radial-gradient(circle at 78% 72%, rgba(197, 132, 255, 0.24), transparent 60%); + filter: blur(8px); +} + +.toolbar-btn-network span:first-child { + color: #9eb3e8; + filter: drop-shadow(0 0 5px rgba(123, 170, 255, 0.24)); +} + +.toolbar-btn-network.active { + color: #a7bcf2; + background: linear-gradient(150deg, rgba(52, 120, 235, 0.16), rgba(103, 67, 198, 0.16)) !important; + border: 1px solid rgba(95, 151, 255, 0.46) !important; + box-shadow: + 0 0 0 1px rgba(125, 180, 255, 0.24), + 0 0 18px rgba(88, 130, 255, 0.28), + 0 0 26px rgba(121, 75, 229, 0.22) !important; +} + +.toolbar-btn-network.active span:first-child { + color: #c8e3ff; + filter: drop-shadow(0 0 8px rgba(123, 183, 255, 0.65)); +} + +.toolbar-btn-network.active span:last-child { + color: #c6dcff; + text-shadow: 0 0 10px rgba(123, 183, 255, 0.45); +} + +/* ===== Empty states alignment + transparent wrappers cleanup ===== */ +.dm-screen .dm-list > .card.meta-muted, +.channels-empty-state--compact .meta-muted, +.channels-list-empty { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + width: 100%; +} + +.channels-screen, +.channels-list-content, +.channels-scroll-wrap { + background: transparent !important; +} + +.channels-screen::before, +.channels-screen.channels-screen--channel::before { + background: transparent !important; +} + +.dm-screen .dm-list > .card.meta-muted { + width: calc(100% - 40px); +} + +.channels-screen--list .channels-bottom-action { + background: linear-gradient(135deg, rgba(212, 175, 55, 0.2), rgba(212, 175, 55, 0.05)); + border: 1px solid rgba(212, 175, 55, 0.3); + color: #D4AF37; + font-weight: 700; +} + +/* ===== SHiNE Glassmorphism activation (channels list) ===== */ +.channels-screen--list .channels-tabs, +.channels-screen--list .channel-row, +.channels-screen--list .channels-status, +.channels-screen--list .channels-empty-state { + background: rgba(18, 24, 38, 0.4) !important; + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.3) !important; + border-radius: 20px; +} + +.channels-screen--list .channels-tabs, +.channels-screen--list .channel-row, +.channels-screen--list .channels-status, +.channels-screen--list .channels-empty-state, +.channels-screen--list .channels-bottom-action { + margin: 16px 20px; +} + +.channels-screen--list .channels-bottom-action { + background: linear-gradient(135deg, rgba(212, 175, 55, 0.2), rgba(212, 175, 55, 0.05)); + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.3); + border-radius: 20px; + color: #D4AF37; +} + +.channels-empty-state--silent { + padding: 0 !important; + min-height: 0; +} + +.channels-screen--list .channels-bottom-action.is-empty-lift { + margin-top: 6px !important; +} + +.toolbar { + background: rgba(18, 24, 38, 0.4); + backdrop-filter: blur(25px); + -webkit-backdrop-filter: blur(25px); + border: 1px solid rgba(212, 175, 55, 0.3); + border-radius: 20px; +} + +.toolbar-btn.active { + background: transparent !important; + border-color: transparent !important; + box-shadow: none !important; + color: #D4AF37; +} + +.toolbar-btn.active span:first-child { + color: #D4AF37; + filter: drop-shadow(0 0 10px rgba(212, 175, 55, 0.6)); +} + +.toolbar-btn.active span:last-child { + color: #D4AF37; + text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); +} diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css index c8d4b04..0cbbe63 100644 --- a/shine-UI/styles/layout.css +++ b/shine-UI/styles/layout.css @@ -1,21 +1,42 @@ body { display: flex; justify-content: center; + background: #05070A; + min-height: 100vh; + position: relative; +} + +body::before { + content: ''; + position: fixed; + top: 50%; + left: 50%; + width: 100vw; + height: 100vh; + background: radial-gradient(circle at center, rgba(88, 50, 168, 0.15) 0%, rgba(212, 175, 55, 0.1) 40%, transparent 70%); + transform: translate(-50%, -50%); + z-index: -1; + filter: blur(100px); + pointer-events: none; } .app-shell { width: min(100vw, 430px); height: 100dvh; position: relative; - background: - radial-gradient(circle at 16% -8%, rgba(211, 168, 76, 0.16), transparent 38%), - linear-gradient(165deg, rgba(10, 21, 44, 0.98), rgba(5, 11, 24, 0.99)); + background: transparent; border-left: 1px solid rgba(211, 170, 86, 0.2); border-right: 1px solid rgba(211, 170, 86, 0.2); box-shadow: var(--shadow); overflow: hidden; } +.screen-content, +.toolbar-slot, +.connection-retry-banner { + z-index: 1; +} + .screen-content { position: absolute; top: 0; diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index 8fe1e0b..763a8c9 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -346,6 +346,34 @@ public final class DatabaseInitializer { ON message_stats (to_login); """); + // 8.0) message_views_state (уникальный просмотр/прочтение сообщения пользователем) + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS message_views_state ( + viewer_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BLOB NOT NULL, + first_seen_at_ms INTEGER NOT NULL, + + UNIQUE ( + viewer_login, + to_bch_name, + to_block_number, + to_block_hash + ) + ); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_message_views_state_target + ON message_views_state (to_bch_name, to_block_number, to_block_hash); + """); + + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel + ON message_views_state (viewer_login, to_bch_name); + """); + // 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target) st.executeUpdate(""" CREATE TABLE IF NOT EXISTS reactions_state ( diff --git a/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 00e42c2..5cd9c40 100644 --- a/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -79,6 +79,7 @@ public final class SqliteDbController { st.execute("PRAGMA foreign_keys = OFF"); ensureReactionsStateTable(st); + ensureMessageViewsStateTable(st); if (!tableExists(c, "connections_state")) { createConnectionsStateTable(st); @@ -89,6 +90,7 @@ public final class SqliteDbController { ensureChannelNamesDescriptionColumn(c, st); ensureConnectionsIndexes(st); ensureReactionsIndexes(st); + ensureMessageViewsIndexes(st); ensureChannelNamesIndexes(st); ensureSignedMessageReceiptUniq(c, st); @@ -131,6 +133,24 @@ public final class SqliteDbController { """); } + private static void ensureMessageViewsStateTable(Statement st) throws SQLException { + st.executeUpdate(""" + CREATE TABLE IF NOT EXISTS message_views_state ( + viewer_login TEXT NOT NULL, + to_bch_name TEXT NOT NULL, + to_block_number INTEGER NOT NULL, + to_block_hash BLOB NOT NULL, + first_seen_at_ms INTEGER NOT NULL, + UNIQUE ( + viewer_login, + to_bch_name, + to_block_number, + to_block_hash + ) + ); + """); + } + private static void createConnectionsStateTable(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS connections_state ( @@ -176,6 +196,17 @@ public final class SqliteDbController { """); } + private static void ensureMessageViewsIndexes(Statement st) throws SQLException { + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_message_views_state_target + ON message_views_state (to_bch_name, to_block_number, to_block_hash); + """); + st.executeUpdate(""" + CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel + ON message_views_state (viewer_login, to_bch_name); + """); + } + private static void ensureChannelNamesStateTable(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS channel_names_state ( diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index b22f920..e65138c 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -49,9 +49,11 @@ import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstra import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_MarkChannelMessagesSeen_Handler; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Request; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler; import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler; @@ -129,6 +131,7 @@ public final class JsonHandlerRegistry { Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()), Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), + Map.entry("MarkChannelMessagesSeen", new Net_MarkChannelMessagesSeen_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()), @@ -183,6 +186,7 @@ public final class JsonHandlerRegistry { Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class), Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), + Map.entry("MarkChannelMessagesSeen", Net_MarkChannelMessagesSeen_Request.class), Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 6b996c0..65f9a6a 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -212,6 +212,111 @@ final class ChannelsReadSupport { } } + static int countViews(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException { + String sql = """ + SELECT COUNT(*) AS cnt + FROM message_views_state + WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=? + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, bch); + ps.setInt(2, blockNumber); + ps.setBytes(3, blockHash); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt("cnt") : 0; + } + } + } + + static boolean isSeenByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException { + if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) { + return false; + } + String sql = """ + SELECT 1 + FROM message_views_state + WHERE viewer_login = ? COLLATE NOCASE + AND to_bch_name = ? + AND to_block_number = ? + AND to_block_hash = ? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + ps.setString(2, toBch); + ps.setInt(3, toBlockNumber); + ps.setBytes(4, toBlockHash); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + static int countUnreadPosts(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException { + if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return 0; + String sql = """ + SELECT COUNT(*) AS cnt + FROM blocks b + LEFT JOIN message_views_state v + ON v.viewer_login = ? + AND v.to_bch_name = b.bch_name + AND v.to_block_number = b.block_number + AND v.to_block_hash = b.block_hash + WHERE b.bch_name = ? + AND b.msg_type = ? + AND b.msg_sub_type = ? + AND b.line_code = ? + AND v.viewer_login IS NULL + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerBch); + ps.setInt(3, MSG_TYPE_TEXT); + ps.setInt(4, MsgSubType.TEXT_POST); + ps.setInt(5, lineCode); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt("cnt") : 0; + } + } + } + + static PostBlock firstUnreadPost(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException { + if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return null; + String sql = """ + SELECT b.login,b.bch_name,b.block_number,b.block_hash,b.block_bytes + FROM blocks b + LEFT JOIN message_views_state v + ON v.viewer_login = ? + AND v.to_bch_name = b.bch_name + AND v.to_block_number = b.block_number + AND v.to_block_hash = b.block_hash + WHERE b.bch_name = ? + AND b.msg_type = ? + AND b.msg_sub_type = ? + AND b.line_code = ? + AND v.viewer_login IS NULL + ORDER BY b.block_number ASC + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerBch); + ps.setInt(3, MSG_TYPE_TEXT); + ps.setInt(4, MsgSubType.TEXT_POST); + ps.setInt(5, lineCode); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + PostBlock pb = new PostBlock(); + pb.login = rs.getString("login"); + pb.bchName = rs.getString("bch_name"); + pb.blockNumber = rs.getInt("block_number"); + pb.blockHash = rs.getBytes("block_hash"); + pb.blockBytes = rs.getBytes("block_bytes"); + return pb; + } + } + } + static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException { if (rootNumber == 0) return ""; diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 64d5e3d..5e9a4ce 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -108,11 +108,22 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { item.setLikesCount(stats[0]); item.setRepliesCount(stats[1]); item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash)); + item.setViewCount(ChannelsReadSupport.countViews(c, post.bchName, post.blockNumber, post.blockHash)); + item.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash)); items.add(item); } resp.setMessages(items); + int unreadCount = ChannelsReadSupport.countUnreadPosts(c, viewerLogin, ownerBch, lineCode); + resp.setUnreadCount(unreadCount); + ChannelsReadSupport.PostBlock firstUnread = ChannelsReadSupport.firstUnreadPost(c, viewerLogin, ownerBch, lineCode); + if (firstUnread != null) { + Net_GetChannelMessages_Response.BlockRef firstUnreadRef = new Net_GetChannelMessages_Response.BlockRef(); + firstUnreadRef.setBlockNumber(firstUnread.blockNumber); + firstUnreadRef.setBlockHash(ChannelsReadSupport.toHex(firstUnread.blockHash)); + resp.setFirstUnreadMessageRef(firstUnreadRef); + } return resp; } catch (Exception e) { log.error("GetChannelMessages failed", e); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java index 9b18a3e..9fa2148 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageThread_Handler.java @@ -178,6 +178,8 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler { node.setLikesCount(stats[0]); node.setRepliesCount(stats[1]); node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash)); + node.setViewCount(ChannelsReadSupport.countViews(c, row.bchName, row.blockNumber, row.blockHash)); + node.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash)); if (row.lineCode != null && row.lineCode >= 0) { Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo(); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index 2dc43ea..c6e072f 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -45,9 +45,9 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { List followedUsersChannels = loadFollowedChannels(c, canonicalLogin, true); List followedChannels = loadFollowedChannels(c, canonicalLogin, false); - resp.setOwnedChannels(buildSummaries(c, own)); - resp.setFollowedUsersChannels(buildSummaries(c, followedUsersChannels)); - resp.setFollowedChannels(buildSummaries(c, followedChannels)); + resp.setOwnedChannels(buildSummaries(c, canonicalLogin, own)); + resp.setFollowedUsersChannels(buildSummaries(c, canonicalLogin, followedUsersChannels)); + resp.setFollowedChannels(buildSummaries(c, canonicalLogin, followedChannels)); return resp; } catch (Exception e) { @@ -56,7 +56,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { } } - private List buildSummaries(Connection c, List keys) throws Exception { + private List buildSummaries(Connection c, String viewerLogin, List keys) throws Exception { List out = new ArrayList<>(); for (ChannelKey key : keys) { Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary(); @@ -74,6 +74,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { row.setChannel(channelRef); row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber)); + row.setUnreadCount(ChannelsReadSupport.countUnreadPosts(c, viewerLogin, key.ownerBch, key.rootNumber)); ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber); if (lastPost != null) { diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java new file mode 100644 index 0000000..3d96ee8 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_MarkChannelMessagesSeen_Handler.java @@ -0,0 +1,135 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.MsgSubType; +import shine.db.SqliteDbController; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_MarkChannelMessagesSeen_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_MarkChannelMessagesSeen_Request req = (Net_MarkChannelMessagesSeen_Request) baseRequest; + List refs = req.getMessages(); + if (refs == null || refs.isEmpty()) { + Net_MarkChannelMessagesSeen_Response ok = new Net_MarkChannelMessagesSeen_Response(); + ok.setOp(req.getOp()); + ok.setRequestId(req.getRequestId()); + ok.setStatus(WireCodes.Status.OK); + ok.setSeenAccepted(0); + ok.setInserted(0); + return ok; + } + + try (Connection c = SqliteDbController.getInstance().getConnection()) { + String viewerLogin = ctx != null ? ctx.getLogin() : null; + if (viewerLogin == null || viewerLogin.isBlank()) { + viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin()); + } + if (viewerLogin == null || viewerLogin.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Invalid login"); + } + + Integer expectedRoot = req.getChannel() != null ? req.getChannel().getChannelRootBlockNumber() : null; + String expectedOwnerBch = req.getChannel() != null ? req.getChannel().getOwnerBlockchainName() : null; + boolean strictChannelMatch = expectedRoot != null && expectedRoot >= 0 + && expectedOwnerBch != null && !expectedOwnerBch.isBlank(); + + String existsSql = """ + SELECT 1 + FROM blocks + WHERE bch_name = ? + AND block_number = ? + AND block_hash = ? + AND msg_type = ? + AND msg_sub_type = ? + %s + LIMIT 1 + """.formatted(strictChannelMatch ? "AND line_code = ?" : ""); + + String insertSql = """ + INSERT OR IGNORE INTO message_views_state ( + viewer_login, to_bch_name, to_block_number, to_block_hash, first_seen_at_ms + ) VALUES (?, ?, ?, ?, ?) + """; + + int seenAccepted = 0; + int inserted = 0; + long nowMs = System.currentTimeMillis(); + Set dedup = new HashSet<>(); + + try (PreparedStatement existsPs = c.prepareStatement(existsSql); + PreparedStatement insertPs = c.prepareStatement(insertSql)) { + + for (Net_MarkChannelMessagesSeen_Request.MessageRef ref : refs) { + if (ref == null) continue; + String bch = String.valueOf(ref.getBlockchainName() == null ? "" : ref.getBlockchainName()).trim(); + Integer no = ref.getBlockNumber(); + String hashHex = String.valueOf(ref.getBlockHash() == null ? "" : ref.getBlockHash()).trim().toLowerCase(); + + if (bch.isBlank() || no == null || no < 0) continue; + if (!hashHex.matches("^[0-9a-f]{64}$")) continue; + + if (strictChannelMatch && !bch.equals(expectedOwnerBch)) continue; + + String key = bch + "|" + no + "|" + hashHex; + if (!dedup.add(key)) continue; + + existsPs.clearParameters(); + existsPs.setString(1, bch); + existsPs.setInt(2, no); + existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex)); + existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT); + existsPs.setInt(5, MsgSubType.TEXT_POST); + if (strictChannelMatch) { + existsPs.setInt(6, expectedRoot); + } + + boolean exists; + try (ResultSet rs = existsPs.executeQuery()) { + exists = rs.next(); + } + if (!exists) continue; + + seenAccepted += 1; + + insertPs.clearParameters(); + insertPs.setString(1, viewerLogin); + insertPs.setString(2, bch); + insertPs.setInt(3, no); + insertPs.setBytes(4, ChannelsReadSupport.hexToBytes(hashHex)); + insertPs.setLong(5, nowMs); + inserted += insertPs.executeUpdate(); + } + } + + Net_MarkChannelMessagesSeen_Response ok = new Net_MarkChannelMessagesSeen_Response(); + ok.setOp(req.getOp()); + ok.setRequestId(req.getRequestId()); + ok.setStatus(WireCodes.Status.OK); + ok.setSeenAccepted(seenAccepted); + ok.setInserted(inserted); + return ok; + } catch (Exception e) { + log.error("MarkChannelMessagesSeen failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Internal server error"); + } + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index 49dabfb..60c6f24 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -8,6 +8,8 @@ import java.util.List; public class Net_GetChannelMessages_Response extends Net_Response { private Channel channel; private List messages = new ArrayList<>(); + private int unreadCount; + private BlockRef firstUnreadMessageRef; public Channel getChannel() { return channel; } public void setChannel(Channel channel) { this.channel = channel; } @@ -15,6 +17,12 @@ public class Net_GetChannelMessages_Response extends Net_Response { public List getMessages() { return messages; } public void setMessages(List messages) { this.messages = messages; } + public int getUnreadCount() { return unreadCount; } + public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; } + + public BlockRef getFirstUnreadMessageRef() { return firstUnreadMessageRef; } + public void setFirstUnreadMessageRef(BlockRef firstUnreadMessageRef) { this.firstUnreadMessageRef = firstUnreadMessageRef; } + public static class Channel { private String ownerLogin; private String ownerBlockchainName; @@ -47,6 +55,8 @@ public class Net_GetChannelMessages_Response extends Net_Response { private int likesCount; private boolean likedByMe; private int repliesCount; + private int viewCount; + private boolean seenByMe; private int versionsTotal; private List versions = new ArrayList<>(); @@ -74,6 +84,12 @@ public class Net_GetChannelMessages_Response extends Net_Response { public int getRepliesCount() { return repliesCount; } public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; } + public int getViewCount() { return viewCount; } + public void setViewCount(int viewCount) { this.viewCount = viewCount; } + + public boolean isSeenByMe() { return seenByMe; } + public void setSeenByMe(boolean seenByMe) { this.seenByMe = seenByMe; } + public int getVersionsTotal() { return versionsTotal; } public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java index 247b4de..62b851b 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java @@ -26,6 +26,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public static class ChannelSummary { private ChannelRef channel; private int messagesCount; + private int unreadCount; private LastMessage lastMessage; public ChannelRef getChannel() { return channel; } @@ -34,6 +35,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public int getMessagesCount() { return messagesCount; } public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; } + public int getUnreadCount() { return unreadCount; } + public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; } + public LastMessage getLastMessage() { return lastMessage; } public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; } } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_MarkChannelMessagesSeen_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_MarkChannelMessagesSeen_Request.java new file mode 100644 index 0000000..29cf3ad --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_MarkChannelMessagesSeen_Request.java @@ -0,0 +1,51 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +import java.util.ArrayList; +import java.util.List; + +public class Net_MarkChannelMessagesSeen_Request extends Net_Request { + private String login; + private ChannelSelector channel; + private List messages = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public ChannelSelector getChannel() { return channel; } + public void setChannel(ChannelSelector channel) { this.channel = channel; } + + public List getMessages() { return messages; } + public void setMessages(List messages) { this.messages = messages; } + + public static class ChannelSelector { + private String ownerBlockchainName; + private Integer channelRootBlockNumber; + private String channelRootBlockHash; + + public String getOwnerBlockchainName() { return ownerBlockchainName; } + public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; } + + public Integer getChannelRootBlockNumber() { return channelRootBlockNumber; } + public void setChannelRootBlockNumber(Integer channelRootBlockNumber) { this.channelRootBlockNumber = channelRootBlockNumber; } + + public String getChannelRootBlockHash() { return channelRootBlockHash; } + public void setChannelRootBlockHash(String channelRootBlockHash) { this.channelRootBlockHash = channelRootBlockHash; } + } + + public static class MessageRef { + private String blockchainName; + private Integer blockNumber; + private String blockHash; + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public Integer getBlockNumber() { return blockNumber; } + public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; } + + public String getBlockHash() { return blockHash; } + public void setBlockHash(String blockHash) { this.blockHash = blockHash; } + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_MarkChannelMessagesSeen_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_MarkChannelMessagesSeen_Response.java new file mode 100644 index 0000000..8121f96 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_MarkChannelMessagesSeen_Response.java @@ -0,0 +1,14 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_MarkChannelMessagesSeen_Response extends Net_Response { + private int seenAccepted; + private int inserted; + + public int getSeenAccepted() { return seenAccepted; } + public void setSeenAccepted(int seenAccepted) { this.seenAccepted = seenAccepted; } + + public int getInserted() { return inserted; } + public void setInserted(int inserted) { this.inserted = inserted; } +}