Каналы: новый роутинг, поиск, вход-возврат, удаление просмотров и документация
This commit is contained in:
parent
6774c26ea1
commit
acdd6c928b
@ -1,2 +1,2 @@
|
||||
client.version=1.2.40
|
||||
server.version=1.2.34
|
||||
client.version=1.2.42
|
||||
server.version=1.2.36
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 = `
|
||||
<span class="channel-action-icon" aria-hidden="true">✦</span>
|
||||
<span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||
`;
|
||||
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 = `
|
||||
<span class="channel-action-icon" aria-hidden="true">⟳</span>
|
||||
<span class="channel-action-label">Отразить</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
`;
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
@ -754,7 +779,7 @@ function renderPostCard(post, {
|
||||
shareButton.className = 'channel-action-item channel-action-share';
|
||||
shareButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
||||
<span class="channel-action-label">Транслировать</span>
|
||||
<span class="channel-action-label">Отправить</span>
|
||||
`;
|
||||
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 = `
|
||||
<span class="channels-unread-jump-icon" aria-hidden="true">↓</span>
|
||||
<span class="channels-unread-jump-badge"></span>
|
||||
`;
|
||||
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();
|
||||
|
||||
@ -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 = `
|
||||
<div class="modal" id="channels-find-modal">
|
||||
<div class="modal-card stack" style="max-width: min(96vw, 760px); width: min(96vw, 760px);">
|
||||
<h3 class="modal-title">Поиск каналов</h3>
|
||||
<p class="meta-muted">Введите логин или формат login/channel</p>
|
||||
<input id="channels-find-input" class="input" placeholder="login/channel" autocomplete="off" />
|
||||
<div id="channels-find-suggest" class="channels-search-suggest" style="display:none"></div>
|
||||
<div id="channels-find-list" class="channels-search-suggest" style="display:none"></div>
|
||||
<div id="channels-find-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" id="channels-find-close" type="button">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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: 'Авторы' },
|
||||
];
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -252,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
error: '',
|
||||
info: '',
|
||||
},
|
||||
authReturnHash: '',
|
||||
sessions: [],
|
||||
channelsFeed: null,
|
||||
channelsIndex: {},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 "";
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -8,8 +8,6 @@ import java.util.List;
|
||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||
private Channel channel;
|
||||
private List<MessageItem> 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<MessageItem> getMessages() { return messages; }
|
||||
public void setMessages(List<MessageItem> 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<VersionItem> 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; }
|
||||
|
||||
|
||||
175
Как_устроены_каналы_в_блокчейне_SHiNE.md
Normal file
175
Как_устроены_каналы_в_блокчейне_SHiNE.md
Normal file
@ -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 может показывать удобный “чатовый” вид, но источник истины — блоки.
|
||||
141
Типы_блоков_и_сообщений_SHiNE.md
Normal file
141
Типы_блоков_и_сообщений_SHiNE.md
Normal file
@ -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).
|
||||
|
||||
Так получится и удобство, и проверяемость.
|
||||
Loading…
Reference in New Issue
Block a user