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 = `
@@ -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; }
+}