diff --git a/VERSION.properties b/VERSION.properties
index 370bc3c..a33be7a 100644
--- a/VERSION.properties
+++ b/VERSION.properties
@@ -1,2 +1,2 @@
-client.version=1.2.40
-server.version=1.2.34
+client.version=1.2.42
+server.version=1.2.36
diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js
index 70f2ce7..a9d99ef 100644
--- a/shine-UI/js/pages/channel-thread-view.js
+++ b/shine-UI/js/pages/channel-thread-view.js
@@ -98,6 +98,24 @@ function buildAbsoluteRouteUrl(routePath = '') {
function parseThreadSelector(route) {
const params = route?.params || {};
+ if (params.ownerLogin && params.channelName && params.messageBlockNumber && params.messageBlockHash) {
+ return {
+ short: {
+ ownerLogin: String(params.ownerLogin || '').trim(),
+ channelName: String(params.channelName || '').trim(),
+ },
+ message: {
+ blockchainName: '',
+ blockNumber: toSafeInt(params.messageBlockNumber),
+ blockHash: normalizeRouteHash(params.messageBlockHash),
+ },
+ channel: {
+ ownerBlockchainName: '',
+ rootBlockNumber: null,
+ rootBlockHash: '0',
+ },
+ };
+ }
const blockNumber = toSafeInt(params.messageBlockNumber);
if (!params.messageBlockchainName || blockNumber == null) return null;
@@ -153,7 +171,17 @@ function buildBackRoute(selector) {
}
function buildThreadRouteFromTarget(target, selector) {
- if (!target || !selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
+ if (!target) return '';
+ if (selector?.short?.ownerLogin && selector?.short?.channelName) {
+ return [
+ 'channel',
+ encodeRoutePart(selector.short.ownerLogin),
+ encodeRoutePart(selector.short.channelName),
+ target.blockNumber,
+ normalizeRouteHash(target.blockHash),
+ ].join('/');
+ }
+ if (!selector?.channel?.ownerBlockchainName || selector.channel.rootBlockNumber == null) return '';
return [
'channel-thread-view',
encodeRoutePart(target.blockchainName),
@@ -293,13 +321,6 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
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;
@@ -337,15 +358,19 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
likeButton.type = 'button';
likeButton.className = 'secondary-btn thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked');
- likeButton.textContent = isPending ? '✦ Сияние...' : '✦ Сияние';
+ likeButton.textContent = isPending ? '❤️ Лайк...' : (isLiked ? '❤️ Лайк' : '🤍 Лайк');
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget);
if (isPending) return;
+ if (!isLiked) {
+ const ok = window.confirm('Поставить лайк?');
+ if (!ok) return;
+ }
revealCounters();
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
- likeButton.textContent = 'Сияние...';
+ likeButton.textContent = 'Лайк...';
try {
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
} catch (error) {
@@ -361,7 +386,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
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();
@@ -373,7 +398,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'secondary-btn thread-share-btn';
- shareButton.textContent = '↗ Транслировать';
+ shareButton.textContent = '↗ Отправить';
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
animatePress(event.currentTarget);
@@ -454,10 +479,6 @@ export function render({ navigate, route }) {
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
- const userIndicator = document.createElement('div');
- userIndicator.className = 'card channels-user-chip';
- userIndicator.textContent = `Вы вошли как @${state.session.login || 'неизвестно'}`;
-
const channelIndicator = document.createElement('div');
channelIndicator.className = 'card channels-user-chip';
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
@@ -490,7 +511,11 @@ export function render({ navigate, route }) {
const requireSigningSession = () => {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
- if (!login || !storagePwd) throw new Error('Сессия недействительна. Выполните вход заново.');
+ if (!login || !storagePwd) {
+ state.authReturnHash = window.location.hash || '#/channels-list';
+ navigate('login-view');
+ throw new Error('Для этого действия нужно войти');
+ }
return { login, storagePwd };
};
@@ -562,7 +587,7 @@ export function render({ navigate, route }) {
leftAction: { label: '<', onClick: () => navigate(backRoute) },
}),
);
- screen.append(userIndicator, channelIndicator, statusBox);
+ screen.append(channelIndicator, statusBox);
if (!selector) {
const invalid = document.createElement('div');
@@ -576,7 +601,25 @@ export function render({ navigate, route }) {
(async () => {
try {
- const payload = await authService.getMessageThread(selector.message, 20, 2, 50, state.session.login);
+ let resolvedMessage = selector.message;
+ if (selector.short?.ownerLogin && selector.short?.channelName) {
+ const ownerFeed = await authService.listSubscriptionsFeed(selector.short.ownerLogin, 1000);
+ const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
+ const channel = ownChannels.find((item) => (
+ String(item?.channel?.channelName || '').trim().toLowerCase() === selector.short.channelName.toLowerCase()
+ ));
+ const ownerBch = String(channel?.channel?.ownerBlockchainName || '').trim();
+ if (!ownerBch || !Number.isFinite(resolvedMessage?.blockNumber)) {
+ throw new Error('Канал или сообщение не найдено.');
+ }
+ resolvedMessage = {
+ blockchainName: ownerBch,
+ blockNumber: resolvedMessage.blockNumber,
+ blockHash: normalizeRouteHash(resolvedMessage.blockHash),
+ };
+ }
+
+ const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
skeleton.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
@@ -605,7 +648,7 @@ export function render({ navigate, route }) {
if (focus) {
const focusWrap = document.createElement('div');
focusWrap.className = 'stack thread-block thread-block--focus';
- focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: true }));
+ focusWrap.append(renderNodeCard(focus, '', handlers, nextNumber(), routeKey, { showViews: false }));
screen.append(focusWrap);
}
diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js
index abddf46..df91883 100644
--- a/shine-UI/js/pages/channel-view.js
+++ b/shine-UI/js/pages/channel-view.js
@@ -21,9 +21,6 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
const pendingReactionActions = new Set();
const pendingScrollByRoute = new Map();
const revealedCountersByRoute = new Map();
-const seenFlushTimersByRoute = new Map();
-const seenPendingByRoute = new Map();
-const firstUnreadJumpByRoute = new Map();
function isChannelsDemoMode() {
try {
@@ -164,6 +161,13 @@ function parseDescriptionOverride(payload) {
function buildSelectorFromRoute(route, channelId) {
const params = route?.params || {};
+ if (params.ownerLogin && params.channelName) {
+ return {
+ ownerLogin: String(params.ownerLogin || '').trim(),
+ channelName: String(params.channelName || '').trim(),
+ };
+ }
+
if (params.ownerBlockchainName) {
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
if (rootBlockNumber != null) {
@@ -186,6 +190,17 @@ function buildSelectorFromRoute(route, channelId) {
function buildThreadRoute(messageRef, selector) {
if (!messageRef || !selector) return '';
+ const ownerLogin = String(selector.ownerLogin || '').trim();
+ const channelName = String(selector.channelName || '').trim();
+ if (ownerLogin && channelName) {
+ return [
+ 'channel',
+ encodeRoutePart(ownerLogin),
+ encodeRoutePart(channelName),
+ messageRef.blockNumber,
+ normalizeRouteHash(messageRef.blockHash),
+ ].join('/');
+ }
return [
'channel-thread-view',
encodeRoutePart(messageRef.blockchainName),
@@ -502,8 +517,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
body: resolvedText || '(пусто)',
likesCount: Number(message?.likesCount || 0),
repliesCount: Number(message?.repliesCount || 0),
- viewCount: Number(message?.viewCount || 0),
- seenByMe: message?.seenByMe === true,
timestampMs: resolveMessageTimestampMs(message),
messageRef,
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
@@ -511,7 +524,25 @@ function mapApiMessageToPost(message, selector, localNumber) {
}
async function loadFromApi(route, channelId) {
- const selector = buildSelectorFromRoute(route, channelId);
+ let selector = buildSelectorFromRoute(route, channelId);
+ if (selector?.ownerLogin && selector?.channelName) {
+ const ownerFeed = await authService.listSubscriptionsFeed(selector.ownerLogin, 1000);
+ const ownChannels = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
+ const channel = ownChannels.find((item) => (
+ String(item?.channel?.channelName || '').trim().toLowerCase() === selector.channelName.toLowerCase()
+ ));
+ if (!channel?.channel?.ownerBlockchainName || channel?.channel?.channelRoot?.blockNumber == null) {
+ throw new Error('Канал не найден.');
+ }
+ selector = {
+ ownerBlockchainName: String(channel.channel.ownerBlockchainName),
+ channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
+ channelRootBlockHash: normalizeRouteHash(channel.channel.channelRoot.blockHash),
+ ownerLogin: selector.ownerLogin,
+ channelName: selector.channelName,
+ };
+ }
+
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
throw new Error('Не удалось определить канал из адреса страницы.');
}
@@ -520,8 +551,6 @@ async function loadFromApi(route, channelId) {
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
- const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName);
- const unreadFromPayload = Number(payload.unreadCount || 0);
const readDescription = async () => {
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
@@ -547,10 +576,6 @@ async function loadFromApi(route, channelId) {
ownerName: ownerLogin || 'неизвестно',
},
posts,
- unreadCount: Number.isFinite(unreadFromPayload)
- ? Math.max(0, unreadFromPayload)
- : posts.filter((post) => post.seenByMe !== true).length,
- firstUnreadKey,
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
selector,
};
@@ -665,11 +690,7 @@ function renderPostCard(post, {
body.className = 'channel-message-body';
body.textContent = post.body;
- const views = document.createElement('p');
- views.className = 'channel-message-views';
- views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
-
- card.append(topRow, body, views);
+ card.append(topRow, body);
const refKey = messageRefKey(post.messageRef);
if (refKey) {
@@ -703,19 +724,23 @@ function renderPostCard(post, {
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
- ✦
- ${isPending ? 'Сияние...' : 'Сияние'}
+ ${isLiked ? '❤️' : '🤍'}
+ ${isPending ? 'Лайк...' : 'Лайк'}
${post.likesCount || 0}
`;
likeButton.disabled = isPending;
likeButton.addEventListener('click', async (event) => {
animatePress(event.currentTarget);
if (isPending) return;
+ if (!isLiked) {
+ const ok = window.confirm('Поставить лайк?');
+ if (!ok) return;
+ }
revealCounters();
await longPressFeel(event.currentTarget, 130);
likeButton.disabled = true;
const labelEl = likeButton.querySelector('.channel-action-label');
- if (labelEl) labelEl.textContent = 'Сияние...';
+ if (labelEl) labelEl.textContent = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
@@ -724,7 +749,7 @@ function renderPostCard(post, {
replyButton.className = 'channel-action-item channel-action-reply';
replyButton.innerHTML = `
⟳
- Отразить
+ Ответить
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
@@ -754,7 +779,7 @@ function renderPostCard(post, {
shareButton.className = 'channel-action-item channel-action-share';
shareButton.innerHTML = `
↗
- Транслировать
+ Отправить
`;
shareButton.addEventListener('click', async (event) => {
event.stopPropagation();
@@ -816,25 +841,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
actionButton.className = channelData.isOwnChannel
? 'primary-btn channel-main-action'
: 'destructive-btn channel-main-action';
- actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
+ actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
const feed = document.createElement('div');
feed.className = 'stack channel-feed';
- const unreadDivider = document.createElement('div');
- unreadDivider.className = 'channels-unread-divider';
- unreadDivider.textContent = 'Непрочитанные сообщения';
- const unreadJump = document.createElement('button');
- unreadJump.type = 'button';
- unreadJump.className = 'channels-unread-jump';
- unreadJump.innerHTML = `
- ↓
-
- `;
- const unreadBadge = unreadJump.querySelector('.channels-unread-jump-badge');
const postsByKey = new Map();
- const unreadKeys = new Set();
- let seenFlushInFlight = false;
- let seenObserver = null;
if (channelData.posts.length) {
channelData.posts.forEach((post) => {
@@ -849,7 +860,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
const key = messageRefKey(post.messageRef);
if (key) {
postsByKey.set(key, post);
- if (post.seenByMe !== true) unreadKeys.add(key);
}
feed.append(row);
});
@@ -860,101 +870,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
feed.append(empty);
}
- const syncUnreadState = () => {
- unreadKeys.clear();
- postsByKey.forEach((post, key) => {
- if (post.seenByMe !== true) unreadKeys.add(key);
- });
- };
-
- const updateUnreadJump = () => {
- const unreadCount = unreadKeys.size;
- unreadJump.classList.toggle('is-visible', unreadCount > 0);
- unreadJump.hidden = unreadCount <= 0;
- if (unreadBadge) unreadBadge.textContent = unreadCount > 0 ? String(unreadCount) : '';
- };
-
- const mountUnreadDivider = () => {
- unreadDivider.remove();
- if (!unreadKeys.size) return;
- const firstUnread = channelData.posts.find((post) => {
- const key = messageRefKey(post.messageRef);
- return key && unreadKeys.has(key);
- });
- const firstUnreadKey = messageRefKey(firstUnread?.messageRef);
- if (!firstUnreadKey) return;
- const target = feed.querySelector(`[data-message-key="${firstUnreadKey}"]`);
- if (target) {
- feed.insertBefore(unreadDivider, target);
- }
- };
-
- const routePending = (() => {
- let bucket = seenPendingByRoute.get(routeKey);
- if (!bucket) {
- bucket = new Set();
- seenPendingByRoute.set(routeKey, bucket);
- }
- return bucket;
- })();
-
- const scheduleSeenFlush = () => {
- const oldTimer = seenFlushTimersByRoute.get(routeKey);
- if (oldTimer) clearTimeout(oldTimer);
- const timer = setTimeout(async () => {
- seenFlushTimersByRoute.delete(routeKey);
- if (seenFlushInFlight) return;
-
- const pendingKeys = [...routePending].filter((key) => {
- const post = postsByKey.get(key);
- return !!post && post.seenByMe !== true;
- });
- if (!pendingKeys.length) return;
-
- const refs = pendingKeys
- .map((key) => parseMessageRefKey(key))
- .filter(Boolean);
- if (!refs.length) return;
-
- pendingKeys.forEach((key) => routePending.delete(key));
- seenFlushInFlight = true;
- try {
- await handlers.onMarkSeenBatch(refs);
- refs.forEach((ref) => {
- const key = messageRefKey(ref);
- const post = key ? postsByKey.get(key) : null;
- if (post) post.seenByMe = true;
- });
- syncUnreadState();
- mountUnreadDivider();
- updateUnreadJump();
- } catch (error) {
- refs.forEach((ref) => {
- const key = messageRefKey(ref);
- if (!key) return;
- const node = feed.querySelector(`[data-message-key="${key}"]`);
- if (node) seenObserver?.observe(node);
- });
- handlers.onSeenError?.(error);
- } finally {
- seenFlushInFlight = false;
- if (routePending.size) scheduleSeenFlush();
- }
- }, 220);
- seenFlushTimersByRoute.set(routeKey, timer);
- };
-
- unreadJump.addEventListener('click', () => {
- const unreadPosts = channelData.posts.filter((post) => {
- const key = messageRefKey(post.messageRef);
- return key && unreadKeys.has(key);
- });
- const targetPost = unreadPosts.length ? unreadPosts[unreadPosts.length - 1] : channelData.posts[channelData.posts.length - 1];
- const key = messageRefKey(targetPost?.messageRef);
- if (!key) return;
- const target = feed.querySelector(`[data-message-key="${key}"]`);
- target?.scrollIntoView({ behavior: 'smooth', block: 'end' });
- });
if (channelData.isOwnChannel) {
actionButton.addEventListener('click', (event) => {
@@ -965,7 +880,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
});
});
} else {
- actionButton.addEventListener('click', handlers.onUnfollowChannel);
+ actionButton.addEventListener('click', handlers.onSubscribeChannel);
}
const backButton = document.createElement('button');
@@ -973,57 +888,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list'));
- screen.append(head, actionButton, feed, backButton, unreadJump);
-
- if (state.session.login && channelData.selector && channelData.posts.length) {
- seenObserver = new IntersectionObserver((entries) => {
- entries.forEach((entry) => {
- if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return;
- const key = String(entry.target?.dataset?.messageKey || '').trim();
- if (!key) return;
- const post = postsByKey.get(key);
- if (!post || post.seenByMe === true) return;
- routePending.add(key);
- seenObserver?.unobserve(entry.target);
- });
- if (routePending.size) scheduleSeenFlush();
- }, {
- root: document.getElementById('app-screen') || null,
- threshold: [0.6],
- });
-
- feed.querySelectorAll('[data-message-key]').forEach((node) => {
- const key = String(node.dataset.messageKey || '').trim();
- if (key && unreadKeys.has(key)) seenObserver?.observe(node);
- });
- }
-
- syncUnreadState();
- mountUnreadDivider();
- updateUnreadJump();
-
- const firstUnreadCandidate = channelData.firstUnreadKey
- || (() => {
- const first = channelData.posts.find((post) => post.seenByMe !== true);
- return messageRefKey(first?.messageRef);
- })();
- if (firstUnreadCandidate) {
- const previous = firstUnreadJumpByRoute.get(routeKey);
- if (previous !== firstUnreadCandidate) {
- pendingScrollByRoute.set(routeKey, firstUnreadCandidate);
- firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate);
- }
- } else {
- firstUnreadJumpByRoute.delete(routeKey);
- }
+ screen.append(head, actionButton, feed, backButton);
applyPendingScroll(screen, routeKey);
return () => {
- seenObserver?.disconnect();
- const timer = seenFlushTimersByRoute.get(routeKey);
- if (timer) clearTimeout(timer);
- seenFlushTimersByRoute.delete(routeKey);
- seenPendingByRoute.delete(routeKey);
+ // noop
};
}
@@ -1070,7 +939,9 @@ export function render({ navigate, route }) {
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
- throw new Error('Сессия недействительна. Выполните вход заново.');
+ state.authReturnHash = window.location.hash || '#/channels-list';
+ navigate('login-view');
+ throw new Error('Для этого действия нужно войти');
}
return { login, storagePwd };
};
@@ -1117,21 +988,6 @@ export function render({ navigate, route }) {
rerender();
};
- const onMarkSeenBatch = async (refs) => {
- if (!Array.isArray(refs) || !refs.length) return;
- const login = String(state.session.login || '').trim();
- if (!login || !routeSelector?.ownerBlockchainName || routeSelector.channelRootBlockNumber == null) return;
- await authService.markChannelMessagesSeen({
- login,
- channel: {
- ownerBlockchainName: routeSelector.ownerBlockchainName,
- channelRootBlockNumber: routeSelector.channelRootBlockNumber,
- channelRootBlockHash: routeSelector.channelRootBlockHash,
- },
- messages: refs,
- });
- };
-
const onShare = async (routePath) => {
try {
const routeToShare = String(routePath || '').trim();
@@ -1145,7 +1001,7 @@ export function render({ navigate, route }) {
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
} catch (error) {
- showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
+ showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
}
};
@@ -1237,11 +1093,14 @@ export function render({ navigate, route }) {
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
}
},
- onUnfollowChannel: async (event) => {
+ onSubscribeChannel: async (event) => {
animatePress(event?.currentTarget);
try {
const { login, storagePwd } = requireSigningSession();
- if (!apiData.selector) throw new Error('Не удалось определить канал для отписки.');
+ if (!apiData.selector) throw new Error('Не удалось определить канал для подписки.');
+ const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
+ const ok = window.confirm(`Подписаться на канал ${targetName}?`);
+ if (!ok) return;
await authService.addBlockFollowChannel({
login,
@@ -1249,26 +1108,15 @@ export function render({ navigate, route }) {
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
- unfollow: true,
+ unfollow: false,
});
softHaptic(15);
- showToast('Отписка от канала выполнена');
- navigate('channels-list');
+ showToast('Подписка на канал выполнена');
} catch (error) {
- showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
+ showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
}
},
- onMarkSeenBatch: async (refs) => {
- try {
- await onMarkSeenBatch(refs);
- } catch (error) {
- throw new Error(toUserMessage(error, 'Не удалось отметить сообщения как прочитанные.'));
- }
- },
- onSeenError: (error) => {
- showStatus(toUserMessage(error, 'Не удалось обновить статус прочтения.'));
- },
});
} catch (error) {
skeleton.remove();
diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js
index db46935..5a25d19 100644
--- a/shine-UI/js/pages/channels-list.js
+++ b/shine-UI/js/pages/channels-list.js
@@ -40,6 +40,11 @@ function normalizeLoginInput(value) {
}
function buildChannelRouteFromSummary(summary, fallbackId) {
+ const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
+ const channelName = String(summary?.channel?.channelName || '').trim();
+ if (ownerLogin && channelName) {
+ return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`;
+ }
const ownerBch = summary?.channel?.ownerBlockchainName;
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
@@ -406,6 +411,117 @@ function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = fal
if (inputEl) inputEl.focus();
}
+function openChannelFinderModal({ navigate }) {
+ const root = document.getElementById('modal-root');
+ root.innerHTML = `
+
+
+
Поиск каналов
+
Введите логин или формат login/channel
+
+
+
+
+
+
+
+
+
+ `;
+
+ const inputEl = root.querySelector('#channels-find-input');
+ const suggestEl = root.querySelector('#channels-find-suggest');
+ const channelsEl = root.querySelector('#channels-find-list');
+ const errorEl = root.querySelector('#channels-find-error');
+ const close = () => { root.innerHTML = ''; };
+
+ const renderButtons = (container, values, onPick) => {
+ container.innerHTML = '';
+ if (!values.length) {
+ container.style.display = 'none';
+ return;
+ }
+ container.style.display = '';
+ values.forEach((value) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'channel-search-item';
+ btn.textContent = value.label;
+ btn.addEventListener('click', () => onPick(value));
+ container.append(btn);
+ });
+ };
+
+ const loadChannelsForLogin = async (login, filterChannel = '') => {
+ const ownerLogin = normalizeLoginInput(login);
+ if (!ownerLogin) return;
+ const feed = await authService.listSubscriptionsFeed(ownerLogin, 1000);
+ const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
+ const needle = String(filterChannel || '').trim().toLowerCase();
+ const channels = rows
+ .map((item) => String(item?.channel?.channelName || '').trim())
+ .filter(Boolean)
+ .filter((name) => !needle || name.toLowerCase().includes(needle))
+ .slice(0, 200)
+ .map((name) => ({ label: `${ownerLogin}/${name}`, ownerLogin, channelName: name }));
+ renderButtons(channelsEl, channels, (item) => {
+ close();
+ navigate(`channel/${encodeRoutePart(item.ownerLogin)}/${encodeRoutePart(item.channelName)}`);
+ });
+ };
+
+ const refresh = createDebounced(async () => {
+ const raw = String(inputEl?.value || '').trim();
+ errorEl.textContent = '';
+ if (!raw) {
+ suggestEl.style.display = 'none';
+ suggestEl.innerHTML = '';
+ channelsEl.style.display = 'none';
+ channelsEl.innerHTML = '';
+ return;
+ }
+
+ const parts = raw.split('/');
+ const loginPrefix = normalizeLoginInput(parts[0] || '');
+ const channelFilter = String(parts[1] || '').trim();
+
+ try {
+ if (raw.includes('/')) {
+ suggestEl.style.display = 'none';
+ suggestEl.innerHTML = '';
+ await loadChannelsForLogin(loginPrefix, channelFilter);
+ return;
+ }
+
+ if (loginPrefix.length < 2) {
+ suggestEl.style.display = 'none';
+ suggestEl.innerHTML = '';
+ channelsEl.style.display = 'none';
+ channelsEl.innerHTML = '';
+ return;
+ }
+
+ const logins = await authService.searchUsers(loginPrefix);
+ const items = (Array.isArray(logins) ? logins : []).slice(0, 15).map((login) => ({
+ label: login,
+ login,
+ }));
+ renderButtons(suggestEl, items, async (item) => {
+ inputEl.value = `${item.login}/`;
+ suggestEl.style.display = 'none';
+ suggestEl.innerHTML = '';
+ await loadChannelsForLogin(item.login, '');
+ });
+ } catch (error) {
+ errorEl.textContent = toUserMessage(error, 'Не удалось выполнить поиск.');
+ }
+ }, 220);
+
+ root.querySelector('#channels-find-close')?.addEventListener('click', close);
+ inputEl?.addEventListener('input', refresh);
+ if (inputEl) inputEl.focus();
+}
+
function mapMockGroups() {
const mapRow = (channel) => ({
...channel,
@@ -527,6 +643,16 @@ function toListModel(groups) {
function renderEmptyState(activeTab, navigate) {
const wrap = document.createElement('div');
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
+ const text = document.createElement('p');
+ text.className = 'meta-muted';
+ if (activeTab === 'subscriptions') {
+ text.textContent = 'Вы пока не подписаны на каналы.';
+ } else if (activeTab === 'my') {
+ text.textContent = 'У вас пока нет каналов.';
+ } else {
+ text.textContent = 'Пока нет каналов авторов.';
+ }
+ wrap.append(text);
return wrap;
}
@@ -896,14 +1022,9 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
}
if (tab === 'authors') {
- button.textContent = 'Подписаться на автора';
+ button.textContent = '🔍 Поиск каналов';
button.className = baseClass;
- button.onclick = () => openSimpleSubscribeModal({
- kind: 'user',
- kindLabel: 'Подписка на автора',
- submitLabel: 'Подписаться',
- onSuccess: onReload,
- });
+ button.onclick = () => openChannelFinderModal({ navigate });
return;
}
@@ -954,7 +1075,7 @@ export function render({ navigate }) {
const notificationsState = readChannelNotificationsState();
const listState = {
- activeTab: 'my',
+ activeTab: 'subscriptions',
openMenuId: null,
notificationsState,
revealedCounters: new Set(),
@@ -1001,8 +1122,8 @@ export function render({ navigate }) {
};
const tabItems = [
+ { key: 'subscriptions', label: 'Каналы' },
{ key: 'my', label: 'Мои' },
- { key: 'subscriptions', label: 'Подписки' },
{ key: 'authors', label: 'Авторы' },
];
diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js
index d23d202..c24a5e8 100644
--- a/shine-UI/js/pages/registration-keys-view.js
+++ b/shine-UI/js/pages/registration-keys-view.js
@@ -130,7 +130,13 @@ export function render({ navigate }) {
setAuthInfo(isLoginFlow
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
: `Ключи сохранены. Регистрация завершена для @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`);
- navigate('profile-view');
+ const nextHash = String(state.authReturnHash || '').trim();
+ state.authReturnHash = '';
+ if (nextHash.startsWith('#/')) {
+ navigate(nextHash.slice(2));
+ } else {
+ navigate('profile-view');
+ }
} catch (error) {
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
setAuthError(message);
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 9449a3e..40f6aa4 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -50,6 +50,42 @@ export function getRoute() {
return { pageId, params: { channelId: dynamicId || '' } };
}
+ if (pageId === 'channel') {
+ // Новый короткий формат:
+ // #/channel/{login}/{channelName}
+ // #/channel/{login}/{channelName}/{messageBlockNumber}/{messageBlockHash}
+ const ownerLogin = decodePart(segments[1] || '');
+ const channelName = decodePart(segments[2] || '');
+ const messageBlockNumber = segments[3] || '';
+ const messageBlockHash = segments[4] || '';
+
+ if (ownerLogin && channelName && messageBlockNumber && messageBlockHash) {
+ return {
+ pageId: 'channel-thread-view',
+ params: {
+ ownerLogin,
+ channelName,
+ messageBlockNumber,
+ messageBlockHash,
+ // поддержка старого контракта страницы треда
+ messageBlockchainName: '',
+ channelOwnerBlockchainName: '',
+ channelRootBlockNumber: '',
+ channelRootBlockHash: '',
+ },
+ };
+ }
+
+ return {
+ pageId: 'channel-view',
+ params: {
+ ownerLogin,
+ channelName,
+ channelId: '',
+ },
+ };
+ }
+
if (pageId === 'channel-thread-view') {
return {
pageId,
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index 3899267..e789dec 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -740,7 +740,12 @@ export class AuthService {
}
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
- const payload = { channel, limit, sort };
+ const normalizedChannel = {
+ ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
+ channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
+ channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
+ };
+ const payload = { channel: normalizedChannel, limit, sort };
const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetChannelMessages', payload);
@@ -749,7 +754,12 @@ export class AuthService {
}
async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
- const payload = { message, depthUp, depthDown, limitChildrenPerNode };
+ const normalizedMessage = {
+ blockchainName: String(message?.blockchainName || '').trim(),
+ blockNumber: Number(message?.blockNumber),
+ blockHash: String(message?.blockHash || '').trim(),
+ };
+ const payload = { message: normalizedMessage, depthUp, depthDown, limitChildrenPerNode };
const cleanLogin = String(login || '').trim();
if (cleanLogin) payload.login = cleanLogin;
const response = await this.ws.request('GetMessageThread', payload);
@@ -757,17 +767,6 @@ 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/state.js b/shine-UI/js/state.js
index 1857cf8..dbec24a 100644
--- a/shine-UI/js/state.js
+++ b/shine-UI/js/state.js
@@ -252,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) {
error: '',
info: '',
},
+ authReturnHash: '',
sessions: [],
channelsFeed: null,
channelsIndex: {},
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 3ca5e5b..a9c73b9 100644
--- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
+++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
@@ -360,35 +360,7 @@ 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)
+ // 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS reactions_state (
from_login TEXT NOT NULL,
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 c13a381..715f72a 100644
--- a/shine-server-db/src/main/java/shine/db/SqliteDbController.java
+++ b/shine-server-db/src/main/java/shine/db/SqliteDbController.java
@@ -14,7 +14,7 @@ import java.sql.Statement;
public final class SqliteDbController {
private static volatile SqliteDbController instance;
- private static final int LATEST_SCHEMA_VERSION = DatabaseInitializer.SCHEMA_VERSION_1;
+ private static final int LATEST_SCHEMA_VERSION = 2;
private final String jdbcUrl;
@@ -84,6 +84,7 @@ public final class SqliteDbController {
private void applyMigration(int targetVersion) {
switch (targetVersion) {
case 1 -> migrateToV1();
+ case 2 -> migrateToV2();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
}
}
@@ -123,6 +124,29 @@ public final class SqliteDbController {
}
}
+ private void migrateToV2() {
+ try (Connection c = DriverManager.getConnection(jdbcUrl);
+ Statement st = c.createStatement()) {
+ c.setAutoCommit(false);
+ try {
+ st.execute("PRAGMA foreign_keys = OFF");
+ st.executeUpdate("DROP TABLE IF EXISTS message_views_state");
+ st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_target");
+ st.executeUpdate("DROP INDEX IF EXISTS idx_message_views_state_viewer_channel");
+ setSchemaVersion(c, 2);
+ st.execute("PRAGMA foreign_keys = ON");
+ c.commit();
+ } catch (Exception e) {
+ try { c.rollback(); } catch (Exception ignored) {}
+ throw new RuntimeException("DB migration to v2 failed", e);
+ } finally {
+ try { c.setAutoCommit(true); } catch (Exception ignored) {}
+ }
+ } catch (SQLException e) {
+ throw new RuntimeException("DB migration to v2 failed", e);
+ }
+ }
+
private int getCurrentSchemaVersion() {
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
@@ -183,23 +207,6 @@ 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("""
@@ -246,17 +253,6 @@ 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 58233ee..464002b 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,11 +49,9 @@ 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;
@@ -131,7 +129,6 @@ 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()),
@@ -187,7 +184,6 @@ 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 65f9a6a..6b996c0 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,111 +212,6 @@ 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 5e9a4ce..64d5e3d 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,22 +108,11 @@ 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 9fa2148..e6cce70 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,9 +178,6 @@ 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();
ci.setOwnerBlockchainName(row.bchName);
@@ -229,4 +226,3 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
int msgSubType;
}
}
-
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 c6e072f..42f0891 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
@@ -74,7 +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));
+ row.setUnreadCount(0);
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/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 60c6f24..1b3ae57 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,8 +8,6 @@ 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; }
@@ -17,11 +15,6 @@ 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;
@@ -55,8 +48,6 @@ 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<>();
@@ -84,12 +75,6 @@ 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.md b/Как_устроены_каналы_в_блокчейне_SHiNE.md
new file mode 100644
index 0000000..e121599
--- /dev/null
+++ b/Как_устроены_каналы_в_блокчейне_SHiNE.md
@@ -0,0 +1,175 @@
+# Как устроены каналы в блокчейне SHiNE
+
+## 1) Коротко: что такое “канал” в текущей реализации
+
+В SHiNE канал — это не отдельная таблица сообщений, а **линия блоков** внутри блокчейна пользователя:
+
+- канал создается TECH-блоком `CreateChannelBody` (`msg_type=0`, `msg_sub_type=1`);
+- сообщения канала — это `TEXT_POST` (`msg_type=1`, `msg_sub_type=10`) с `line_code = rootBlockNumber канала`;
+- ответы (треды) — это `TEXT_REPLY` (`msg_sub_type=20`) с target-ссылкой на конкретный блок-сообщение.
+
+То есть канал = “корневой блок канала” + все посты в его линии + связанные ответы.
+
+---
+
+## 2) Как канал появляется
+
+Создание канала идет через `AddBlock`:
+
+1. UI собирает `CreateChannelBody` (v2, а при legacy-ошибке fallback на v1).
+2. UI подписывает блок приватным blockchain-ключом на устройстве.
+3. UI отправляет на сервер `AddBlock` с `blockBytesB64` (полный бинарный блок: preimage + sigMarker + signature).
+4. Сервер:
+ - проверяет цепочку (`prevHash`, `blockNumber=last+1`);
+ - парсит body;
+ - валидирует подпись;
+ - валидирует имя канала;
+ - сохраняет блок и обновляет state.
+
+Дополнительно сервер поддерживает `channel_names_state` как нормализованное состояние названий каналов.
+
+---
+
+## 3) Правила имени и описания канала
+
+### Имя канала (`ChannelNameRules`)
+
+- длина: `3..32` символов (code points);
+- допустимые символы: Latin/Cyrillic, цифры, пробел, `_`, `-`;
+- имя нормализуется (trim + схлопывание пробелов);
+- канонический slug строится в lower-case, `ё -> е`, разделители -> `-`.
+
+### Уникальность
+
+Проверяется по slug. При конфликте сервер возвращает `channel_name_already_exists`.
+
+### Описание канала
+
+В `CreateChannelBody v2` описание хранится прямо в блоке (до 200 байт UTF-8).
+
+Для совместимости с legacy-v1 есть fallback: описание может сохраняться как `USER_PARAM` ключа вида:
+
+`channel_desc:{ownerBlockchainName}:{rootBlockNumber}:{rootBlockHash}`
+
+В UI при чтении описание берется из ответа канала и при наличии override — перекрывается значением из `USER_PARAM`.
+
+---
+
+## 4) Канал “0”
+
+`rootBlockNumber=0` — технический root-канал.
+Публикации `TEXT_POST` в канал `0` сейчас отключены (на сервере есть явный запрет).
+
+---
+
+## 5) Как идут сообщения в канале
+
+### Публикация поста
+
+UI вызывает `addBlockTextPost` -> `AddBlock` с `TEXT_POST`.
+
+Ограничения:
+
+- писать можно только в **свой** блокчейн и свои каналы;
+- для поста задается `line_code` канала;
+- пост в канале — это новый неизменяемый блок.
+
+### Ответы
+
+Ответы (`TEXT_REPLY`) не обязаны лежать в той же линии.
+Они ссылаются на целевой блок через target (`to_bch_name`, `to_block_number`, `to_block_hash`).
+
+Это позволяет отвечать и из других блокчейнов (межпользовательский тред).
+
+---
+
+## 6) Редактирование и удаление сообщений
+
+### Редактирование
+
+Поддержано на уровне протокола:
+
+- `TEXT_EDIT_POST` (11) — правка поста;
+- `TEXT_EDIT_REPLY` (21) — правка ответа.
+
+Правка — это **новый блок**, ссылающийся на оригинал.
+Оригинальный блок не меняется.
+
+Серверные read-API уже собирают `versions[]` и `versionsTotal`.
+
+### Удаление
+
+Сейчас отдельного subtype “delete post/reply” нет.
+Физического удаления блоков из цепочки нет (блоки иммутабельны).
+
+Итог:
+
+- изменить можно через edit-блок;
+- удалить “как в чате” сейчас нельзя.
+
+---
+
+## 7) Как UI получает канал
+
+Основные read-API:
+
+- `ListSubscriptionsFeed` — список каналов/подписок;
+- `GetChannelMessages` — посты канала;
+- `GetMessageThread` — тред вокруг выбранного сообщения.
+
+Важно: UI получает **JSON-представление**, собранное сервером из блоков БД, а не сырые блоки по умолчанию.
+
+В JSON возвращаются:
+
+- `messageRef` (номер и hash блока),
+- автор,
+- текущий текст,
+- `versions[]` (оригинал + правки),
+- counters (`likesCount`, `repliesCount`).
+
+---
+
+## 8) Как строится тред
+
+`GetMessageThread`:
+
+1. Находит focus-сообщение по `(blockchainName, blockNumber)`.
+2. Строит `ancestors` вверх по target-ссылкам.
+3. Строит `descendants` вниз: replies, где target = focus.
+
+Запросы в БД идут по `to_bch_name + to_block_number + to_block_hash`, поэтому ответы из других блокчейнов тоже связываются.
+
+---
+
+## 9) Что проверяется криптографически
+
+При записи (`AddBlock`) сервер проверяет:
+
+- корректность формата блока;
+- непрерывность цепочки;
+- `SHA-256(preimage)` и Ed25519-подпись;
+- соответствие публичного blockchain-ключа пользователя.
+
+На чтении (`GetChannelMessages`, `GetMessageThread`) сервер отдает уже сохраненные данные (JSON из БД).
+Повторная верификация каждой записи при каждом чтении не делается.
+
+---
+
+## 10) UI-статус на сегодня (важно)
+
+На момент этого документа:
+
+- в UI нет полноценного экрана истории правок сообщения (хотя `versions` уже приходят);
+- нет операции удаления сообщений (и на протоколе нет delete subtype);
+- канал читается как JSON-слой поверх блоков, а не как “сырой бинарный блок-объект”.
+
+---
+
+## 11) Практический вывод по модели данных
+
+Каналы в SHiNE — это append-only модель:
+
+- каждое действие = новый подписанный блок;
+- “изменение” = добавление новой версии, не перезапись старой;
+- целостность и авторство обеспечиваются подписью и связностью цепочки;
+- UI может показывать удобный “чатовый” вид, но источник истины — блоки.
diff --git a/Типы_блоков_и_сообщений_SHiNE.md b/Типы_блоков_и_сообщений_SHiNE.md
new file mode 100644
index 0000000..94cf9b3
--- /dev/null
+++ b/Типы_блоков_и_сообщений_SHiNE.md
@@ -0,0 +1,141 @@
+# Типы блоков и сообщений SHiNE (карта протокола)
+
+## 1) Главный принцип
+
+В блокчейн попадают только записи `AddBlock` (подписанные бинарные блоки).
+Все остальное (например, call signaling, push-события, служебные JSON-операции) — не блокчейн-данные.
+
+---
+
+## 2) Базовые `msg_type`
+
+## `msg_type=0` — TECH
+
+- `subType=0` — `HEADER_COMPAT` (техническая совместимость);
+- `subType=1` — `TECH_CREATE_CHANNEL` (создание канала).
+
+Используется для структуры каналов.
+
+## `msg_type=1` — TEXT
+
+- `10` — `TEXT_POST` (пост в линии канала);
+- `11` — `TEXT_EDIT_POST` (правка поста);
+- `20` — `TEXT_REPLY` (ответ на сообщение через target);
+- `21` — `TEXT_EDIT_REPLY` (правка ответа).
+
+Это основной контент каналов и тредов.
+
+## `msg_type=2` — REACTION
+
+- `1` — `REACTION_LIKE`;
+- `2` — `REACTION_UNLIKE`.
+
+Лайки/снятие лайка, считаются через state-триггеры и/или агрегации.
+
+## `msg_type=3` — CONNECTION
+
+Связи между пользователями (friend/contact/follow/spouse/parent/child/sibling + обратные UN*).
+
+Используется для соцграфа и подписок:
+
+- `FOLLOW/UNFOLLOW` — подписки на авторов/каналы.
+
+## `msg_type=4` — USER_PARAM
+
+Ключ-значение параметра пользователя (profile / тех.параметры / fallback-метаданные).
+
+Пример для каналов: fallback-описание `channel_desc:...`.
+
+---
+
+## 3) Что **не** является блокчейн-типом
+
+Ниже операции есть в протоколе, но не через `AddBlock`:
+
+- `CallInviteBroadcast`, `CallSignalToSession` (сигналинг звонков),
+- `SendDirectMessage`, `ReceiveIncomingMessage`, `ReceiveOutcomingMessage`,
+- `AckSessionDelivery`, `UpsertPushToken`, `SendTestWebPush`,
+- системные `Ping`, `GetServerInfo`, логи и т.п.
+
+Это JSON-операции поверх WS/серверной логики.
+
+---
+
+## 4) Формат блока (высокоуровнево)
+
+Блок включает:
+
+1. preimage (header + body),
+2. `sigMarker`,
+3. `signature64`.
+
+`hash32 = SHA-256(preimage)`, подпись Ed25519 проверяется сервером при `AddBlock`.
+
+Ключевые проверки на сервере:
+
+- `blockNumber == last + 1`,
+- `prevHash` совпадает с последним хэшем цепочки,
+- body валиден по типу/версии/subtype,
+- подпись корректна.
+
+---
+
+## 5) Где и как это используется в UI
+
+## Уже активно
+
+- создание канала (`TECH_CREATE_CHANNEL`);
+- пост в канал (`TEXT_POST`);
+- ответ (`TEXT_REPLY`);
+- лайк/анлайк (`REACTION_*`);
+- follow/unfollow через connection-блоки.
+
+## Частично готово на API, но не доведено в UI
+
+- отображение полной истории правок (`versions[]` есть в API, но UI показывает не полностью как отдельный workflow);
+- редактирование поста/ответа (типы в протоколе есть, UI-сценарий не завершен);
+- удаление сообщений отсутствует как тип.
+
+---
+
+## 6) Про “AI сообщения”
+
+Отдельного `msg_type/subType` “AI message” в текущем протоколе нет.
+Если нужно, это обычно делают либо:
+
+- как новый `TEXT_*` subtype (если это контент канала),
+- либо как отдельный новый `msg_type` (если нужна независимая семантика/правила).
+
+---
+
+## 7) Почему в UI виден JSON, а не “сырой блок”
+
+Текущий read-path сделан так:
+
+- сервер читает блоки из БД;
+- парсит и собирает удобное JSON-представление;
+- UI рендерит его как сообщения/треды.
+
+Плюсы:
+
+- проще и быстрее для интерфейса;
+- не дублируется сложная логика парсинга блоков на клиенте.
+
+Минусы:
+
+- клиент не делает локальную крипто-верификацию каждого прочитанного элемента.
+
+При необходимости можно добавить режим “raw block view” и верификацию на клиенте как отдельный экспертный экран.
+
+---
+
+## 8) Рекомендация по UX/протоколу
+
+Для обычного пользователя лучше оставить “UI-сообщения” (читаемо и быстро).
+Для аудита/доверия имеет смысл добавить отдельный режим:
+
+- показать `blockNumber/hash/signature`,
+- показать все версии,
+- кнопка “проверить подпись локально” (advanced).
+
+Так получится и удобство, и проверяемость.