Каналы: новый роутинг, поиск, вход-возврат, удаление просмотров и документация
This commit is contained in:
parent
6774c26ea1
commit
acdd6c928b
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.40
|
client.version=1.2.42
|
||||||
server.version=1.2.34
|
server.version=1.2.36
|
||||||
|
|||||||
@ -98,6 +98,24 @@ function buildAbsoluteRouteUrl(routePath = '') {
|
|||||||
|
|
||||||
function parseThreadSelector(route) {
|
function parseThreadSelector(route) {
|
||||||
const params = route?.params || {};
|
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);
|
const blockNumber = toSafeInt(params.messageBlockNumber);
|
||||||
if (!params.messageBlockchainName || blockNumber == null) return null;
|
if (!params.messageBlockchainName || blockNumber == null) return null;
|
||||||
|
|
||||||
@ -153,7 +171,17 @@ function buildBackRoute(selector) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildThreadRouteFromTarget(target, 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 [
|
return [
|
||||||
'channel-thread-view',
|
'channel-thread-view',
|
||||||
encodeRoutePart(target.blockchainName),
|
encodeRoutePart(target.blockchainName),
|
||||||
@ -293,13 +321,6 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
|||||||
|
|
||||||
card.append(meta, body, stats);
|
card.append(meta, body, stats);
|
||||||
|
|
||||||
if (options.showViews === true) {
|
|
||||||
const views = document.createElement('p');
|
|
||||||
views.className = 'thread-node-views';
|
|
||||||
views.textContent = `Просмотры: ${Number(node?.viewCount || 0)}`;
|
|
||||||
card.append(views);
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = buildTargetFromNode(node);
|
const target = buildTargetFromNode(node);
|
||||||
const refKey = messageRefKey(target);
|
const refKey = messageRefKey(target);
|
||||||
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
|
const countersVisible = refKey ? isCounterVisible(routeKey, refKey) : true;
|
||||||
@ -337,15 +358,19 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
|||||||
likeButton.type = 'button';
|
likeButton.type = 'button';
|
||||||
likeButton.className = 'secondary-btn thread-like-btn';
|
likeButton.className = 'secondary-btn thread-like-btn';
|
||||||
if (isLiked) likeButton.classList.add('is-liked');
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
likeButton.textContent = isPending ? '✦ Сияние...' : '✦ Сияние';
|
likeButton.textContent = isPending ? '❤️ Лайк...' : (isLiked ? '❤️ Лайк' : '🤍 Лайк');
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
|
if (!isLiked) {
|
||||||
|
const ok = window.confirm('Поставить лайк?');
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
revealCounters();
|
revealCounters();
|
||||||
await longPressFeel(event.currentTarget, 130);
|
await longPressFeel(event.currentTarget, 130);
|
||||||
likeButton.disabled = true;
|
likeButton.disabled = true;
|
||||||
likeButton.textContent = 'Сияние...';
|
likeButton.textContent = 'Лайк...';
|
||||||
try {
|
try {
|
||||||
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
await handlers.onToggleLike(target, isLiked ? 'unlike' : 'like');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -361,7 +386,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
|||||||
const replyButton = document.createElement('button');
|
const replyButton = document.createElement('button');
|
||||||
replyButton.type = 'button';
|
replyButton.type = 'button';
|
||||||
replyButton.className = 'secondary-btn thread-reply-btn';
|
replyButton.className = 'secondary-btn thread-reply-btn';
|
||||||
replyButton.textContent = '⟳ Отразить';
|
replyButton.textContent = '💬 Ответить';
|
||||||
replyButton.addEventListener('click', (event) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
revealCounters();
|
revealCounters();
|
||||||
@ -373,7 +398,7 @@ function renderNodeCard(node, heading, handlers, localNumber, routeKey, options
|
|||||||
const shareButton = document.createElement('button');
|
const shareButton = document.createElement('button');
|
||||||
shareButton.type = 'button';
|
shareButton.type = 'button';
|
||||||
shareButton.className = 'secondary-btn thread-share-btn';
|
shareButton.className = 'secondary-btn thread-share-btn';
|
||||||
shareButton.textContent = '↗ Транслировать';
|
shareButton.textContent = '↗ Отправить';
|
||||||
shareButton.addEventListener('click', async (event) => {
|
shareButton.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
@ -454,10 +479,6 @@ export function render({ navigate, route }) {
|
|||||||
const appScreen = document.getElementById('app-screen');
|
const appScreen = document.getElementById('app-screen');
|
||||||
appScreen?.classList.add('channels-scroll-clean');
|
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');
|
const channelIndicator = document.createElement('div');
|
||||||
channelIndicator.className = 'card channels-user-chip';
|
channelIndicator.className = 'card channels-user-chip';
|
||||||
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
channelIndicator.textContent = `Канал: ${channelDisplayName || selector?.channel?.ownerBlockchainName || 'неизвестно'}`;
|
||||||
@ -490,7 +511,11 @@ export function render({ navigate, route }) {
|
|||||||
const requireSigningSession = () => {
|
const requireSigningSession = () => {
|
||||||
const login = state.session.login;
|
const login = state.session.login;
|
||||||
const storagePwd = state.session.storagePwdInMemory;
|
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 };
|
return { login, storagePwd };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -562,7 +587,7 @@ export function render({ navigate, route }) {
|
|||||||
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
leftAction: { label: '<', onClick: () => navigate(backRoute) },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
screen.append(userIndicator, channelIndicator, statusBox);
|
screen.append(channelIndicator, statusBox);
|
||||||
|
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
const invalid = document.createElement('div');
|
const invalid = document.createElement('div');
|
||||||
@ -576,7 +601,25 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
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();
|
skeleton.remove();
|
||||||
|
|
||||||
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
|
||||||
@ -605,7 +648,7 @@ export function render({ navigate, route }) {
|
|||||||
if (focus) {
|
if (focus) {
|
||||||
const focusWrap = document.createElement('div');
|
const focusWrap = document.createElement('div');
|
||||||
focusWrap.className = 'stack thread-block thread-block--focus';
|
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);
|
screen.append(focusWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,9 +21,6 @@ export const pageMeta = { id: 'channel-view', title: 'Канал' };
|
|||||||
const pendingReactionActions = new Set();
|
const pendingReactionActions = new Set();
|
||||||
const pendingScrollByRoute = new Map();
|
const pendingScrollByRoute = new Map();
|
||||||
const revealedCountersByRoute = new Map();
|
const revealedCountersByRoute = new Map();
|
||||||
const seenFlushTimersByRoute = new Map();
|
|
||||||
const seenPendingByRoute = new Map();
|
|
||||||
const firstUnreadJumpByRoute = new Map();
|
|
||||||
|
|
||||||
function isChannelsDemoMode() {
|
function isChannelsDemoMode() {
|
||||||
try {
|
try {
|
||||||
@ -164,6 +161,13 @@ function parseDescriptionOverride(payload) {
|
|||||||
function buildSelectorFromRoute(route, channelId) {
|
function buildSelectorFromRoute(route, channelId) {
|
||||||
const params = route?.params || {};
|
const params = route?.params || {};
|
||||||
|
|
||||||
|
if (params.ownerLogin && params.channelName) {
|
||||||
|
return {
|
||||||
|
ownerLogin: String(params.ownerLogin || '').trim(),
|
||||||
|
channelName: String(params.channelName || '').trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (params.ownerBlockchainName) {
|
if (params.ownerBlockchainName) {
|
||||||
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
|
const rootBlockNumber = toSafeInt(params.channelRootBlockNumber);
|
||||||
if (rootBlockNumber != null) {
|
if (rootBlockNumber != null) {
|
||||||
@ -186,6 +190,17 @@ function buildSelectorFromRoute(route, channelId) {
|
|||||||
|
|
||||||
function buildThreadRoute(messageRef, selector) {
|
function buildThreadRoute(messageRef, selector) {
|
||||||
if (!messageRef || !selector) return '';
|
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 [
|
return [
|
||||||
'channel-thread-view',
|
'channel-thread-view',
|
||||||
encodeRoutePart(messageRef.blockchainName),
|
encodeRoutePart(messageRef.blockchainName),
|
||||||
@ -502,8 +517,6 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
body: resolvedText || '(пусто)',
|
body: resolvedText || '(пусто)',
|
||||||
likesCount: Number(message?.likesCount || 0),
|
likesCount: Number(message?.likesCount || 0),
|
||||||
repliesCount: Number(message?.repliesCount || 0),
|
repliesCount: Number(message?.repliesCount || 0),
|
||||||
viewCount: Number(message?.viewCount || 0),
|
|
||||||
seenByMe: message?.seenByMe === true,
|
|
||||||
timestampMs: resolveMessageTimestampMs(message),
|
timestampMs: resolveMessageTimestampMs(message),
|
||||||
messageRef,
|
messageRef,
|
||||||
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
reactionState: messageRef ? getMessageReactionState(messageRef) : '',
|
||||||
@ -511,7 +524,25 @@ function mapApiMessageToPost(message, selector, localNumber) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadFromApi(route, channelId) {
|
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) {
|
if (!selector?.ownerBlockchainName || selector.channelRootBlockNumber == null) {
|
||||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||||
}
|
}
|
||||||
@ -520,8 +551,6 @@ async function loadFromApi(route, channelId) {
|
|||||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
const posts = messages.map((message, index) => mapApiMessageToPost(message, selector, index + 1));
|
||||||
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
const ownerLogin = String(payload.channel?.ownerLogin || '').trim();
|
||||||
const firstUnreadKey = blockRefToMessageKey(payload.firstUnreadMessageRef, selector.ownerBlockchainName);
|
|
||||||
const unreadFromPayload = Number(payload.unreadCount || 0);
|
|
||||||
|
|
||||||
const readDescription = async () => {
|
const readDescription = async () => {
|
||||||
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
|
const sourceDescription = String(payload.channel?.channelDescription || '').trim();
|
||||||
@ -547,10 +576,6 @@ async function loadFromApi(route, channelId) {
|
|||||||
ownerName: ownerLogin || 'неизвестно',
|
ownerName: ownerLogin || 'неизвестно',
|
||||||
},
|
},
|
||||||
posts,
|
posts,
|
||||||
unreadCount: Number.isFinite(unreadFromPayload)
|
|
||||||
? Math.max(0, unreadFromPayload)
|
|
||||||
: posts.filter((post) => post.seenByMe !== true).length,
|
|
||||||
firstUnreadKey,
|
|
||||||
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
|
isOwnChannel: ownerLogin.toLowerCase() === (state.session.login || '').toLowerCase(),
|
||||||
selector,
|
selector,
|
||||||
};
|
};
|
||||||
@ -665,11 +690,7 @@ function renderPostCard(post, {
|
|||||||
body.className = 'channel-message-body';
|
body.className = 'channel-message-body';
|
||||||
body.textContent = post.body;
|
body.textContent = post.body;
|
||||||
|
|
||||||
const views = document.createElement('p');
|
card.append(topRow, body);
|
||||||
views.className = 'channel-message-views';
|
|
||||||
views.textContent = `Просмотры: ${Number(post.viewCount || 0)}`;
|
|
||||||
|
|
||||||
card.append(topRow, body, views);
|
|
||||||
|
|
||||||
const refKey = messageRefKey(post.messageRef);
|
const refKey = messageRefKey(post.messageRef);
|
||||||
if (refKey) {
|
if (refKey) {
|
||||||
@ -703,19 +724,23 @@ function renderPostCard(post, {
|
|||||||
const isLiked = post.reactionState === 'liked';
|
const isLiked = post.reactionState === 'liked';
|
||||||
if (isLiked) likeButton.classList.add('is-liked');
|
if (isLiked) likeButton.classList.add('is-liked');
|
||||||
likeButton.innerHTML = `
|
likeButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">✦</span>
|
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||||
<span class="channel-action-label">${isPending ? 'Сияние...' : 'Сияние'}</span>
|
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||||
`;
|
`;
|
||||||
likeButton.disabled = isPending;
|
likeButton.disabled = isPending;
|
||||||
likeButton.addEventListener('click', async (event) => {
|
likeButton.addEventListener('click', async (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
|
if (!isLiked) {
|
||||||
|
const ok = window.confirm('Поставить лайк?');
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
revealCounters();
|
revealCounters();
|
||||||
await longPressFeel(event.currentTarget, 130);
|
await longPressFeel(event.currentTarget, 130);
|
||||||
likeButton.disabled = true;
|
likeButton.disabled = true;
|
||||||
const labelEl = likeButton.querySelector('.channel-action-label');
|
const labelEl = likeButton.querySelector('.channel-action-label');
|
||||||
if (labelEl) labelEl.textContent = 'Сияние...';
|
if (labelEl) labelEl.textContent = 'Лайк...';
|
||||||
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -724,7 +749,7 @@ function renderPostCard(post, {
|
|||||||
replyButton.className = 'channel-action-item channel-action-reply';
|
replyButton.className = 'channel-action-item channel-action-reply';
|
||||||
replyButton.innerHTML = `
|
replyButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">⟳</span>
|
<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) => {
|
replyButton.addEventListener('click', (event) => {
|
||||||
animatePress(event.currentTarget);
|
animatePress(event.currentTarget);
|
||||||
@ -754,7 +779,7 @@ function renderPostCard(post, {
|
|||||||
shareButton.className = 'channel-action-item channel-action-share';
|
shareButton.className = 'channel-action-item channel-action-share';
|
||||||
shareButton.innerHTML = `
|
shareButton.innerHTML = `
|
||||||
<span class="channel-action-icon" aria-hidden="true">↗</span>
|
<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) => {
|
shareButton.addEventListener('click', async (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -816,25 +841,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
actionButton.className = channelData.isOwnChannel
|
actionButton.className = channelData.isOwnChannel
|
||||||
? 'primary-btn channel-main-action'
|
? 'primary-btn channel-main-action'
|
||||||
: 'destructive-btn channel-main-action';
|
: 'destructive-btn channel-main-action';
|
||||||
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Отписаться от канала';
|
actionButton.textContent = channelData.isOwnChannel ? 'Добавить сообщение' : 'Подписаться на канал';
|
||||||
|
|
||||||
const feed = document.createElement('div');
|
const feed = document.createElement('div');
|
||||||
feed.className = 'stack channel-feed';
|
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 postsByKey = new Map();
|
||||||
const unreadKeys = new Set();
|
|
||||||
let seenFlushInFlight = false;
|
|
||||||
let seenObserver = null;
|
|
||||||
|
|
||||||
if (channelData.posts.length) {
|
if (channelData.posts.length) {
|
||||||
channelData.posts.forEach((post) => {
|
channelData.posts.forEach((post) => {
|
||||||
@ -849,7 +860,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const key = messageRefKey(post.messageRef);
|
const key = messageRefKey(post.messageRef);
|
||||||
if (key) {
|
if (key) {
|
||||||
postsByKey.set(key, post);
|
postsByKey.set(key, post);
|
||||||
if (post.seenByMe !== true) unreadKeys.add(key);
|
|
||||||
}
|
}
|
||||||
feed.append(row);
|
feed.append(row);
|
||||||
});
|
});
|
||||||
@ -860,101 +870,6 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
feed.append(empty);
|
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) {
|
if (channelData.isOwnChannel) {
|
||||||
actionButton.addEventListener('click', (event) => {
|
actionButton.addEventListener('click', (event) => {
|
||||||
@ -965,7 +880,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
actionButton.addEventListener('click', handlers.onUnfollowChannel);
|
actionButton.addEventListener('click', handlers.onSubscribeChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const backButton = document.createElement('button');
|
const backButton = document.createElement('button');
|
||||||
@ -973,57 +888,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
backButton.textContent = 'Назад к каналам';
|
backButton.textContent = 'Назад к каналам';
|
||||||
backButton.addEventListener('click', () => navigate('channels-list'));
|
backButton.addEventListener('click', () => navigate('channels-list'));
|
||||||
|
|
||||||
screen.append(head, actionButton, feed, backButton, unreadJump);
|
screen.append(head, actionButton, feed, backButton);
|
||||||
|
|
||||||
if (state.session.login && channelData.selector && channelData.posts.length) {
|
|
||||||
seenObserver = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.6) return;
|
|
||||||
const key = String(entry.target?.dataset?.messageKey || '').trim();
|
|
||||||
if (!key) return;
|
|
||||||
const post = postsByKey.get(key);
|
|
||||||
if (!post || post.seenByMe === true) return;
|
|
||||||
routePending.add(key);
|
|
||||||
seenObserver?.unobserve(entry.target);
|
|
||||||
});
|
|
||||||
if (routePending.size) scheduleSeenFlush();
|
|
||||||
}, {
|
|
||||||
root: document.getElementById('app-screen') || null,
|
|
||||||
threshold: [0.6],
|
|
||||||
});
|
|
||||||
|
|
||||||
feed.querySelectorAll('[data-message-key]').forEach((node) => {
|
|
||||||
const key = String(node.dataset.messageKey || '').trim();
|
|
||||||
if (key && unreadKeys.has(key)) seenObserver?.observe(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
syncUnreadState();
|
|
||||||
mountUnreadDivider();
|
|
||||||
updateUnreadJump();
|
|
||||||
|
|
||||||
const firstUnreadCandidate = channelData.firstUnreadKey
|
|
||||||
|| (() => {
|
|
||||||
const first = channelData.posts.find((post) => post.seenByMe !== true);
|
|
||||||
return messageRefKey(first?.messageRef);
|
|
||||||
})();
|
|
||||||
if (firstUnreadCandidate) {
|
|
||||||
const previous = firstUnreadJumpByRoute.get(routeKey);
|
|
||||||
if (previous !== firstUnreadCandidate) {
|
|
||||||
pendingScrollByRoute.set(routeKey, firstUnreadCandidate);
|
|
||||||
firstUnreadJumpByRoute.set(routeKey, firstUnreadCandidate);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
firstUnreadJumpByRoute.delete(routeKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyPendingScroll(screen, routeKey);
|
applyPendingScroll(screen, routeKey);
|
||||||
return () => {
|
return () => {
|
||||||
seenObserver?.disconnect();
|
// noop
|
||||||
const timer = seenFlushTimersByRoute.get(routeKey);
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
seenFlushTimersByRoute.delete(routeKey);
|
|
||||||
seenPendingByRoute.delete(routeKey);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1070,7 +939,9 @@ export function render({ navigate, route }) {
|
|||||||
const login = state.session.login;
|
const login = state.session.login;
|
||||||
const storagePwd = state.session.storagePwdInMemory;
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
if (!login || !storagePwd) {
|
if (!login || !storagePwd) {
|
||||||
throw new Error('Сессия недействительна. Выполните вход заново.');
|
state.authReturnHash = window.location.hash || '#/channels-list';
|
||||||
|
navigate('login-view');
|
||||||
|
throw new Error('Для этого действия нужно войти');
|
||||||
}
|
}
|
||||||
return { login, storagePwd };
|
return { login, storagePwd };
|
||||||
};
|
};
|
||||||
@ -1117,21 +988,6 @@ export function render({ navigate, route }) {
|
|||||||
rerender();
|
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) => {
|
const onShare = async (routePath) => {
|
||||||
try {
|
try {
|
||||||
const routeToShare = String(routePath || '').trim();
|
const routeToShare = String(routePath || '').trim();
|
||||||
@ -1145,7 +1001,7 @@ export function render({ navigate, route }) {
|
|||||||
if (result === 'shared') showToast('Ссылка передана');
|
if (result === 'shared') showToast('Ссылка передана');
|
||||||
if (result === 'shared' || result === 'copied') softHaptic(10);
|
if (result === 'shared' || result === 'copied') softHaptic(10);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showStatus(toUserMessage(error, 'Не удалось транслировать ссылку.'));
|
showStatus(toUserMessage(error, 'Не удалось отправить ссылку.'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1237,11 +1093,14 @@ export function render({ navigate, route }) {
|
|||||||
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
|
throw new Error(toUserMessage(error, 'Не удалось сохранить описание.'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onUnfollowChannel: async (event) => {
|
onSubscribeChannel: async (event) => {
|
||||||
animatePress(event?.currentTarget);
|
animatePress(event?.currentTarget);
|
||||||
try {
|
try {
|
||||||
const { login, storagePwd } = requireSigningSession();
|
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({
|
await authService.addBlockFollowChannel({
|
||||||
login,
|
login,
|
||||||
@ -1249,26 +1108,15 @@ export function render({ navigate, route }) {
|
|||||||
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
targetBlockchainName: apiData.selector.ownerBlockchainName,
|
||||||
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
targetBlockNumber: apiData.selector.channelRootBlockNumber,
|
||||||
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
targetBlockHashHex: apiData.selector.channelRootBlockHash,
|
||||||
unfollow: true,
|
unfollow: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
softHaptic(15);
|
softHaptic(15);
|
||||||
showToast('Отписка от канала выполнена');
|
showToast('Подписка на канал выполнена');
|
||||||
navigate('channels-list');
|
|
||||||
} catch (error) {
|
} 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) {
|
} catch (error) {
|
||||||
skeleton.remove();
|
skeleton.remove();
|
||||||
|
|||||||
@ -40,6 +40,11 @@ function normalizeLoginInput(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelRouteFromSummary(summary, fallbackId) {
|
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 ownerBch = summary?.channel?.ownerBlockchainName;
|
||||||
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
|
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
|
||||||
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
|
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
|
||||||
@ -406,6 +411,117 @@ function openSimpleSubscribeModal({ kind, kindLabel, submitLabel, unfollow = fal
|
|||||||
if (inputEl) inputEl.focus();
|
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() {
|
function mapMockGroups() {
|
||||||
const mapRow = (channel) => ({
|
const mapRow = (channel) => ({
|
||||||
...channel,
|
...channel,
|
||||||
@ -527,6 +643,16 @@ function toListModel(groups) {
|
|||||||
function renderEmptyState(activeTab, navigate) {
|
function renderEmptyState(activeTab, navigate) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent';
|
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;
|
return wrap;
|
||||||
}
|
}
|
||||||
@ -896,14 +1022,9 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tab === 'authors') {
|
if (tab === 'authors') {
|
||||||
button.textContent = 'Подписаться на автора';
|
button.textContent = '🔍 Поиск каналов';
|
||||||
button.className = baseClass;
|
button.className = baseClass;
|
||||||
button.onclick = () => openSimpleSubscribeModal({
|
button.onclick = () => openChannelFinderModal({ navigate });
|
||||||
kind: 'user',
|
|
||||||
kindLabel: 'Подписка на автора',
|
|
||||||
submitLabel: 'Подписаться',
|
|
||||||
onSuccess: onReload,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -954,7 +1075,7 @@ export function render({ navigate }) {
|
|||||||
const notificationsState = readChannelNotificationsState();
|
const notificationsState = readChannelNotificationsState();
|
||||||
|
|
||||||
const listState = {
|
const listState = {
|
||||||
activeTab: 'my',
|
activeTab: 'subscriptions',
|
||||||
openMenuId: null,
|
openMenuId: null,
|
||||||
notificationsState,
|
notificationsState,
|
||||||
revealedCounters: new Set(),
|
revealedCounters: new Set(),
|
||||||
@ -1001,8 +1122,8 @@ export function render({ navigate }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
|
{ key: 'subscriptions', label: 'Каналы' },
|
||||||
{ key: 'my', label: 'Мои' },
|
{ key: 'my', label: 'Мои' },
|
||||||
{ key: 'subscriptions', label: 'Подписки' },
|
|
||||||
{ key: 'authors', label: 'Авторы' },
|
{ key: 'authors', label: 'Авторы' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -130,7 +130,13 @@ export function render({ navigate }) {
|
|||||||
setAuthInfo(isLoginFlow
|
setAuthInfo(isLoginFlow
|
||||||
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
? `Ключи сохранены. Вы вошли как @${state.registrationDraft.login}. Далее откройте вкладку «Каналы».`
|
||||||
: `Ключи сохранены. Регистрация завершена для @${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) {
|
} catch (error) {
|
||||||
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
const message = toUserMessage(error, 'Не удалось сохранить ключи на устройстве.');
|
||||||
setAuthError(message);
|
setAuthError(message);
|
||||||
|
|||||||
@ -50,6 +50,42 @@ export function getRoute() {
|
|||||||
return { pageId, params: { channelId: dynamicId || '' } };
|
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') {
|
if (pageId === 'channel-thread-view') {
|
||||||
return {
|
return {
|
||||||
pageId,
|
pageId,
|
||||||
|
|||||||
@ -740,7 +740,12 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
|
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();
|
const cleanLogin = String(login || '').trim();
|
||||||
if (cleanLogin) payload.login = cleanLogin;
|
if (cleanLogin) payload.login = cleanLogin;
|
||||||
const response = await this.ws.request('GetChannelMessages', payload);
|
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 = '') {
|
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();
|
const cleanLogin = String(login || '').trim();
|
||||||
if (cleanLogin) payload.login = cleanLogin;
|
if (cleanLogin) payload.login = cleanLogin;
|
||||||
const response = await this.ws.request('GetMessageThread', payload);
|
const response = await this.ws.request('GetMessageThread', payload);
|
||||||
@ -757,17 +767,6 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
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 }) {
|
async addBlockSigned({ login, storagePwd, msgType, msgSubType, msgVersion = 1, bodyBytes }) {
|
||||||
const cleanLogin = (login || '').trim();
|
const cleanLogin = (login || '').trim();
|
||||||
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
if (!cleanLogin) throw new Error('Missing login for AddBlock');
|
||||||
|
|||||||
@ -252,6 +252,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
error: '',
|
error: '',
|
||||||
info: '',
|
info: '',
|
||||||
},
|
},
|
||||||
|
authReturnHash: '',
|
||||||
sessions: [],
|
sessions: [],
|
||||||
channelsFeed: null,
|
channelsFeed: null,
|
||||||
channelsIndex: {},
|
channelsIndex: {},
|
||||||
|
|||||||
@ -360,35 +360,7 @@ public final class DatabaseInitializer {
|
|||||||
ON message_stats (to_login);
|
ON message_stats (to_login);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
// 8.0) message_views_state (уникальный просмотр/прочтение сообщения пользователем)
|
// 8.0) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_views_state (
|
|
||||||
viewer_login TEXT NOT NULL,
|
|
||||||
to_bch_name TEXT NOT NULL,
|
|
||||||
to_block_number INTEGER NOT NULL,
|
|
||||||
to_block_hash BLOB NOT NULL,
|
|
||||||
first_seen_at_ms INTEGER NOT NULL,
|
|
||||||
|
|
||||||
UNIQUE (
|
|
||||||
viewer_login,
|
|
||||||
to_bch_name,
|
|
||||||
to_block_number,
|
|
||||||
to_block_hash
|
|
||||||
)
|
|
||||||
);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_views_state_target
|
|
||||||
ON message_views_state (to_bch_name, to_block_number, to_block_hash);
|
|
||||||
""");
|
|
||||||
|
|
||||||
st.executeUpdate("""
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_views_state_viewer_channel
|
|
||||||
ON message_views_state (viewer_login, to_bch_name);
|
|
||||||
""");
|
|
||||||
|
|
||||||
// 8.1) reactions_state (идемпотентный LIKE/UNLIKE per actor/target)
|
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS reactions_state (
|
CREATE TABLE IF NOT EXISTS reactions_state (
|
||||||
from_login TEXT NOT NULL,
|
from_login TEXT NOT NULL,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import java.sql.Statement;
|
|||||||
public final class SqliteDbController {
|
public final class SqliteDbController {
|
||||||
|
|
||||||
private static volatile SqliteDbController instance;
|
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;
|
private final String jdbcUrl;
|
||||||
|
|
||||||
@ -84,6 +84,7 @@ public final class SqliteDbController {
|
|||||||
private void applyMigration(int targetVersion) {
|
private void applyMigration(int targetVersion) {
|
||||||
switch (targetVersion) {
|
switch (targetVersion) {
|
||||||
case 1 -> migrateToV1();
|
case 1 -> migrateToV1();
|
||||||
|
case 2 -> migrateToV2();
|
||||||
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
|
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() {
|
private int getCurrentSchemaVersion() {
|
||||||
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
|
try (Connection c = DriverManager.getConnection(jdbcUrl)) {
|
||||||
if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) {
|
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 {
|
private static void createConnectionsStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
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 {
|
private static void ensureChannelNamesStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS channel_names_state (
|
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_GetChannelMessages_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_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_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_GetChannelMessages_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_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_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_GetUserConnectionsGraph_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_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("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
|
||||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||||
Map.entry("MarkChannelMessagesSeen", new Net_MarkChannelMessagesSeen_Handler()),
|
|
||||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_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("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
|
||||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||||
Map.entry("GetMessageThread", Net_GetMessageThread_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("ListContacts", Net_ListContacts_Request.class),
|
||||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_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 {
|
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
|
||||||
if (rootNumber == 0) return "";
|
if (rootNumber == 0) return "";
|
||||||
|
|
||||||
|
|||||||
@ -108,22 +108,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
item.setLikesCount(stats[0]);
|
item.setLikesCount(stats[0]);
|
||||||
item.setRepliesCount(stats[1]);
|
item.setRepliesCount(stats[1]);
|
||||||
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
|
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);
|
items.add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.setMessages(items);
|
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;
|
return resp;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("GetChannelMessages failed", e);
|
log.error("GetChannelMessages failed", e);
|
||||||
|
|||||||
@ -178,9 +178,6 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
node.setLikesCount(stats[0]);
|
node.setLikesCount(stats[0]);
|
||||||
node.setRepliesCount(stats[1]);
|
node.setRepliesCount(stats[1]);
|
||||||
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
|
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) {
|
if (row.lineCode != null && row.lineCode >= 0) {
|
||||||
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
|
||||||
ci.setOwnerBlockchainName(row.bchName);
|
ci.setOwnerBlockchainName(row.bchName);
|
||||||
@ -229,4 +226,3 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
|
|||||||
int msgSubType;
|
int msgSubType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -74,7 +74,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
|
|
||||||
row.setChannel(channelRef);
|
row.setChannel(channelRef);
|
||||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
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);
|
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||||
if (lastPost != null) {
|
if (lastPost != null) {
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import java.util.List;
|
|||||||
public class Net_GetChannelMessages_Response extends Net_Response {
|
public class Net_GetChannelMessages_Response extends Net_Response {
|
||||||
private Channel channel;
|
private Channel channel;
|
||||||
private List<MessageItem> messages = new ArrayList<>();
|
private List<MessageItem> messages = new ArrayList<>();
|
||||||
private int unreadCount;
|
|
||||||
private BlockRef firstUnreadMessageRef;
|
|
||||||
|
|
||||||
public Channel getChannel() { return channel; }
|
public Channel getChannel() { return channel; }
|
||||||
public void setChannel(Channel channel) { this.channel = 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 List<MessageItem> getMessages() { return messages; }
|
||||||
public void setMessages(List<MessageItem> messages) { this.messages = 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 {
|
public static class Channel {
|
||||||
private String ownerLogin;
|
private String ownerLogin;
|
||||||
@ -55,8 +48,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
private int likesCount;
|
private int likesCount;
|
||||||
private boolean likedByMe;
|
private boolean likedByMe;
|
||||||
private int repliesCount;
|
private int repliesCount;
|
||||||
private int viewCount;
|
|
||||||
private boolean seenByMe;
|
|
||||||
private int versionsTotal;
|
private int versionsTotal;
|
||||||
private List<VersionItem> versions = new ArrayList<>();
|
private List<VersionItem> versions = new ArrayList<>();
|
||||||
|
|
||||||
@ -84,12 +75,6 @@ public class Net_GetChannelMessages_Response extends Net_Response {
|
|||||||
public int getRepliesCount() { return repliesCount; }
|
public int getRepliesCount() { return repliesCount; }
|
||||||
public void setRepliesCount(int repliesCount) { this.repliesCount = 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 int getVersionsTotal() { return versionsTotal; }
|
||||||
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = 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