Compare commits

...

2 Commits

37 changed files with 1735 additions and 290 deletions

View File

@ -8,6 +8,12 @@
## Примечание ## Примечание
- Если внешний инструмент/интеграция требует английский формат, допускается английский, но рядом желательно дать краткое пояснение на русском. - Если внешний инструмент/интеграция требует английский формат, допускается английский, но рядом желательно дать краткое пояснение на русском.
## Документация блокчейна
- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`.
- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям.
- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`.
- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение.
## Версионирование ## Версионирование
- Единый файл версий проекта: `VERSION.properties` (в корне репозитория). - Единый файл версий проекта: `VERSION.properties` (в корне репозитория).
- Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`: - Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`:
@ -18,6 +24,8 @@
## Deploy ## Deploy
- Все документы и заметки по деплою хранить в папке `Deploy Server/`. - Все документы и заметки по деплою хранить в папке `Deploy Server/`.
- Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`. - Для сервера `VPS-05` (`45.136.124.227`) доступ выполнять через пользователя `player`.
- Базовый целевой хост для деплоя по умолчанию: `player@45.136.124.227`.
- Базовый путь на сервере для SHiNE: `/home/player` (проекты SHiNE размещать в `/home/player/SHiNE/...`).
- По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке. - По возможности все справки, комментарии и примечания в конфигах/документах писать на русском языке.
- Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`). - Деплой UI выполнять только в один целевой контур за запуск: либо `prod` (основной), либо один выбранный тестовый (`ui_1`, `ui_2`, `ui_3`, `ui_drygmira`, `ui_milana`, `ui_aidar`).
- Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен. - Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен.

View File

@ -0,0 +1,36 @@
# Типы каналов и CreateChannel
## 1. Формат `CreateChannelBody`
Формат `TECH_CREATE_CHANNEL` поддерживает единственный текущий `version=1` и включает:
1. line-поля канала (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`);
2. `channelName`;
3. `channelDescription`;
4. `channelType` (`uint16`, 2 байта);
5. `channelTypeVersion` (`uint16`, 2 байта).
## 2. Типы каналов
- `0``stories` (root-канал пользователя).
- `1` — публичный канал.
- `100` — персональный канал.
- `200` — групповой/чатовый канал.
Версия типа (`channelTypeVersion`) сейчас используется со значением `1`.
## 3. Имя root-канала
- Root-канал (`line_code = 0`) в API/чтении отображается как `stories`.
- Публикации в `stories` разрешены владельцу собственного блокчейна.
## 4. Уникальность имени канала
Проверка уникальности выполняется на сервере по ключу:
`owner_bch_name + channel_type_code + slug(channel_name)`
Это означает:
- одно и то же имя допустимо у одного владельца для разных типов (`1`, `100`, `200`);
- в рамках одного типа и одного владельца имя уникально.
## 5. Персональные каналы (`type=100`)
- Сервер не проверяет бизнес-валидность собеседника по имени канала.
- Проверка существования login для персонального канала выполняется на UI при создании.
- При чтении сервер пытается собрать парный поток `A->B` + `B->A` (если обратный канал существует).

View File

@ -0,0 +1,29 @@
# Командные сообщения каналов
## 1. Общий префикс
Командные сообщения распознаются по префиксу:
`/.`
Пример:
- `/.desc Новый комментарий канала`
## 2. Поддерживаемые команды
### Для всех типов каналов (`0`, `1`, `100`, `200`)
- `/.desc <text>` — смена описания канала.
Примечание:
- Описание канала в чтении определяется последней командой `/.desc` в линии канала.
- Если `/.desc` не было, используется описание из `CreateChannel`.
### Дополнительно для `type=200`
- `/.add <login> <channelName>`
- `/.remove <login> <channelName>`
Формат аргументов фиксирован: через пробел.
## 3. Текущая модель применения
- Команды передаются как обычные `TEXT_POST` сообщения.
- Сервер уже применяет `/.desc` при вычислении актуального описания канала.
- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации.

View File

@ -0,0 +1,12 @@
# История изменений документации блокчейна
## 2026-05-13 00:02:32 +0300
- Базовый коммит-ориентир: `f63f40f1eb2f`.
- Добавлен текущий формат `CreateChannelBody` с полями `channelType (2 байта)` и `channelTypeVersion (2 байта)`.
- Зафиксированы типы каналов: `0=stories`, `1=public`, `100=personal`, `200=group`.
- Серверная уникальность имени канала изменена на `owner + type + name(slug)`.
- Root-канал `0` переименован в `stories` на уровне API-чтения.
- Для персонального канала (`type=100`) включена сборка парного потока при чтении (`A->B` + `B->A`, если существует).
- Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении.
- Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками).
- В `AGENTS.md` добавлено обязательное правило актуализации документации в `Dev_Docs/Blockchain/`.

View File

@ -0,0 +1,16 @@
# Blockchain Docs (Актуально)
## Назначение
Этот набор файлов — актуальная документация по текущему формату блокчейна SHiNE для каналов, их типов и командных сообщений.
## Оглавление
1. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md)
Текущий формат `CreateChannelBody`, типы каналов, уникальность имён и правила `stories`.
2. [02_Channel_Commands.md](./02_Channel_Commands.md)
Командные сообщения в каналах: `/.desc`, `/.add`, `/.remove`.
3. [CHANGELOG.md](./CHANGELOG.md)
История изменений документации и блокчейн-правил.
## Обязательное правило сопровождения
- Любое изменение блокчейн-кода (форматы, типы, правила чтения/записи, команды) должно сопровождаться обновлением файлов из этого каталога.
- Изменение обязательно фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания.

View File

@ -1,2 +1,2 @@
client.version=1.2.44 client.version=1.2.45
server.version=1.2.38 server.version=1.2.39

View File

@ -8,6 +8,12 @@ const ITEMS = [
{ pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' }, { pageId: 'notifications-view', label: 'Уведомления', icon: '🔔' },
{ pageId: 'profile-view', label: 'Профиль', icon: '👤' }, { pageId: 'profile-view', label: 'Профиль', icon: '👤' },
]; ];
const CHANNEL_HOLD_MS = 260;
const CHANNEL_MODES = Object.freeze([
{ key: 'feed', label: 'Каналы' },
{ key: 'dialogs', label: 'Чаты' },
{ key: 'my', label: 'Мои' },
]);
function getTotalUnreadMessages() { function getTotalUnreadMessages() {
const chats = Object.values(state.chats || {}); const chats = Object.values(state.chats || {});
@ -54,9 +60,99 @@ export function renderToolbar(currentPageId, navigate) {
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`); badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
btn.append(badge); btn.append(badge);
} }
btn.addEventListener('click', () => navigate(item.pageId)); if (item.pageId === 'channels-list') {
installChannelsHoldSwitcher(btn, navigate);
} else {
btn.addEventListener('click', () => navigate(item.pageId));
}
root.append(btn); root.append(btn);
}); });
return root; return root;
} }
function installChannelsHoldSwitcher(button, navigate) {
let holdTimer = 0;
let pressed = false;
let holdActive = false;
let overlay = null;
let selectedMode = 'dialogs';
const clearTimer = () => {
if (holdTimer) {
window.clearTimeout(holdTimer);
holdTimer = 0;
}
};
const closeOverlay = () => {
if (overlay) overlay.remove();
overlay = null;
holdActive = false;
};
const setSelectedModeByX = (clientX) => {
if (!overlay) return;
const rect = overlay.getBoundingClientRect();
const part = rect.width / 3;
const localX = Math.max(0, Math.min(rect.width - 1, clientX - rect.left));
const index = Math.max(0, Math.min(2, Math.floor(localX / Math.max(1, part))));
selectedMode = CHANNEL_MODES[index].key;
const buttons = overlay.querySelectorAll('.toolbar-channels-hold-item');
buttons.forEach((el, idx) => {
el.classList.toggle('is-active', idx === index);
});
};
const openOverlay = () => {
const rect = button.getBoundingClientRect();
overlay = document.createElement('div');
overlay.className = 'toolbar-channels-hold-overlay';
overlay.innerHTML = CHANNEL_MODES.map((mode) => (
`<button type="button" class="toolbar-channels-hold-item${mode.key === selectedMode ? ' is-active' : ''}" data-mode="${mode.key}">${mode.label}</button>`
)).join('');
overlay.style.left = `${Math.round(rect.left + rect.width / 2)}px`;
overlay.style.top = `${Math.round(rect.top - 12)}px`;
document.body.append(overlay);
holdActive = true;
};
button.addEventListener('pointerdown', (event) => {
pressed = true;
holdActive = false;
selectedMode = 'dialogs';
clearTimer();
holdTimer = window.setTimeout(() => {
if (!pressed) return;
openOverlay();
setSelectedModeByX(event.clientX);
}, CHANNEL_HOLD_MS);
});
button.addEventListener('pointermove', (event) => {
if (holdActive) setSelectedModeByX(event.clientX);
});
button.addEventListener('pointerup', () => {
clearTimer();
const wasHold = holdActive;
const mode = selectedMode;
pressed = false;
closeOverlay();
if (wasHold) {
navigate(`channels-list/${mode}`);
return;
}
navigate('channels-list/dialogs');
});
button.addEventListener('pointercancel', () => {
clearTimer();
pressed = false;
closeOverlay();
});
button.addEventListener('contextmenu', (event) => {
event.preventDefault();
});
}

View File

@ -11,6 +11,9 @@ import {
export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' }; export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
function persistCreateSuccessFlash(message) { function persistCreateSuccessFlash(message) {
try { try {
@ -45,7 +48,15 @@ export function render({ navigate }) {
form.innerHTML = ` form.innerHTML = `
<strong class="channel-head-title">Создание канала</strong> <strong class="channel-head-title">Создание канала</strong>
<p class="channel-head-meta">Можно использовать только латиницу, цифры, _ и -.</p> <p class="channel-head-meta">Можно использовать только латиницу, цифры, _ и -.</p>
<p class="channel-head-meta">Длина названия: от 3 до 32 символов. Название уникально во всей системе.</p> <p class="channel-head-meta">Длина названия: от 3 до 32 символов.</p>
<label for="channel-type">Тип канала</label>
<select id="channel-type" class="input">
<option value="${CHANNEL_TYPE_PUBLIC}">Публичный (1)</option>
<option value="${CHANNEL_TYPE_PERSONAL}">Персональный (100)</option>
<option value="${CHANNEL_TYPE_GROUP}">Групповой (200)</option>
</select>
<div id="channel-type-hint" class="meta-muted">Публичный канал видят все. Писать может только владелец.</div>
<label for="channel-name">Название канала</label> <label for="channel-name">Название канала</label>
<input id="channel-name" class="input" maxlength="32" placeholder="Например: my_channel-1" required /> <input id="channel-name" class="input" maxlength="32" placeholder="Например: my_channel-1" required />
@ -64,6 +75,8 @@ export function render({ navigate }) {
`; `;
const nameEl = form.querySelector('#channel-name'); const nameEl = form.querySelector('#channel-name');
const typeEl = form.querySelector('#channel-type');
const typeHintEl = form.querySelector('#channel-type-hint');
const descriptionEl = form.querySelector('#channel-description'); const descriptionEl = form.querySelector('#channel-description');
const nameErrorEl = form.querySelector('#channel-name-error'); const nameErrorEl = form.querySelector('#channel-name-error');
const descriptionErrorEl = form.querySelector('#channel-description-error'); const descriptionErrorEl = form.querySelector('#channel-description-error');
@ -79,10 +92,27 @@ export function render({ navigate }) {
submitEl.disabled = submitInFlight; submitEl.disabled = submitInFlight;
cancelEl.disabled = submitInFlight; cancelEl.disabled = submitInFlight;
nameEl.disabled = submitInFlight; nameEl.disabled = submitInFlight;
typeEl.disabled = submitInFlight;
descriptionEl.disabled = submitInFlight; descriptionEl.disabled = submitInFlight;
submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать'; submitEl.textContent = submitInFlight ? 'Создаём...' : 'Создать';
}; };
const updateTypeHint = () => {
const typeCode = Number(typeEl.value || CHANNEL_TYPE_PUBLIC);
if (typeCode === CHANNEL_TYPE_PERSONAL) {
typeHintEl.textContent = 'Для персонального канала название должно быть login собеседника.';
nameEl.placeholder = 'Например: aidar';
return;
}
if (typeCode === CHANNEL_TYPE_GROUP) {
typeHintEl.textContent = 'Для группового канала участников добавляют командами /.add и /.remove.';
nameEl.placeholder = 'Например: team_room';
return;
}
typeHintEl.textContent = 'Публичный канал видят все. Писать может только владелец.';
nameEl.placeholder = 'Например: my_channel-1';
};
const updateValidation = () => { const updateValidation = () => {
const nameCheck = validateChannelDisplayName(nameEl.value); const nameCheck = validateChannelDisplayName(nameEl.value);
const descriptionCheck = validateDescription(descriptionEl.value); const descriptionCheck = validateDescription(descriptionEl.value);
@ -124,11 +154,22 @@ export function render({ navigate }) {
errorEl.textContent = ''; errorEl.textContent = '';
try { try {
const channelType = Number(typeEl.value || CHANNEL_TYPE_PUBLIC);
if (channelType === CHANNEL_TYPE_PERSONAL) {
const targetLogin = normalizeChannelDisplayName(check.name);
const foundUser = await authService.getUser(targetLogin);
if (!foundUser?.exists) {
throw new Error('Логин для персонального канала не найден.');
}
}
await authService.addBlockCreateChannel({ await authService.addBlockCreateChannel({
login, login,
storagePwd, storagePwd,
channelName: normalizeChannelDisplayName(check.name), channelName: normalizeChannelDisplayName(check.name),
channelDescription: normalizeChannelDescription(check.description), channelDescription: normalizeChannelDescription(check.description),
channelType,
channelTypeVersion: 1,
}); });
persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`); persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`);
@ -141,9 +182,11 @@ export function render({ navigate }) {
}); });
cancelEl.addEventListener('click', () => navigate('channels-list')); cancelEl.addEventListener('click', () => navigate('channels-list'));
typeEl.addEventListener('change', updateTypeHint);
screen.append(form); screen.append(form);
nameEl.focus(); nameEl.focus();
updateTypeHint();
updateValidation(); updateValidation();
return screen; return screen;
} }

View File

@ -518,6 +518,7 @@ function renderPostCard(post, {
navigate, navigate,
routeKey, routeKey,
selector, selector,
canWrite,
onToggleLike, onToggleLike,
onReply, onReply,
onShare, onShare,
@ -579,52 +580,55 @@ function renderPostCard(post, {
if (!post.messageRef || !selector) return card; if (!post.messageRef || !selector) return card;
const actionKey = makeReactionActionKey(post.messageRef);
const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'channel-message-actions'; actions.className = 'channel-message-actions';
const likeButton = document.createElement('button'); if (canWrite) {
likeButton.type = 'button'; const actionKey = makeReactionActionKey(post.messageRef);
likeButton.className = 'channel-action-item channel-action-like'; const isPending = actionKey ? pendingReactionActions.has(actionKey) : false;
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<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 = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
});
const replyButton = document.createElement('button'); const likeButton = document.createElement('button');
replyButton.type = 'button'; likeButton.type = 'button';
replyButton.className = 'channel-action-item channel-action-reply'; likeButton.className = 'channel-action-item channel-action-like';
replyButton.innerHTML = ` const isLiked = post.reactionState === 'liked';
<span class="channel-action-icon" aria-hidden="true"></span> if (isLiked) likeButton.classList.add('is-liked');
<span class="channel-action-label">Ответить</span> likeButton.innerHTML = `
`; <span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
replyButton.addEventListener('click', (event) => { <span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
animatePress(event.currentTarget); <span class="channel-action-counter">${post.likesCount || 0}</span>
revealCounters(); `;
openReplyModal({ likeButton.disabled = isPending;
onSubmit: async (text) => onReply(post.messageRef, text), 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 = 'Лайк...';
await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton });
}); });
});
const replyButton = document.createElement('button');
replyButton.type = 'button';
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>
`;
replyButton.addEventListener('click', (event) => {
animatePress(event.currentTarget);
revealCounters();
openReplyModal({
onSubmit: async (text) => onReply(post.messageRef, text),
});
});
actions.append(likeButton, replyButton);
}
const openThreadButton = document.createElement('button'); const openThreadButton = document.createElement('button');
openThreadButton.type = 'button'; openThreadButton.type = 'button';
@ -656,7 +660,7 @@ function renderPostCard(post, {
await onShare(route); await onShare(route);
}); });
actions.append(likeButton, replyButton, openThreadButton, shareButton); actions.append(openThreadButton, shareButton);
card.append(actions); card.append(actions);
return card; return card;
} }
@ -704,6 +708,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
navigate, navigate,
routeKey, routeKey,
selector: channelData.selector, selector: channelData.selector,
canWrite: channelData.isOwnChannel,
onToggleLike: handlers.onToggleLike, onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply, onReply: handlers.onReply,
onShare: handlers.onShare, onShare: handlers.onShare,

View File

@ -15,6 +15,9 @@ export const pageMeta = { id: 'channels-list', title: 'Каналы' };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const MENU_OVERLAY_ID = 'channels-context-menu-overlay'; const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const TAB_ORDER = ['feed', 'dialogs', 'my'];
function isChannelsDemoMode() { function isChannelsDemoMode() {
try { try {
@ -40,16 +43,18 @@ function normalizeLoginInput(value) {
} }
function buildChannelRouteFromSummary(summary, fallbackId) { function buildChannelRouteFromSummary(summary, fallbackId) {
const ownerBch = summary?.channel?.ownerBlockchainName;
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
if (ownerBch && rootBlockNumber != null) {
return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`;
}
const ownerLogin = String(summary?.channel?.ownerLogin || '').trim(); const ownerLogin = String(summary?.channel?.ownerLogin || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim(); const channelName = String(summary?.channel?.channelName || '').trim();
if (ownerLogin && channelName) { if (ownerLogin && channelName) {
return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`; return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`;
} }
const ownerBch = summary?.channel?.ownerBlockchainName; return `channel-view/${fallbackId}`;
const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber;
const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash);
if (!ownerBch || rootBlockNumber == null) return `channel-view/${fallbackId}`;
return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`;
} }
function avatarLetterFromName(name = '') { function avatarLetterFromName(name = '') {
@ -526,7 +531,11 @@ function mapMockGroups() {
const mapRow = (channel) => ({ const mapRow = (channel) => ({
...channel, ...channel,
route: `channel-view/${channel.id}`, route: `channel-view/${channel.id}`,
tabCategory: channel.kind === 'own' || channel.kind === 'own-personal' ? 'my' : 'subscriptions', tabCategory: channel.kind === 'own'
? 'my'
: channel.kind === 'own-personal'
? 'dialogs'
: 'feed',
messagePreview: channel.lastMessage || 'Ждем ваших начинаний', messagePreview: channel.lastMessage || 'Ждем ваших начинаний',
isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal', isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal',
isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal', isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal',
@ -545,11 +554,11 @@ function mapMockGroups() {
const followedUserChannels = mockChannels const followedUserChannels = mockChannels
.filter((channel) => channel.kind === 'followed-user-channel') .filter((channel) => channel.kind === 'followed-user-channel')
.map((item) => ({ ...mapRow(item), tabCategory: 'authors' })); .map((item) => ({ ...mapRow(item), tabCategory: 'feed' }));
const subscribedChannels = mockChannels const subscribedChannels = mockChannels
.filter((channel) => channel.kind === 'subscribed') .filter((channel) => channel.kind === 'subscribed')
.map((item) => ({ ...mapRow(item), tabCategory: 'subscriptions' })); .map((item) => ({ ...mapRow(item), tabCategory: 'feed' }));
return { ownChannels, followedUserChannels, subscribedChannels, index: {} }; return { ownChannels, followedUserChannels, subscribedChannels, index: {} };
} }
@ -561,18 +570,14 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно'; const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно';
const channelName = summary?.channel?.channelName || '(без названия)'; const channelName = summary?.channel?.channelName || '(без названия)';
const channelDescription = String(summary?.channel?.channelDescription || '').trim(); const channelDescription = String(summary?.channel?.channelDescription || '').trim();
const channelTypeCode = Number(summary?.channel?.channelTypeCode ?? 1);
const channelTypeVersion = Number(summary?.channel?.channelTypeVersion ?? 1);
const isOwn = bucketKey === 'own'; const isOwn = bucketKey === 'own';
const tabCategory = bucketKey === 'own' const tabCategory = isOwn
? 'my' ? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my')
: bucketKey === 'followedUsers' : 'feed';
? 'authors'
: 'subscriptions';
const title = isOwn const title = isOwn ? channelName : `${ownerLogin}/${channelName}`;
? channelName
: tabCategory === 'authors'
? channelName
: `${ownerLogin}/${channelName}`;
return { return {
id: rowId, id: rowId,
@ -585,6 +590,8 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
title, title,
channelName, channelName,
channelDescription, channelDescription,
channelTypeCode,
channelTypeVersion,
messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний', messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний',
messagesCount: Number(summary?.messagesCount || 0), messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0), unreadCount: Number(summary?.unreadCount || 0),
@ -597,20 +604,6 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
}; };
} }
function isSyntheticDefaultChannel(row) {
if (!row || !row.isOwnChannel) return false;
const name = String(row.channelName || '').trim();
if (name !== '0') return false;
const hasDescription = Boolean(String(row.channelDescription || '').trim());
const hasMessages = Number(row.messagesCount || 0) > 0;
const hasTimestamp = Number(row.lastMessageAt || 0) > 0;
const preview = String(row.messagePreview || '').trim();
const hasCustomPreview = preview && preview !== 'Ждем ваших начинаний';
return !hasDescription && !hasMessages && !hasTimestamp && !hasCustomPreview;
}
function pullCreateSuccessFlash() { function pullCreateSuccessFlash() {
try { try {
const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim(); const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim();
@ -624,8 +617,7 @@ function pullCreateSuccessFlash() {
function mapApiFeed(feed, notificationsState) { function mapApiFeed(feed, notificationsState) {
const index = {}; const index = {};
const ownChannels = (feed?.ownedChannels || []) const ownChannels = (feed?.ownedChannels || [])
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState)) .map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState));
.filter((row) => !isSyntheticDefaultChannel(row));
const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState)); const followedUserChannels = (feed?.followedUsersChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index, notificationsState)); const subscribedChannels = (feed?.followedChannels || []).map((it, idx) => mapApiChannelRow(it, 'followedChannels', idx, index, notificationsState));
@ -645,12 +637,14 @@ function renderEmptyState(activeTab, navigate) {
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'); const text = document.createElement('p');
text.className = 'meta-muted'; text.className = 'meta-muted';
if (activeTab === 'subscriptions') { if (activeTab === 'feed') {
text.textContent = 'Вы пока не подписаны на каналы.'; text.textContent = 'Нет подписок и найденных каналов.';
} else if (activeTab === 'dialogs') {
text.textContent = 'У вас пока нет персональных каналов.';
} else if (activeTab === 'my') { } else if (activeTab === 'my') {
text.textContent = 'У вас пока нет каналов.'; text.textContent = 'У вас пока нет каналов.';
} else { } else {
text.textContent = ока нет каналов авторов.'; text.textContent = усто.';
} }
wrap.append(text); wrap.append(text);
@ -882,7 +876,7 @@ function renderChannelMain(channel, activeTab) {
const main = document.createElement('div'); const main = document.createElement('div');
main.className = 'channel-row-main'; main.className = 'channel-row-main';
if (activeTab === 'authors') { if (activeTab === 'feed') {
const author = document.createElement('p'); const author = document.createElement('p');
author.className = 'channel-row-author'; author.className = 'channel-row-author';
author.textContent = `@${channel.ownerName}`; author.textContent = `@${channel.ownerName}`;
@ -907,7 +901,7 @@ function renderChannelMain(channel, activeTab) {
title.className = 'channel-row-title'; title.className = 'channel-row-title';
title.textContent = activeTab === 'my' ? channel.channelName : channel.title; title.textContent = activeTab === 'my' ? channel.channelName : channel.title;
if (activeTab === 'my' && channel.channelDescription) { if ((activeTab === 'my' || activeTab === 'dialogs') && channel.channelDescription) {
const desc = document.createElement('p'); const desc = document.createElement('p');
desc.className = 'channel-row-description'; desc.className = 'channel-row-description';
desc.textContent = channel.channelDescription; desc.textContent = channel.channelDescription;
@ -1009,7 +1003,7 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
const tab = listState.activeTab; const tab = listState.activeTab;
const baseClass = `primary-btn channels-bottom-action${isTabEmpty && tab !== 'my' ? ' is-empty-lift' : ''}`; const baseClass = `primary-btn channels-bottom-action${isTabEmpty && tab !== 'my' ? ' is-empty-lift' : ''}`;
if (tab === 'subscriptions') { if (tab === 'feed') {
button.textContent = 'Подписаться на канал'; button.textContent = 'Подписаться на канал';
button.className = baseClass; button.className = baseClass;
button.onclick = () => openSimpleSubscribeModal({ button.onclick = () => openSimpleSubscribeModal({
@ -1021,16 +1015,23 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f
return; return;
} }
if (tab === 'authors') { if (tab === 'dialogs') {
button.textContent = '🔍 Поиск каналов'; button.textContent = 'Новый персональный канал';
button.className = baseClass; button.className = baseClass;
button.onclick = () => openChannelFinderModal({ navigate }); button.onclick = () => navigate('add-channel-view');
return; return;
} }
button.textContent = 'Создать канал'; if (tab === 'my') {
button.textContent = 'Создать канал';
button.className = baseClass;
button.onclick = () => navigate('add-channel-view');
return;
}
button.textContent = 'Поиск каналов';
button.className = baseClass; button.className = baseClass;
button.onclick = () => navigate('add-channel-view'); button.onclick = () => openChannelFinderModal({ navigate });
} }
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
@ -1065,7 +1066,7 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
} }
} }
export function render({ navigate }) { export function render({ navigate, route }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--list'; screen.className = 'stack channels-screen channels-screen--list';
const appScreen = document.getElementById('app-screen'); const appScreen = document.getElementById('app-screen');
@ -1075,7 +1076,9 @@ export function render({ navigate }) {
const notificationsState = readChannelNotificationsState(); const notificationsState = readChannelNotificationsState();
const listState = { const listState = {
activeTab: 'subscriptions', activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim())
? String(route?.params?.mode).trim()
: 'dialogs',
openMenuId: null, openMenuId: null,
notificationsState, notificationsState,
revealedCounters: new Set(), revealedCounters: new Set(),
@ -1083,9 +1086,6 @@ export function render({ navigate }) {
menuCleanup: null, menuCleanup: null,
}; };
const tabs = document.createElement('div');
tabs.className = 'channels-tabs channels-tabs--sticky';
const contentEl = document.createElement('div'); const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content'; contentEl.className = 'channels-list-content';
@ -1095,12 +1095,16 @@ export function render({ navigate }) {
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate }); const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
const rerenderList = () => { const rerenderList = () => {
const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab); try {
const expectedHash = `#/channels-list/${listState.activeTab}`;
if (window.location.hash !== expectedHash) {
window.history.replaceState({}, '', expectedHash);
}
} catch {
// ignore history errors
}
tabItems.forEach((tab) => { const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab);
const btn = tabs.querySelector(`[data-tab="${tab.key}"]`);
if (btn) btn.classList.toggle('is-active', tab.key === listState.activeTab);
});
closeChannelMenu(listState); closeChannelMenu(listState);
@ -1121,28 +1125,28 @@ export function render({ navigate }) {
}); });
}; };
const tabItems = [ let touchStartX = 0;
{ key: 'subscriptions', label: 'Каналы' }, let touchStartY = 0;
{ key: 'my', label: 'Мои' }, contentEl.addEventListener('touchstart', (event) => {
{ key: 'authors', label: 'Авторы' }, const p = event.changedTouches?.[0];
]; if (!p) return;
touchStartX = p.clientX;
touchStartY = p.clientY;
}, { passive: true });
contentEl.addEventListener('touchend', (event) => {
const p = event.changedTouches?.[0];
if (!p) return;
const dx = p.clientX - touchStartX;
const dy = p.clientY - touchStartY;
if (Math.abs(dx) < 45 || Math.abs(dx) < Math.abs(dy)) return;
const index = TAB_ORDER.indexOf(listState.activeTab);
if (index < 0) return;
if (dx < 0 && index < TAB_ORDER.length - 1) listState.activeTab = TAB_ORDER[index + 1];
if (dx > 0 && index > 0) listState.activeTab = TAB_ORDER[index - 1];
rerenderList();
}, { passive: true });
tabItems.forEach((tab) => { screen.append(contentEl, bottomCta);
const btn = document.createElement('button');
btn.type = 'button';
btn.dataset.tab = tab.key;
btn.className = `channels-tab-btn ${tab.key === listState.activeTab ? 'is-active' : ''}`;
btn.textContent = tab.label;
btn.addEventListener('click', () => {
if (listState.activeTab === tab.key) return;
listState.activeTab = tab.key;
animatePress(btn);
rerenderList();
});
tabs.append(btn);
});
screen.append(tabs, contentEl, bottomCta);
if (createSuccessFlash) { if (createSuccessFlash) {
showToast(createSuccessFlash); showToast(createSuccessFlash);

View File

@ -123,6 +123,15 @@ export function getRoute() {
}; };
} }
if (pageId === 'channels-list') {
return {
pageId,
params: {
mode: segments[1] ? decodePart(segments[1]) : '',
},
};
}
return { pageId, params: {} }; return { pageId, params: {} };
} }

View File

@ -42,7 +42,12 @@ const MSG_SUBTYPE_REACTION_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30;
const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31; const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31;
const CREATE_CHANNEL_BODY_VERSION = 2; const CREATE_CHANNEL_BODY_VERSION = 1;
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PUBLIC = 1;
const CHANNEL_TYPE_PERSONAL = 100;
const CHANNEL_TYPE_GROUP = 200;
const CHANNEL_TYPE_VERSION_DEFAULT = 1;
const CONNECTION_SUBTYPES = Object.freeze({ const CONNECTION_SUBTYPES = Object.freeze({
// Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11. // Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11.
@ -404,13 +409,15 @@ function normalizeChannelDescription(value) {
return text; return text;
} }
function makeCreateChannelBodyV2Bytes({ function makeCreateChannelBodyBytes({
lineCode, lineCode,
prevLineNumber, prevLineNumber,
prevLineHashHex, prevLineHashHex,
thisLineNumber, thisLineNumber,
channelName, channelName,
channelDescription = '', channelDescription = '',
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
}) { }) {
const check = validateChannelDisplayName(channelName); const check = validateChannelDisplayName(channelName);
if (!check.ok) throw new Error(channelNameErrorText(check.code)); if (!check.ok) throw new Error(channelNameErrorText(check.code));
@ -426,6 +433,14 @@ function makeCreateChannelBodyV2Bytes({
if (descriptionBytes.length > 200) { if (descriptionBytes.length > 200) {
throw new Error('Описание канала слишком длинное: максимум 200 символов.'); throw new Error('Описание канала слишком длинное: максимум 200 символов.');
} }
const typeCode = Number(channelType);
const typeVer = Number(channelTypeVersion);
if (!Number.isFinite(typeCode) || typeCode < 0 || typeCode > 65535) {
throw new Error('Некорректный тип канала.');
}
if (!Number.isFinite(typeVer) || typeVer < 0 || typeVer > 65535) {
throw new Error('Некорректная версия типа канала.');
}
return concatBytes( return concatBytes(
int32Bytes(lineCode), int32Bytes(lineCode),
@ -436,6 +451,8 @@ function makeCreateChannelBodyV2Bytes({
nameBytes, nameBytes,
int16Bytes(descriptionBytes.length), int16Bytes(descriptionBytes.length),
descriptionBytes, descriptionBytes,
uint16Bytes(typeCode),
uint16Bytes(typeVer),
); );
} }
@ -1004,11 +1021,19 @@ export class AuthService {
rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber), rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber),
rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64), rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64),
channelName: String(item?.channel?.channelName || ''), channelName: String(item?.channel?.channelName || ''),
channelTypeCode: Number(item?.channel?.channelTypeCode ?? CHANNEL_TYPE_PUBLIC),
})) }))
.filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0); .filter((item) => Number.isFinite(item.rootBlockNumber) && item.rootBlockNumber >= 0);
} }
async addBlockCreateChannel({ login, channelName, channelDescription = '', storagePwd }) { async addBlockCreateChannel({
login,
channelName,
channelDescription = '',
channelType = CHANNEL_TYPE_PUBLIC,
channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT,
storagePwd,
}) {
const cleanLogin = (login || '').trim(); const cleanLogin = (login || '').trim();
if (!cleanLogin) throw new Error('Missing login'); if (!cleanLogin) throw new Error('Missing login');
@ -1018,7 +1043,9 @@ export class AuthService {
const cleanChannelDescription = normalizeChannelDescription(channelDescription); const cleanChannelDescription = normalizeChannelDescription(channelDescription);
const channelSlug = toCanonicalChannelSlug(cleanChannelName); const channelSlug = toCanonicalChannelSlug(cleanChannelName);
const key = `create-channel:${cleanLogin}:${channelSlug || cleanChannelName.toLowerCase()}`; const typeCode = Number(channelType);
const typeVersion = Number(channelTypeVersion);
const key = `create-channel:${cleanLogin}:${typeCode}:${channelSlug || cleanChannelName.toLowerCase()}`;
return this.runWriteLocked(key, async () => { return this.runWriteLocked(key, async () => {
const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd);
const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
@ -1053,13 +1080,15 @@ export class AuthService {
msgType: MSG_TYPE_TECH, msgType: MSG_TYPE_TECH,
msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL, msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL,
msgVersion: CREATE_CHANNEL_BODY_VERSION, msgVersion: CREATE_CHANNEL_BODY_VERSION,
bodyBytes: makeCreateChannelBodyV2Bytes({ bodyBytes: makeCreateChannelBodyBytes({
lineCode: 0, lineCode: 0,
prevLineNumber, prevLineNumber,
prevLineHashHex, prevLineHashHex,
thisLineNumber, thisLineNumber,
channelName: cleanChannelName, channelName: cleanChannelName,
channelDescription: cleanChannelDescription, channelDescription: cleanChannelDescription,
channelType: typeCode,
channelTypeVersion: typeVersion,
}), }),
}); });
@ -1099,11 +1128,6 @@ export class AuthService {
if (ownerBlockchainName !== blockchainName) { if (ownerBlockchainName !== blockchainName) {
throw new Error('Posting is allowed only to your own channels'); throw new Error('Posting is allowed only to your own channels');
} }
// Канал 0 оставляем как технический root-поток.
// Контент-публикации в него временно отключены (пишем только в именованные каналы).
if (lineCode === 0) {
throw new Error('Публикации в канал 0 временно отключены. Создайте отдельный канал.');
}
let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64); let rootHashHex = normalizeHex32(selector?.channelRootBlockHash, ZERO64);
if (rootHashHex === ZERO64) { if (rootHashHex === ZERO64) {

View File

@ -3548,6 +3548,37 @@ textarea.input {
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
} }
.toolbar-channels-hold-overlay {
position: fixed;
z-index: 1200;
transform: translate(-50%, -100%);
display: grid;
grid-template-columns: repeat(3, minmax(68px, 1fr));
gap: 6px;
padding: 8px;
border-radius: 10px;
background: rgba(15, 18, 31, 0.94);
border: 1px solid rgba(160, 175, 220, 0.35);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
}
.toolbar-channels-hold-item {
border: 1px solid rgba(160, 175, 220, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: #e9efff;
font-size: 12px;
line-height: 1.2;
min-height: 34px;
padding: 6px 8px;
transition: background-color 0.12s ease, border-color 0.12s ease;
}
.toolbar-channels-hold-item.is-active {
background: rgba(133, 170, 255, 0.34);
border-color: rgba(197, 219, 255, 0.75);
}
/* ===== Targeted UI touchups (requested) ===== */ /* ===== Targeted UI touchups (requested) ===== */
.channels-screen--list .channel-row .avatar, .channels-screen--list .channel-row .avatar,
.profile-screen .avatar.large { .profile-screen .avatar.large {

View File

@ -16,13 +16,13 @@ public final class BodyRecordParser {
int v = version & 0xFFFF; int v = version & 0xFFFF;
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
// TECH supports Header v1 and CreateChannel v1/v2. // TECH supports Header v1 and CreateChannel current format (ver=1).
if (t == (CreateChannelBody.TYPE & 0xFFFF)) { if (t == (CreateChannelBody.TYPE & 0xFFFF)) {
if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF) && v == (HeaderBody.VER & 0xFFFF)) { if (st == (HeaderBody.SUBTYPE_COMPAT & 0xFFFF) && v == (HeaderBody.VER & 0xFFFF)) {
return new HeaderBody(subType, version, bodyBytes).check(); return new HeaderBody(subType, version, bodyBytes).check();
} }
if (st == (CreateChannelBody.SUBTYPE & 0xFFFF) if (st == (CreateChannelBody.SUBTYPE & 0xFFFF)
&& (v == (CreateChannelBody.VER & 0xFFFF) || v == (CreateChannelBody.VER2 & 0xFFFF))) { && (v == (CreateChannelBody.VER & 0xFFFF))) {
return new CreateChannelBody(subType, version, bodyBytes).check(); return new CreateChannelBody(subType, version, bodyBytes).check();
} }
throw new IllegalArgumentException( throw new IllegalArgumentException(

View File

@ -11,15 +11,7 @@ import java.util.Objects;
/** /**
* TECH body for create channel. * TECH body for create channel.
* *
* v1 body bytes: * body bytes:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] channelNameLen
* [N] channelName UTF-8
*
* v2 body bytes:
* [4] lineCode * [4] lineCode
* [4] prevLineNumber * [4] prevLineNumber
* [32] prevLineHash32 * [32] prevLineHash32
@ -28,18 +20,22 @@ import java.util.Objects;
* [N] channelName UTF-8 * [N] channelName UTF-8
* [2] channelDescriptionLen * [2] channelDescriptionLen
* [M] channelDescription UTF-8 (0..200 bytes) * [M] channelDescription UTF-8 (0..200 bytes)
* [2] channelTypeCode (uint16)
* [2] channelTypeVersion (uint16)
*/ */
public final class CreateChannelBody implements BodyRecord, BodyHasLine { public final class CreateChannelBody implements BodyRecord, BodyHasLine {
public static final short TYPE = 0; public static final short TYPE = 0;
public static final short VER = 1; public static final short VER = 1;
public static final short VER2 = 2;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public static final int KEY_V2 = ((TYPE & 0xFFFF) << 16) | (VER2 & 0xFFFF);
public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL; public static final short SUBTYPE = MsgSubType.TECH_CREATE_CHANNEL;
public static final short CHANNEL_TYPE_STORIES = 0;
public static final short CHANNEL_TYPE_PUBLIC = 1;
public static final short CHANNEL_TYPE_PERSONAL = 100;
public static final short CHANNEL_TYPE_GROUP = 200;
public static final short CHANNEL_TYPE_VERSION_DEFAULT = 1;
private static final byte[] ZERO32 = new byte[32]; private static final byte[] ZERO32 = new byte[32];
private static final int MAX_NAME_LENGTH = 32; private static final int MAX_NAME_LENGTH = 32;
private static final int MAX_DESCRIPTION_UTF8_LEN = 200; private static final int MAX_DESCRIPTION_UTF8_LEN = 200;
@ -54,38 +50,35 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
public final String channelName; public final String channelName;
public final String channelDescription; public final String channelDescription;
public final short channelTypeCode;
public final short channelTypeVersion;
public CreateChannelBody(short subType, short version, byte[] bodyBytes) { public CreateChannelBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null"); Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType; this.subType = subType;
this.version = version; this.version = version;
int ver = this.version & 0xFFFF; int ver = this.version & 0xFFFF;
if (ver != (VER & 0xFFFF) && ver != (VER2 & 0xFFFF)) { if (ver != (VER & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody version must be 1 or 2, got=" + ver); throw new IllegalArgumentException("CreateChannelBody version must be 1, got=" + ver);
} }
if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) { if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF)); throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF));
} }
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 2 + 4) {
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1) {
throw new IllegalArgumentException("CreateChannelBody too short"); throw new IllegalArgumentException("CreateChannelBody too short");
} }
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt(); this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt(); this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32]; this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32); bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt(); this.thisLineNumber = bb.getInt();
int nameLen = Byte.toUnsignedInt(bb.get()); int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0"); if (nameLen <= 0) throw new IllegalArgumentException("channelNameLen is 0");
if (bb.remaining() < nameLen) { if (bb.remaining() < nameLen + 2 + 4) {
throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen); throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " nameLen=" + nameLen);
} }
@ -93,94 +86,61 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
bb.get(nameBytes); bb.get(nameBytes);
this.channelName = new String(nameBytes, StandardCharsets.UTF_8); this.channelName = new String(nameBytes, StandardCharsets.UTF_8);
if (ver == (VER2 & 0xFFFF)) { int descriptionLen = Short.toUnsignedInt(bb.getShort());
if (bb.remaining() < 2) { if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("CreateChannelBody v2 missing channelDescriptionLen"); throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
}
int descriptionLen = Short.toUnsignedInt(bb.getShort());
if (descriptionLen > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
}
if (bb.remaining() != descriptionLen) {
throw new IllegalArgumentException("CreateChannelBody v2 tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
}
if (descriptionLen == 0) {
this.channelDescription = "";
} else {
byte[] descriptionBytes = new byte[descriptionLen];
bb.get(descriptionBytes);
this.channelDescription = normalizeDescription(new String(descriptionBytes, StandardCharsets.UTF_8));
}
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
return;
} }
if (bb.remaining() != descriptionLen + 4) {
this.channelDescription = ""; throw new IllegalArgumentException("CreateChannelBody tail mismatch: remaining=" + bb.remaining() + " descriptionLen=" + descriptionLen);
}
if (descriptionLen == 0) {
this.channelDescription = "";
} else {
byte[] descriptionBytes = new byte[descriptionLen];
bb.get(descriptionBytes);
this.channelDescription = normalizeDescription(new String(descriptionBytes, StandardCharsets.UTF_8));
}
this.channelTypeCode = bb.getShort();
this.channelTypeVersion = bb.getShort();
if (bb.remaining() != 0) { if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
} }
} }
public CreateChannelBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
String channelName) {
this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, "", VER);
}
public CreateChannelBody(int lineCode, public CreateChannelBody(int lineCode,
int prevLineNumber, int prevLineNumber,
byte[] prevLineHash32, byte[] prevLineHash32,
int thisLineNumber, int thisLineNumber,
String channelName, String channelName,
String channelDescription) { String channelDescription,
this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, channelDescription, VER2); short channelTypeCode,
} short channelTypeVersion) {
private CreateChannelBody(int lineCode,
int prevLineNumber,
byte[] prevLineHash32,
int thisLineNumber,
String channelName,
String channelDescription,
short version) {
Objects.requireNonNull(channelName, "channelName == null"); Objects.requireNonNull(channelName, "channelName == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
this.subType = SUBTYPE; this.subType = SUBTYPE;
this.version = version; this.version = VER;
this.lineCode = lineCode; this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber; this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); this.prevLineHash32 = (prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
this.thisLineNumber = thisLineNumber; this.thisLineNumber = thisLineNumber;
this.channelName = channelName; this.channelName = channelName;
this.channelDescription = channelDescription == null ? "" : channelDescription; this.channelDescription = channelDescription == null ? "" : channelDescription;
this.channelTypeCode = channelTypeCode;
this.channelTypeVersion = channelTypeVersion;
} }
@Override @Override
public CreateChannelBody check() { public CreateChannelBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) { if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)"); throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1)");
} }
String normalizedName = normalizeDisplayName(channelName); String normalizedName = normalizeDisplayName(channelName);
if (normalizedName.isEmpty()) { if (normalizedName.isEmpty()) throw new IllegalArgumentException("channelName is blank");
throw new IllegalArgumentException("channelName is blank");
}
int cpLen = normalizedName.codePointCount(0, normalizedName.length()); int cpLen = normalizedName.codePointCount(0, normalizedName.length());
if (cpLen > MAX_NAME_LENGTH) { if (cpLen > MAX_NAME_LENGTH) throw new IllegalArgumentException("channelName length must be <=32");
throw new IllegalArgumentException("channelName length must be <=32");
}
String normalizedDescription = normalizeDescription(channelDescription); String normalizedDescription = normalizeDescription(channelDescription);
byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8); byte[] descUtf8 = normalizedDescription.getBytes(StandardCharsets.UTF_8);
@ -188,16 +148,14 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200"); throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
} }
if (prevLineNumber < 0) { int typeCode = Short.toUnsignedInt(channelTypeCode);
throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody"); int typeVer = Short.toUnsignedInt(channelTypeVersion);
} if (typeCode < 0 || typeCode > 0xFFFF) throw new IllegalArgumentException("channelTypeCode invalid");
if (prevLineHash32 == null || prevLineHash32.length != 32) { if (typeVer < 0 || typeVer > 0xFFFF) throw new IllegalArgumentException("channelTypeVersion invalid");
throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (thisLineNumber <= 0) {
throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
}
if (prevLineNumber < 0) throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody");
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
if (thisLineNumber <= 0) throw new IllegalArgumentException("thisLineNumber must be >=1 for CreateChannelBody");
return this; return this;
} }
@ -217,45 +175,33 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine {
if (nameUtf8.length == 0 || nameUtf8.length > 255) { if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("channelName utf8 len must be 1..255"); throw new IllegalArgumentException("channelName utf8 len must be 1..255");
} }
boolean isV2 = (version & 0xFFFF) == (VER2 & 0xFFFF);
byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8); byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8);
if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) { if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) {
throw new IllegalArgumentException("channelDescription utf8 len must be <=200"); throw new IllegalArgumentException("channelDescription utf8 len must be <=200");
} }
int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + (isV2 ? 2 + descriptionUtf8.length : 0); int cap = 4 + (4 + 32 + 4) + 1 + nameUtf8.length + 2 + descriptionUtf8.length + 4;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode); bb.putInt(lineCode);
bb.putInt(prevLineNumber); bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32)); bb.put(prevLineHash32 == null ? ZERO32 : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber); bb.putInt(thisLineNumber);
bb.put((byte) nameUtf8.length); bb.put((byte) nameUtf8.length);
bb.put(nameUtf8); bb.put(nameUtf8);
bb.putShort((short) (descriptionUtf8.length & 0xFFFF));
if (isV2) { if (descriptionUtf8.length > 0) bb.put(descriptionUtf8);
bb.putShort((short) (descriptionUtf8.length & 0xFFFF)); bb.putShort(channelTypeCode);
if (descriptionUtf8.length > 0) { bb.putShort(channelTypeVersion);
bb.put(descriptionUtf8);
}
}
return bb.array(); return bb.array();
} }
@Override @Override
public int lineCode() { return lineCode; } public int lineCode() { return lineCode; }
@Override @Override
public int prevLineBlockGlobalNumber() { return prevLineNumber; } public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override @Override
public byte[] prevLineBlockHash32() { public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32);
}
@Override @Override
public int lineSeq() { return thisLineNumber; } public int lineSeq() { return thisLineNumber; }
} }

View File

@ -397,11 +397,13 @@ public final class DatabaseInitializer {
// 9) channel_names_state (global normalized channel names) // 9) channel_names_state (global normalized channel names)
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS channel_names_state ( CREATE TABLE IF NOT EXISTS channel_names_state (
slug TEXT NOT NULL PRIMARY KEY, slug TEXT NOT NULL,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
channel_description TEXT NOT NULL DEFAULT '', channel_description TEXT NOT NULL DEFAULT '',
owner_login TEXT NOT NULL, owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL, owner_bch_name TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
channel_type_version INTEGER NOT NULL DEFAULT 1,
channel_root_block_number INTEGER NOT NULL, channel_root_block_number INTEGER NOT NULL,
channel_root_block_hash BLOB NOT NULL, channel_root_block_hash BLOB NOT NULL,
created_at_ms INTEGER NOT NULL created_at_ms INTEGER NOT NULL
@ -409,8 +411,8 @@ public final class DatabaseInitializer {
"""); """);
st.executeUpdate(""" st.executeUpdate("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
ON channel_names_state (slug); ON channel_names_state (owner_bch_name, channel_type_code, slug);
"""); """);
st.executeUpdate(""" st.executeUpdate("""
@ -423,6 +425,48 @@ public final class DatabaseInitializer {
ON channel_names_state (owner_login, owner_bch_name); ON channel_names_state (owner_login, owner_bch_name);
"""); """);
// 9.1) chat200_state
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL,
channel_root_block_hash BLOB NOT NULL,
channel_name TEXT NOT NULL,
channel_type_version INTEGER NOT NULL,
chat_title TEXT NOT NULL DEFAULT '',
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_chat200_state_owner
ON chat200_state (owner_login, owner_bch_name);
""");
// 9.2) chat200_members_state
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_members_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL,
member_login TEXT NOT NULL,
member_channel_name TEXT NOT NULL,
is_active INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
updated_by_block_number INTEGER NOT NULL,
PRIMARY KEY (
owner_bch_name,
channel_root_block_number,
member_login,
member_channel_name
)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_chat200_members_owner
ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active);
""");
// 10) direct_messages // 10) direct_messages
st.executeUpdate(""" st.executeUpdate("""
CREATE TABLE IF NOT EXISTS direct_messages ( CREATE TABLE IF NOT EXISTS direct_messages (

View File

@ -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 = 2; private static final int LATEST_SCHEMA_VERSION = 3;
private final String jdbcUrl; private final String jdbcUrl;
@ -85,6 +85,7 @@ public final class SqliteDbController {
switch (targetVersion) { switch (targetVersion) {
case 1 -> migrateToV1(); case 1 -> migrateToV1();
case 2 -> migrateToV2(); case 2 -> migrateToV2();
case 3 -> migrateToV3();
default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion);
} }
} }
@ -107,6 +108,7 @@ public final class SqliteDbController {
} }
ensureChannelNamesDescriptionColumn(c, st); ensureChannelNamesDescriptionColumn(c, st);
ensureChannelNamesTypeColumns(c, st);
ensureSignedMessageReceiptUniq(c, st); ensureSignedMessageReceiptUniq(c, st);
DatabaseTriggersInstaller.createAllTriggers(st); DatabaseTriggersInstaller.createAllTriggers(st);
setSchemaVersion(c, 1); setSchemaVersion(c, 1);
@ -147,6 +149,69 @@ public final class SqliteDbController {
} }
} }
private void migrateToV3() {
try (Connection c = DriverManager.getConnection(jdbcUrl);
Statement st = c.createStatement()) {
c.setAutoCommit(false);
try {
ensureChat200StateTables(st);
setSchemaVersion(c, 3);
c.commit();
} catch (Exception e) {
try { c.rollback(); } catch (Exception ignored) {}
throw new RuntimeException("DB migration to v3 failed", e);
} finally {
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
} catch (SQLException e) {
throw new RuntimeException("DB migration to v3 failed", e);
}
}
private static void ensureChat200StateTables(Statement st) throws SQLException {
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_state (
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL,
channel_root_block_hash BLOB NOT NULL,
channel_name TEXT NOT NULL,
channel_type_version INTEGER NOT NULL,
chat_title TEXT NOT NULL DEFAULT '',
updated_at_ms INTEGER NOT NULL,
PRIMARY KEY (owner_bch_name, channel_root_block_number)
);
""");
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS chat200_members_state (
owner_bch_name TEXT NOT NULL,
channel_root_block_number INTEGER NOT NULL,
member_login TEXT NOT NULL,
member_channel_name TEXT NOT NULL,
is_active INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL,
updated_by_block_number INTEGER NOT NULL,
PRIMARY KEY (
owner_bch_name,
channel_root_block_number,
member_login,
member_channel_name
)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_chat200_state_owner
ON chat200_state (owner_login, owner_bch_name);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_chat200_members_owner
ON chat200_members_state (owner_bch_name, channel_root_block_number, is_active);
""");
}
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)) {
@ -256,11 +321,13 @@ public final class SqliteDbController {
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 (
slug TEXT NOT NULL PRIMARY KEY, slug TEXT NOT NULL,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
channel_description TEXT NOT NULL DEFAULT '', channel_description TEXT NOT NULL DEFAULT '',
owner_login TEXT NOT NULL, owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL, owner_bch_name TEXT NOT NULL,
channel_type_code INTEGER NOT NULL DEFAULT 1,
channel_type_version INTEGER NOT NULL DEFAULT 1,
channel_root_block_number INTEGER NOT NULL, channel_root_block_number INTEGER NOT NULL,
channel_root_block_hash BLOB NOT NULL, channel_root_block_hash BLOB NOT NULL,
created_at_ms INTEGER NOT NULL created_at_ms INTEGER NOT NULL
@ -286,10 +353,33 @@ public final class SqliteDbController {
} }
} }
private static void ensureChannelNamesTypeColumns(Connection c, Statement st) throws SQLException {
boolean hasTypeCode = false;
boolean hasTypeVersion = false;
try (Statement probe = c.createStatement();
ResultSet rs = probe.executeQuery("PRAGMA table_info(channel_names_state)")) {
while (rs.next()) {
String name = rs.getString("name");
if ("channel_type_code".equalsIgnoreCase(name)) hasTypeCode = true;
if ("channel_type_version".equalsIgnoreCase(name)) hasTypeVersion = true;
}
}
if (!hasTypeCode) {
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_code INTEGER NOT NULL DEFAULT 1");
}
if (!hasTypeVersion) {
st.executeUpdate("ALTER TABLE channel_names_state ADD COLUMN channel_type_version INTEGER NOT NULL DEFAULT 1");
}
}
private static void ensureChannelNamesIndexes(Statement st) throws SQLException { private static void ensureChannelNamesIndexes(Statement st) throws SQLException {
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_slug");
st.executeUpdate("DROP INDEX IF EXISTS uq_channel_names_state_owner_slug");
st.executeUpdate(""" st.executeUpdate("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug
ON channel_names_state (slug); ON channel_names_state (owner_bch_name, channel_type_code, slug);
"""); """);
st.executeUpdate(""" st.executeUpdate("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target

View File

@ -24,19 +24,26 @@ public final class ChannelNameStateDAO {
return instance; return instance;
} }
public boolean existsBySlug(Connection c, String slug) throws SQLException { public boolean existsByOwnerTypeAndSlug(Connection c, String ownerBchName, int channelTypeCode, String slug) throws SQLException {
String sql = "SELECT 1 FROM channel_names_state WHERE slug = ? LIMIT 1"; String sql = """
SELECT 1
FROM channel_names_state
WHERE owner_bch_name = ? AND channel_type_code = ? AND slug = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, slug); ps.setString(1, ownerBchName);
ps.setInt(2, channelTypeCode);
ps.setString(3, slug);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
return rs.next(); return rs.next();
} }
} }
} }
public boolean existsBySlug(String slug) throws SQLException { public boolean existsByOwnerTypeAndSlug(String ownerBchName, int channelTypeCode, String slug) throws SQLException {
try (Connection c = db.getConnection()) { try (Connection c = db.getConnection()) {
return existsBySlug(c, slug); return existsByOwnerTypeAndSlug(c, ownerBchName, channelTypeCode, slug);
} }
} }
@ -54,10 +61,12 @@ public final class ChannelNameStateDAO {
channel_description, channel_description,
owner_login, owner_login,
owner_bch_name, owner_bch_name,
channel_type_code,
channel_type_version,
channel_root_block_number, channel_root_block_number,
channel_root_block_hash, channel_root_block_hash,
created_at_ms created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"""; """;
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getSlug()); ps.setString(1, entry.getSlug());
@ -65,9 +74,11 @@ public final class ChannelNameStateDAO {
ps.setString(3, entry.getChannelDescription() == null ? "" : entry.getChannelDescription()); ps.setString(3, entry.getChannelDescription() == null ? "" : entry.getChannelDescription());
ps.setString(4, entry.getOwnerLogin()); ps.setString(4, entry.getOwnerLogin());
ps.setString(5, entry.getOwnerBlockchainName()); ps.setString(5, entry.getOwnerBlockchainName());
ps.setInt(6, entry.getChannelRootBlockNumber()); ps.setInt(6, entry.getChannelTypeCode());
ps.setBytes(7, entry.getChannelRootBlockHash()); ps.setInt(7, entry.getChannelTypeVersion());
ps.setLong(8, entry.getCreatedAtMs()); ps.setInt(8, entry.getChannelRootBlockNumber());
ps.setBytes(9, entry.getChannelRootBlockHash());
ps.setLong(10, entry.getCreatedAtMs());
ps.executeUpdate(); ps.executeUpdate();
} }
} }

View File

@ -8,6 +8,8 @@ public class ChannelNameStateEntry {
private String channelDescription; private String channelDescription;
private String ownerLogin; private String ownerLogin;
private String ownerBlockchainName; private String ownerBlockchainName;
private int channelTypeCode;
private int channelTypeVersion;
private int channelRootBlockNumber; private int channelRootBlockNumber;
private byte[] channelRootBlockHash; private byte[] channelRootBlockHash;
private long createdAtMs; private long createdAtMs;
@ -52,6 +54,22 @@ public class ChannelNameStateEntry {
this.ownerBlockchainName = ownerBlockchainName; this.ownerBlockchainName = ownerBlockchainName;
} }
public int getChannelTypeCode() {
return channelTypeCode;
}
public void setChannelTypeCode(int channelTypeCode) {
this.channelTypeCode = channelTypeCode;
}
public int getChannelTypeVersion() {
return channelTypeVersion;
}
public void setChannelTypeVersion(int channelTypeVersion) {
this.channelTypeVersion = channelTypeVersion;
}
public int getChannelRootBlockNumber() { public int getChannelRootBlockNumber() {
return channelRootBlockNumber; return channelRootBlockNumber;
} }

View File

@ -48,9 +48,15 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
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_GetGroupDialog_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_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.entyties.Net_GetChannelsCounters_Request;
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_GetGroupDialog_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_ListGroupChats200_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.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;
@ -129,6 +135,9 @@ 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("GetGroupDialog", new Net_GetGroupDialog_Handler()),
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_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()),
@ -184,6 +193,9 @@ 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("GetGroupDialog", Net_GetGroupDialog_Request.class),
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_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),

View File

@ -6,6 +6,7 @@ import blockchain.MsgSubType;
import blockchain.body.BodyHasLine; import blockchain.body.BodyHasLine;
import blockchain.body.BodyHasTarget; import blockchain.body.BodyHasTarget;
import blockchain.body.CreateChannelBody; import blockchain.body.CreateChannelBody;
import blockchain.body.TextLineBody;
import blockchain.body.UserParamBody; import blockchain.body.UserParamBody;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -34,6 +35,8 @@ import utils.blockchain.BlockchainNameUtil;
import java.util.Arrays; import java.util.Arrays;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
/** /**
@ -142,7 +145,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии"; case "prev_line_block_not_found" -> "Не найден блок prevLineNumber для проверки линии";
case "bad_prev_line_hash" -> "Некорректный prevLineHash"; case "bad_prev_line_hash" -> "Некорректный prevLineHash";
case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine";
case "channel_zero_writes_disabled" -> "Запись в канал 0 временно отключена";
case "channel_name_already_exists" -> "Такое название канала уже занято"; case "channel_name_already_exists" -> "Такое название канала уже занято";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
default -> "Ошибка: " + code; default -> "Ошибка: " + code;
@ -245,6 +247,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
} }
ChannelNameStateEntry channelNameStateEntry = null; ChannelNameStateEntry channelNameStateEntry = null;
Chat200CreateSeed chat200CreateSeed = null;
if (block.body instanceof CreateChannelBody createChannelBody) { if (block.body instanceof CreateChannelBody createChannelBody) {
final String normalizedName; final String normalizedName;
final String slug; final String slug;
@ -255,8 +258,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_name", serverLastNum, serverLastHashHex); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_name", serverLastNum, serverLastHashHex);
} }
int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
int channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion);
try { try {
if (channelNameStateDAO.existsBySlug(slug)) { if (channelNameStateDAO.existsByOwnerTypeAndSlug(blockchainName, channelTypeCode, slug)) {
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex); return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
} }
} catch (Exception e) { } catch (Exception e) {
@ -275,9 +281,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
); );
channelNameStateEntry.setOwnerLogin(login); channelNameStateEntry.setOwnerLogin(login);
channelNameStateEntry.setOwnerBlockchainName(blockchainName); channelNameStateEntry.setOwnerBlockchainName(blockchainName);
channelNameStateEntry.setChannelTypeCode(channelTypeCode);
channelNameStateEntry.setChannelTypeVersion(channelTypeVersion);
channelNameStateEntry.setChannelRootBlockNumber(block.blockNumber); channelNameStateEntry.setChannelRootBlockNumber(block.blockNumber);
channelNameStateEntry.setChannelRootBlockHash(block.getHash32()); channelNameStateEntry.setChannelRootBlockHash(block.getHash32());
channelNameStateEntry.setCreatedAtMs(block.timestamp * 1000L); channelNameStateEntry.setCreatedAtMs(block.timestamp * 1000L);
if (channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_GROUP & 0xFFFF)) {
chat200CreateSeed = new Chat200CreateSeed();
chat200CreateSeed.ownerLogin = login;
chat200CreateSeed.ownerBch = blockchainName;
chat200CreateSeed.rootBlockNumber = block.blockNumber;
chat200CreateSeed.rootBlockHash = block.getHash32();
chat200CreateSeed.channelName = normalizedName;
chat200CreateSeed.channelTypeVersion = channelTypeVersion;
chat200CreateSeed.chatTitle = channelNameStateEntry.getChannelDescription();
chat200CreateSeed.updatedAtMs = block.timestamp * 1000L;
}
} }
// 4.2) запрет дырок: blockNumber строго last+1 // 4.2) запрет дырок: blockNumber строго last+1
@ -338,17 +358,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
prevLineHash32 = bl.prevLineBlockHash32(); prevLineHash32 = bl.prevLineBlockHash32();
thisLineNumber = bl.lineSeq(); thisLineNumber = bl.lineSeq();
// Канал 0 сохраняем как технический root, но публикации в него пока не принимаем.
// Это правило защищает от "случайных" постов в дефолтный канал.
int msgType = block.type & 0xFFFF;
int msgSubType = block.subType & 0xFFFF;
if (msgType == 1
&& msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
&& lineCode != null
&& lineCode == 0) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "channel_zero_writes_disabled", serverLastNum, serverLastHashHex);
}
// Нормализация: -1 не пишем в БД (для совместимости со старым TextBody) // Нормализация: -1 не пишем в БД (для совместимости со старым TextBody)
if (prevLineNumber != null && prevLineNumber == -1) { if (prevLineNumber != null && prevLineNumber == -1) {
lineCode = null; lineCode = null;
@ -433,6 +442,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
} }
dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry); dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry);
if (chat200CreateSeed != null) {
upsertChat200StateFromCreate(chat200CreateSeed);
}
maybeApplyChat200Command(blockchainName, block);
} catch (Exception e) { } catch (Exception e) {
if (isChannelSlugConflict(e)) { if (isChannelSlugConflict(e)) {
return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex); return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex);
@ -463,8 +477,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
Throwable cur = throwable; Throwable cur = throwable;
while (cur != null) { while (cur != null) {
String message = String.valueOf(cur.getMessage()); String message = String.valueOf(cur.getMessage());
if (message.contains("channel_names_state.slug") if (message.contains("uq_channel_names_state_owner_type_slug")
|| message.contains("uq_channel_names_state_slug")) { || message.contains("channel_names_state.owner_bch_name")
|| message.contains("channel_names_state.slug")) {
return true; return true;
} }
cur = cur.getCause(); cur = cur.getCause();
@ -472,6 +487,149 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return false; return false;
} }
private static final class Chat200CreateSeed {
String ownerLogin;
String ownerBch;
int rootBlockNumber;
byte[] rootBlockHash;
String channelName;
int channelTypeVersion;
String chatTitle;
long updatedAtMs;
}
private void upsertChat200StateFromCreate(Chat200CreateSeed seed) throws Exception {
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO chat200_state (
owner_login, owner_bch_name, channel_root_block_number, channel_root_block_hash,
channel_name, channel_type_version, chat_title, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner_bch_name, channel_root_block_number) DO UPDATE SET
owner_login = excluded.owner_login,
channel_root_block_hash = excluded.channel_root_block_hash,
channel_name = excluded.channel_name,
channel_type_version = excluded.channel_type_version,
chat_title = excluded.chat_title,
updated_at_ms = excluded.updated_at_ms
""")) {
ps.setString(1, seed.ownerLogin);
ps.setString(2, seed.ownerBch);
ps.setInt(3, seed.rootBlockNumber);
ps.setBytes(4, seed.rootBlockHash);
ps.setString(5, seed.channelName);
ps.setInt(6, seed.channelTypeVersion);
ps.setString(7, seed.chatTitle == null ? "" : seed.chatTitle);
ps.setLong(8, seed.updatedAtMs);
ps.executeUpdate();
}
}
private void maybeApplyChat200Command(String ownerBch, BchBlockEntry block) throws Exception {
int msgType = block.type & 0xFFFF;
int msgSubType = block.subType & 0xFFFF;
if (msgType != 1 || msgSubType != (MsgSubType.TEXT_POST & 0xFFFF)) return;
if (!(block.body instanceof TextLineBody tlb)) return;
Integer lineCode = tlb.lineCode();
if (lineCode == null || lineCode <= 0) return;
if (!isChat200Channel(ownerBch, lineCode)) return;
CommandParsed cmd = parseChatCommand(tlb.message);
if (cmd == null) return;
long updatedAtMs = block.timestamp * 1000L;
if ("desc".equals(cmd.command)) {
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE chat200_state
SET chat_title = ?, updated_at_ms = ?
WHERE owner_bch_name = ? AND channel_root_block_number = ?
""")) {
ps.setString(1, cmd.arg1 == null ? "" : cmd.arg1);
ps.setLong(2, updatedAtMs);
ps.setString(3, ownerBch);
ps.setInt(4, lineCode);
ps.executeUpdate();
}
return;
}
if (!("add".equals(cmd.command) || "remove".equals(cmd.command))) return;
String memberLogin = cmd.arg1 == null ? "" : cmd.arg1.trim();
String memberChannel = cmd.arg2 == null ? "" : cmd.arg2.trim();
if (memberLogin.isBlank() || memberChannel.isBlank()) return;
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
INSERT INTO chat200_members_state (
owner_bch_name, channel_root_block_number, member_login, member_channel_name,
is_active, updated_at_ms, updated_by_block_number
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(owner_bch_name, channel_root_block_number, member_login, member_channel_name)
DO UPDATE SET
is_active = excluded.is_active,
updated_at_ms = excluded.updated_at_ms,
updated_by_block_number = excluded.updated_by_block_number
""")) {
ps.setString(1, ownerBch);
ps.setInt(2, lineCode);
ps.setString(3, memberLogin);
ps.setString(4, memberChannel);
ps.setInt(5, "add".equals(cmd.command) ? 1 : 0);
ps.setLong(6, updatedAtMs);
ps.setInt(7, block.blockNumber);
ps.executeUpdate();
}
}
private boolean isChat200Channel(String ownerBch, int rootBlockNumber) throws Exception {
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT channel_type_code
FROM channel_names_state
WHERE owner_bch_name = ? AND channel_root_block_number = ?
LIMIT 1
""")) {
ps.setString(1, ownerBch);
ps.setInt(2, rootBlockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return false;
return rs.getInt("channel_type_code") == (CreateChannelBody.CHANNEL_TYPE_GROUP & 0xFFFF);
}
}
}
private static final class CommandParsed {
final String command;
final String arg1;
final String arg2;
private CommandParsed(String command, String arg1, String arg2) {
this.command = command;
this.arg1 = arg1;
this.arg2 = arg2;
}
}
private static CommandParsed parseChatCommand(String text) {
String value = String.valueOf(text == null ? "" : text).trim();
if (!value.startsWith("/.")) return null;
String raw = value.substring(2).trim();
if (raw.isBlank()) return null;
int sp = raw.indexOf(' ');
String cmd = (sp < 0 ? raw : raw.substring(0, sp)).trim().toLowerCase();
String tail = sp < 0 ? "" : raw.substring(sp + 1).trim();
if ("desc".equals(cmd)) return new CommandParsed("desc", tail, "");
if ("add".equals(cmd) || "remove".equals(cmd)) {
String[] parts = tail.split("\\s+", 2);
String a1 = parts.length > 0 ? parts[0] : "";
String a2 = parts.length > 1 ? parts[1] : "";
return new CommandParsed(cmd, a1, a2);
}
return null;
}
private static long safeAdd(long a, long b) { private static long safeAdd(long a, long b) {
long r = a + b; long r = a + b;
if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow"); if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow");

View File

@ -77,19 +77,24 @@ public final class ChannelNamesStateBootstrapper {
final String displayName; final String displayName;
final String slug; final String slug;
final String channelDescription; final String channelDescription;
final int channelTypeCode;
final int channelTypeVersion;
try { try {
displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName); displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName);
slug = ChannelNameRules.toCanonicalSlug(displayName); slug = ChannelNameRules.toCanonicalSlug(displayName);
channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription); channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription);
channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode);
channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion);
} catch (Exception badName) { } catch (Exception badName) {
skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)"); skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)");
continue; continue;
} }
String identity = ownerBch + "#" + blockNumber; String identity = ownerBch + "#" + blockNumber;
String existing = slugToIdentity.putIfAbsent(slug, identity); String ownerTypeSlug = ownerBch + "|" + channelTypeCode + "|" + slug;
String existing = slugToIdentity.putIfAbsent(ownerTypeSlug, identity);
if (existing != null && !existing.equals(identity)) { if (existing != null && !existing.equals(identity)) {
conflicts.add("slug=\"" + slug + "\" conflicts: " + existing + " vs " + identity); conflicts.add("owner/type/slug=\"" + ownerTypeSlug + "\" conflicts: " + existing + " vs " + identity);
continue; continue;
} }
@ -99,6 +104,8 @@ public final class ChannelNamesStateBootstrapper {
entry.setChannelDescription(channelDescription == null ? "" : channelDescription); entry.setChannelDescription(channelDescription == null ? "" : channelDescription);
entry.setOwnerLogin(ownerLogin); entry.setOwnerLogin(ownerLogin);
entry.setOwnerBlockchainName(ownerBch); entry.setOwnerBlockchainName(ownerBch);
entry.setChannelTypeCode(channelTypeCode);
entry.setChannelTypeVersion(channelTypeVersion);
entry.setChannelRootBlockNumber(blockNumber); entry.setChannelRootBlockNumber(blockNumber);
entry.setChannelRootBlockHash(blockHash); entry.setChannelRootBlockHash(blockHash);
entry.setCreatedAtMs(parsed.timestamp * 1000L); entry.setCreatedAtMs(parsed.timestamp * 1000L);

View File

@ -6,6 +6,7 @@ import blockchain.body.CreateChannelBody;
import blockchain.body.TextBody; import blockchain.body.TextBody;
import blockchain.body.TextLineBody; import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody; import blockchain.body.TextReplyBody;
import shine.db.channels.ChannelNameRules;
import shine.db.MsgSubType; import shine.db.MsgSubType;
import java.sql.Connection; import java.sql.Connection;
@ -13,12 +14,18 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
final class ChannelsReadSupport { final class ChannelsReadSupport {
static final int MSG_TYPE_TEXT = 1; static final int MSG_TYPE_TEXT = 1;
static final int MSG_TYPE_REACTION = 2; static final int MSG_TYPE_REACTION = 2;
static final int MSG_TYPE_TECH = 0; static final int MSG_TYPE_TECH = 0;
static final String STORIES_CHANNEL_NAME = "stories";
static final String COMMAND_PREFIX = "/.";
static final String COMMAND_DESC = "desc";
static final String COMMAND_ADD = "add";
static final String COMMAND_REMOVE = "remove";
private ChannelsReadSupport() {} private ChannelsReadSupport() {}
@ -32,22 +39,51 @@ final class ChannelsReadSupport {
} }
} }
static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException { static ChannelMeta detectChannelMeta(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return "news"; ChannelMeta meta = new ChannelMeta();
meta.channelTypeVersion = CreateChannelBody.CHANNEL_TYPE_VERSION_DEFAULT & 0xFFFF;
if (rootNumber == 0) {
meta.channelName = STORIES_CHANNEL_NAME;
meta.channelDescription = detectLatestDescriptionCommand(c, ownerBch, 0);
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
return meta;
}
String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1"; String sql = "SELECT block_bytes FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) { try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch); ps.setString(1, ownerBch);
ps.setInt(2, rootNumber); ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null; if (!rs.next()) {
meta.channelName = null;
meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
return meta;
}
byte[] bytes = rs.getBytes("block_bytes"); byte[] bytes = rs.getBytes("block_bytes");
BchBlockEntry e = new BchBlockEntry(bytes); BchBlockEntry e = new BchBlockEntry(bytes);
BodyRecord body = e.body; BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) return ccb.channelName; if (body instanceof CreateChannelBody ccb) {
return null; meta.channelName = ccb.channelName;
meta.channelDescription = ccb.channelDescription == null ? "" : ccb.channelDescription;
meta.channelTypeCode = Short.toUnsignedInt(ccb.channelTypeCode);
meta.channelTypeVersion = Short.toUnsignedInt(ccb.channelTypeVersion);
} else {
meta.channelName = null;
meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
}
String updatedDescription = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
if (updatedDescription != null) {
meta.channelDescription = updatedDescription;
}
return meta;
} catch (Exception ignored) { } catch (Exception ignored) {
return null; meta.channelName = null;
meta.channelDescription = "";
meta.channelTypeCode = CreateChannelBody.CHANNEL_TYPE_PUBLIC & 0xFFFF;
return meta;
} }
} }
} }
@ -168,6 +204,15 @@ final class ChannelsReadSupport {
} }
} }
static List<PostBlock> mergeSortedByTime(List<PostBlock> source) {
List<PostBlock> out = new ArrayList<>(source);
out.sort(Comparator
.comparingLong((PostBlock pb) -> parseTextAndTime(pb.blockBytes).createdAtMs)
.thenComparing(pb -> String.valueOf(pb.bchName))
.thenComparingInt(pb -> pb.blockNumber));
return out;
}
static List<PostBlock> versionsForPost(Connection c, String ownerBch, int originalBlock, byte[] originalHash) throws SQLException { static List<PostBlock> versionsForPost(Connection c, String ownerBch, int originalBlock, byte[] originalHash) throws SQLException {
String sql = """ String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes SELECT login,bch_name,block_number,block_hash,block_bytes
@ -213,7 +258,10 @@ final class ChannelsReadSupport {
} }
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) {
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, 0);
return fromCommand == null ? "" : fromCommand;
}
// Preferred source: persisted state (fast path, works for CreateChannelBody v2). // Preferred source: persisted state (fast path, works for CreateChannelBody v2).
String stateSql = """ String stateSql = """
@ -227,7 +275,9 @@ final class ChannelsReadSupport {
ps.setInt(2, rootNumber); ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) { try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) { if (rs.next()) {
return String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description")); String saved = String.valueOf(rs.getString("channel_description") == null ? "" : rs.getString("channel_description"));
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
return fromCommand == null ? saved : fromCommand;
} }
} }
} catch (SQLException ignored) { } catch (SQLException ignored) {
@ -244,7 +294,11 @@ final class ChannelsReadSupport {
byte[] bytes = rs.getBytes("block_bytes"); byte[] bytes = rs.getBytes("block_bytes");
BchBlockEntry e = new BchBlockEntry(bytes); BchBlockEntry e = new BchBlockEntry(bytes);
BodyRecord body = e.body; BodyRecord body = e.body;
if (body instanceof CreateChannelBody ccb) return ccb.channelDescription == null ? "" : ccb.channelDescription; if (body instanceof CreateChannelBody ccb) {
String fromCommand = detectLatestDescriptionCommand(c, ownerBch, rootNumber);
if (fromCommand != null) return fromCommand;
return ccb.channelDescription == null ? "" : ccb.channelDescription;
}
return ""; return "";
} catch (Exception ignored) { } catch (Exception ignored) {
return ""; return "";
@ -252,6 +306,145 @@ final class ChannelsReadSupport {
} }
} }
static Integer detectChannelTypeCode(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return CreateChannelBody.CHANNEL_TYPE_STORIES & 0xFFFF;
String stateSql = """
SELECT channel_type_code
FROM channel_names_state
WHERE owner_bch_name = ? AND channel_root_block_number = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(stateSql)) {
ps.setString(1, ownerBch);
ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) return rs.getInt("channel_type_code");
}
} catch (SQLException ignored) {
// fallback below
}
ChannelMeta meta = detectChannelMeta(c, ownerBch, rootNumber);
return meta.channelTypeCode;
}
static Integer detectChannelTypeVersion(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return CreateChannelBody.CHANNEL_TYPE_VERSION_DEFAULT & 0xFFFF;
String stateSql = """
SELECT channel_type_version
FROM channel_names_state
WHERE owner_bch_name = ? AND channel_root_block_number = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(stateSql)) {
ps.setString(1, ownerBch);
ps.setInt(2, rootNumber);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) return rs.getInt("channel_type_version");
}
} catch (SQLException ignored) {
// fallback below
}
ChannelMeta meta = detectChannelMeta(c, ownerBch, rootNumber);
return meta.channelTypeVersion;
}
static String detectLatestDescriptionCommand(Connection c, String ownerBch, int lineCode) throws SQLException {
String sql = """
SELECT block_bytes
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type=? AND line_code=?
ORDER BY block_number DESC
LIMIT 300
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
ps.setInt(3, MsgSubType.TEXT_POST);
ps.setInt(4, lineCode);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
TextInfo info = parseTextAndTime(rs.getBytes("block_bytes"));
CommandInfo commandInfo = parseCommandText(info.text);
if (commandInfo != null && COMMAND_DESC.equals(commandInfo.command)) {
return commandInfo.arg;
}
}
}
}
return null;
}
static CommandInfo parseCommandText(String text) {
String value = String.valueOf(text == null ? "" : text).trim();
if (!value.startsWith(COMMAND_PREFIX)) return null;
String raw = value.substring(COMMAND_PREFIX.length()).trim();
if (raw.isEmpty()) return null;
int sp = raw.indexOf(' ');
String cmd = (sp < 0 ? raw : raw.substring(0, sp)).trim().toLowerCase();
String arg = sp < 0 ? "" : raw.substring(sp + 1).trim();
if (cmd.isEmpty()) return null;
return new CommandInfo(cmd, arg);
}
static PairChannelSelector findPersonalPairChannel(Connection c, String ownerLogin, String partnerLogin) throws SQLException {
if (ownerLogin == null || ownerLogin.isBlank() || partnerLogin == null || partnerLogin.isBlank()) return null;
String canonicalPartner = canonicalLogin(c, partnerLogin);
if (canonicalPartner == null || canonicalPartner.isBlank()) return null;
String partnerBchSql = """
SELECT blockchain_name
FROM blockchain_state
WHERE login = ? COLLATE NOCASE
ORDER BY blockchain_name
LIMIT 1
""";
String partnerBch = null;
try (PreparedStatement ps = c.prepareStatement(partnerBchSql)) {
ps.setString(1, canonicalPartner);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) partnerBch = rs.getString("blockchain_name");
}
}
if (partnerBch == null || partnerBch.isBlank()) return null;
String ownerSlug;
try {
ownerSlug = ChannelNameRules.toCanonicalSlug(ownerLogin);
} catch (Exception e) {
ownerSlug = ownerLogin.trim().toLowerCase();
}
String rootSql = """
SELECT channel_root_block_number
FROM channel_names_state
WHERE owner_bch_name = ?
AND channel_type_code = ?
AND slug = ?
ORDER BY channel_root_block_number DESC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(rootSql)) {
ps.setString(1, partnerBch);
ps.setInt(2, CreateChannelBody.CHANNEL_TYPE_PERSONAL & 0xFFFF);
ps.setString(3, ownerSlug);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PairChannelSelector out = new PairChannelSelector();
out.ownerBlockchainName = partnerBch;
out.channelRootBlockNumber = rs.getInt("channel_root_block_number");
return out;
}
}
}
static boolean isLikedByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException { static boolean isLikedByLogin(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) { if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
return false; return false;
@ -313,4 +506,26 @@ final class ChannelsReadSupport {
String text = ""; String text = "";
long createdAtMs = 0L; long createdAtMs = 0L;
} }
static final class ChannelMeta {
String channelName;
String channelDescription;
int channelTypeCode;
int channelTypeVersion;
}
static final class CommandInfo {
final String command;
final String arg;
CommandInfo(String command, String arg) {
this.command = command;
this.arg = arg;
}
}
static final class PairChannelSelector {
String ownerBlockchainName;
int channelRootBlockNumber;
}
} }

View File

@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController; import shine.db.SqliteDbController;
import utils.blockchain.BlockchainNameUtil; import utils.blockchain.BlockchainNameUtil;
import blockchain.body.CreateChannelBody;
import java.sql.Connection; import java.sql.Connection;
import java.util.ArrayList; import java.util.ArrayList;
@ -53,15 +54,40 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel(); Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel();
channel.setOwnerBlockchainName(ownerBch); channel.setOwnerBlockchainName(ownerBch);
channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch)); channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch));
channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode)); ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, lineCode);
channel.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, ownerBch, lineCode)); channel.setChannelName(meta.channelName);
channel.setChannelDescription(meta.channelDescription);
channel.setChannelTypeCode(meta.channelTypeCode);
channel.setChannelTypeVersion(meta.channelTypeVersion);
Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef(); Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef();
rootRef.setBlockNumber(lineCode); rootRef.setBlockNumber(lineCode);
rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash()); rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash());
channel.setChannelRoot(rootRef); channel.setChannelRoot(rootRef);
resp.setChannel(channel); resp.setChannel(channel);
List<ChannelsReadSupport.PostBlock> posts = ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc); List<ChannelsReadSupport.PostBlock> posts = new ArrayList<>(
ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc)
);
if (meta.channelTypeCode == (CreateChannelBody.CHANNEL_TYPE_PERSONAL & 0xFFFF)) {
String ownerLogin = BlockchainNameUtil.loginFromBlockchainName(ownerBch);
ChannelsReadSupport.PairChannelSelector pair = ChannelsReadSupport.findPersonalPairChannel(c, ownerLogin, meta.channelName);
if (pair != null) {
posts.addAll(ChannelsReadSupport.channelPosts(
c,
pair.ownerBlockchainName,
pair.channelRootBlockNumber,
limit,
asc
));
posts = ChannelsReadSupport.mergeSortedByTime(posts);
if (!asc) {
java.util.Collections.reverse(posts);
}
if (posts.size() > limit) {
posts = new ArrayList<>(posts.subList(0, limit));
}
}
}
List<Net_GetChannelMessages_Response.MessageItem> items = new ArrayList<>(); List<Net_GetChannelMessages_Response.MessageItem> items = new ArrayList<>();
for (ChannelsReadSupport.PostBlock post : posts) { for (ChannelsReadSupport.PostBlock post : posts) {

View File

@ -0,0 +1,107 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.MsgSubType;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class Net_GetChannelsCounters_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetChannelsCounters_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetChannelsCounters_Request req = (Net_GetChannelsCounters_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login");
}
try (Connection c = SqliteDbController.getInstance().getConnection()) {
String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim());
if (canonicalLogin == null) {
return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден");
}
Net_GetChannelsCounters_Response resp = new Net_GetChannelsCounters_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(canonicalLogin);
resp.setFeedCount(countFeed(c, canonicalLogin));
resp.setDialogs100Count(countOwnedByType(c, canonicalLogin, 100));
resp.setGroupChats200Count(countOwnedByType(c, canonicalLogin, 200));
resp.setMyChannelsCount(countMyChannels(c, canonicalLogin));
return resp;
} catch (Exception e) {
log.error("GetChannelsCounters failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private int countFeed(Connection c, String login) throws Exception {
String sql = """
SELECT COUNT(*)
FROM connections_state
WHERE login = ? COLLATE NOCASE
AND rel_type = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, MsgSubType.CONNECTION_FOLLOW);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt(1) : 0;
}
}
}
private int countOwnedByType(Connection c, String login, int typeCode) throws Exception {
String sql = """
SELECT COUNT(*)
FROM channel_names_state
WHERE owner_login = ? COLLATE NOCASE
AND channel_type_code = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, typeCode);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt(1) : 0;
}
}
}
private int countMyChannels(Connection c, String login) throws Exception {
String bchCountSql = "SELECT COUNT(*) FROM blockchain_state WHERE login = ? COLLATE NOCASE";
int stories = 0;
try (PreparedStatement ps = c.prepareStatement(bchCountSql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
stories = rs.next() ? rs.getInt(1) : 0;
}
}
String namedSql = """
SELECT COUNT(*)
FROM channel_names_state
WHERE owner_login = ? COLLATE NOCASE
AND channel_type_code IN (1,100,200)
""";
int named = 0;
try (PreparedStatement ps = c.prepareStatement(namedSql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
named = rs.next() ? rs.getInt(1) : 0;
}
}
return stories + named;
}
}

View File

@ -0,0 +1,217 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import shine.db.channels.ChannelNameRules;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class Net_GetGroupDialog_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetGroupDialog_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetGroupDialog_Request req = (Net_GetGroupDialog_Request) baseRequest;
if (req.getGroup() == null
|| req.getGroup().getOwnerBlockchainName() == null
|| req.getGroup().getOwnerBlockchainName().isBlank()
|| req.getGroup().getChannelRootBlockNumber() == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля group");
}
try (Connection c = SqliteDbController.getInstance().getConnection()) {
Net_GetGroupDialog_Response resp = new Net_GetGroupDialog_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
String ownerBch = req.getGroup().getOwnerBlockchainName().trim();
int root = req.getGroup().getChannelRootBlockNumber();
fillGroupInfo(c, ownerBch, root, resp);
List<MsgRow> all = new ArrayList<>();
all.addAll(loadTextByLine(c, ownerBch, root));
for (MemberRef ref : loadActiveMembers(c, ownerBch, root)) {
GroupChannelRef target = resolveMemberChannel(c, ref);
if (target == null) continue;
all.addAll(loadTextByLine(c, target.ownerBch, target.rootNumber));
}
all.sort(Comparator.comparingLong(o -> o.createdAtMs));
resp.setMessages(toOut(all));
return resp;
} catch (Exception e) {
log.error("GetGroupDialog failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private void fillGroupInfo(Connection c, String ownerBch, int root, Net_GetGroupDialog_Response resp) throws Exception {
String sql = """
SELECT owner_login, owner_bch_name, channel_root_block_number, channel_name, chat_title
FROM chat200_state
WHERE owner_bch_name = ? AND channel_root_block_number = ?
LIMIT 1
""";
Net_GetGroupDialog_Response.GroupInfo g = new Net_GetGroupDialog_Response.GroupInfo();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, root);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
g.setOwnerLogin(rs.getString("owner_login"));
g.setOwnerBlockchainName(rs.getString("owner_bch_name"));
g.setChannelRootBlockNumber(rs.getInt("channel_root_block_number"));
g.setChannelName(rs.getString("channel_name"));
g.setChatTitle(rs.getString("chat_title"));
resp.setGroup(g);
return;
}
}
}
g.setOwnerBlockchainName(ownerBch);
g.setChannelRootBlockNumber(root);
g.setChannelName("");
g.setChatTitle("");
resp.setGroup(g);
}
private List<MemberRef> loadActiveMembers(Connection c, String ownerBch, int root) throws Exception {
String sql = """
SELECT member_login, member_channel_name
FROM chat200_members_state
WHERE owner_bch_name = ? AND channel_root_block_number = ? AND is_active = 1
""";
List<MemberRef> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, root);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
MemberRef ref = new MemberRef();
ref.memberLogin = rs.getString("member_login");
ref.memberChannelName = rs.getString("member_channel_name");
out.add(ref);
}
}
}
return out;
}
private GroupChannelRef resolveMemberChannel(Connection c, MemberRef ref) throws Exception {
String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, ref.memberLogin);
if (canonicalLogin == null || canonicalLogin.isBlank()) return null;
String bchSql = "SELECT blockchain_name FROM blockchain_state WHERE login = ? COLLATE NOCASE ORDER BY blockchain_name LIMIT 1";
String memberBch = null;
try (PreparedStatement ps = c.prepareStatement(bchSql)) {
ps.setString(1, canonicalLogin);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) memberBch = rs.getString("blockchain_name");
}
}
if (memberBch == null || memberBch.isBlank()) return null;
String slug;
try {
slug = ChannelNameRules.toCanonicalSlug(ref.memberChannelName);
} catch (Exception e) {
return null;
}
String rootSql = """
SELECT channel_root_block_number
FROM channel_names_state
WHERE owner_bch_name = ? AND channel_type_code = 200 AND slug = ?
ORDER BY channel_root_block_number DESC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(rootSql)) {
ps.setString(1, memberBch);
ps.setString(2, slug);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
GroupChannelRef out = new GroupChannelRef();
out.ownerBch = memberBch;
out.rootNumber = rs.getInt("channel_root_block_number");
return out;
}
}
}
private List<MsgRow> loadTextByLine(Connection c, String ownerBch, int line) throws Exception {
String sql = """
SELECT login, bch_name, block_number, block_hash, block_bytes
FROM blocks
WHERE bch_name = ? AND msg_type = 1 AND line_code = ?
ORDER BY block_number ASC
""";
List<MsgRow> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, line);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
MsgRow row = new MsgRow();
row.authorLogin = rs.getString("login");
row.authorBch = rs.getString("bch_name");
row.blockNumber = rs.getInt("block_number");
row.blockHash = rs.getBytes("block_hash");
ChannelsReadSupport.TextInfo ti = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes"));
row.createdAtMs = ti.createdAtMs;
row.text = ti.text;
out.add(row);
}
}
}
return out;
}
private List<Net_GetGroupDialog_Response.MessageItem> toOut(List<MsgRow> rows) {
List<Net_GetGroupDialog_Response.MessageItem> out = new ArrayList<>();
for (MsgRow r : rows) {
Net_GetGroupDialog_Response.MessageItem item = new Net_GetGroupDialog_Response.MessageItem();
item.setAuthorLogin(r.authorLogin);
item.setAuthorBlockchainName(r.authorBch);
item.setBlockNumber(r.blockNumber);
item.setBlockHash(ChannelsReadSupport.toHex(r.blockHash));
item.setCreatedAtMs(r.createdAtMs);
item.setText(r.text);
out.add(item);
}
return out;
}
private static final class MemberRef {
String memberLogin;
String memberChannelName;
}
private static final class GroupChannelRef {
String ownerBch;
int rootNumber;
}
private static final class MsgRow {
String authorLogin;
String authorBch;
int blockNumber;
byte[] blockHash;
long createdAtMs;
String text;
}
}

View File

@ -0,0 +1,86 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.SqliteDbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class Net_ListGroupChats200_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_ListGroupChats200_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_ListGroupChats200_Request req = (Net_ListGroupChats200_Request) baseRequest;
if (req.getLogin() == null || req.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login");
}
try (Connection c = SqliteDbController.getInstance().getConnection()) {
String canonicalLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin().trim());
if (canonicalLogin == null) {
return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден");
}
Net_ListGroupChats200_Response resp = new Net_ListGroupChats200_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(canonicalLogin);
resp.setChats(loadRows(c, canonicalLogin));
return resp;
} catch (Exception e) {
log.error("ListGroupChats200 failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
private List<Net_ListGroupChats200_Response.Row> loadRows(Connection c, String login) throws Exception {
String sql = """
SELECT s.owner_login, s.owner_bch_name, s.channel_root_block_number, s.channel_root_block_hash,
s.channel_name, s.chat_title, s.updated_at_ms,
COALESCE(m.members_count, 0) AS members_count
FROM chat200_state s
LEFT JOIN (
SELECT owner_bch_name, channel_root_block_number, COUNT(*) AS members_count
FROM chat200_members_state
WHERE is_active = 1
GROUP BY owner_bch_name, channel_root_block_number
) m
ON m.owner_bch_name = s.owner_bch_name
AND m.channel_root_block_number = s.channel_root_block_number
WHERE s.owner_login = ? COLLATE NOCASE
ORDER BY s.updated_at_ms DESC, s.channel_root_block_number DESC
""";
List<Net_ListGroupChats200_Response.Row> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Net_ListGroupChats200_Response.Row row = new Net_ListGroupChats200_Response.Row();
row.setOwnerLogin(rs.getString("owner_login"));
row.setOwnerBlockchainName(rs.getString("owner_bch_name"));
row.setChannelRootBlockNumber(rs.getInt("channel_root_block_number"));
row.setChannelRootBlockHash(ChannelsReadSupport.toHex(rs.getBytes("channel_root_block_hash")));
row.setChannelName(rs.getString("channel_name"));
row.setChatTitle(rs.getString("chat_title"));
row.setUpdatedAtMs(rs.getLong("updated_at_ms"));
row.setMembersCount(rs.getInt("members_count"));
out.add(row);
}
}
}
return out;
}
}

View File

@ -61,11 +61,14 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
for (ChannelKey key : keys) { for (ChannelKey key : keys) {
Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary(); Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary();
Net_ListSubscriptionsFeed_Response.ChannelRef channelRef = new Net_ListSubscriptionsFeed_Response.ChannelRef(); Net_ListSubscriptionsFeed_Response.ChannelRef channelRef = new Net_ListSubscriptionsFeed_Response.ChannelRef();
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, key.ownerBch, key.rootNumber);
channelRef.setOwnerLogin(key.ownerLogin); channelRef.setOwnerLogin(key.ownerLogin);
channelRef.setOwnerBlockchainName(key.ownerBch); channelRef.setOwnerBlockchainName(key.ownerBch);
channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber)); channelRef.setChannelName(meta.channelName);
channelRef.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, key.ownerBch, key.rootNumber)); channelRef.setChannelDescription(meta.channelDescription);
channelRef.setPersonal(key.rootNumber == 0); channelRef.setChannelTypeCode(meta.channelTypeCode);
channelRef.setChannelTypeVersion(meta.channelTypeVersion);
channelRef.setPersonal(meta.channelTypeCode == 100);
Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef(); Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef();
rootRef.setBlockNumber(key.rootNumber); rootRef.setBlockNumber(key.rootNumber);

View File

@ -21,6 +21,8 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private String ownerBlockchainName; private String ownerBlockchainName;
private String channelName; private String channelName;
private String channelDescription; private String channelDescription;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private BlockRef channelRoot; private BlockRef channelRoot;
public String getOwnerLogin() { return ownerLogin; } public String getOwnerLogin() { return ownerLogin; }
@ -35,6 +37,12 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getChannelDescription() { return channelDescription; } public String getChannelDescription() { return channelDescription; }
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; } public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
public Integer getChannelTypeCode() { return channelTypeCode; }
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
public Integer getChannelTypeVersion() { return channelTypeVersion; }
public void setChannelTypeVersion(Integer channelTypeVersion) { this.channelTypeVersion = channelTypeVersion; }
public BlockRef getChannelRoot() { return channelRoot; } public BlockRef getChannelRoot() { return channelRoot; }
public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; } public void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; }
} }

View File

@ -0,0 +1,11 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetChannelsCounters_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}

View File

@ -0,0 +1,23 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_GetChannelsCounters_Response extends Net_Response {
private String login;
private int feedCount;
private int dialogs100Count;
private int groupChats200Count;
private int myChannelsCount;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public int getFeedCount() { return feedCount; }
public void setFeedCount(int feedCount) { this.feedCount = feedCount; }
public int getDialogs100Count() { return dialogs100Count; }
public void setDialogs100Count(int dialogs100Count) { this.dialogs100Count = dialogs100Count; }
public int getGroupChats200Count() { return groupChats200Count; }
public void setGroupChats200Count(int groupChats200Count) { this.groupChats200Count = groupChats200Count; }
public int getMyChannelsCount() { return myChannelsCount; }
public void setMyChannelsCount(int myChannelsCount) { this.myChannelsCount = myChannelsCount; }
}

View File

@ -0,0 +1,24 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetGroupDialog_Request extends Net_Request {
private String login;
private GroupSelector group;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public GroupSelector getGroup() { return group; }
public void setGroup(GroupSelector group) { this.group = group; }
public static class GroupSelector {
private String ownerBlockchainName;
private Integer channelRootBlockNumber;
public String getOwnerBlockchainName() { return ownerBlockchainName; }
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
public Integer getChannelRootBlockNumber() { return channelRootBlockNumber; }
public void setChannelRootBlockNumber(Integer channelRootBlockNumber) { this.channelRootBlockNumber = channelRootBlockNumber; }
}
}

View File

@ -0,0 +1,58 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_GetGroupDialog_Response extends Net_Response {
private GroupInfo group;
private List<MessageItem> messages = new ArrayList<>();
public GroupInfo getGroup() { return group; }
public void setGroup(GroupInfo group) { this.group = group; }
public List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
public static class GroupInfo {
private String ownerLogin;
private String ownerBlockchainName;
private int channelRootBlockNumber;
private String channelName;
private String chatTitle;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getOwnerBlockchainName() { return ownerBlockchainName; }
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
public int getChannelRootBlockNumber() { return channelRootBlockNumber; }
public void setChannelRootBlockNumber(int channelRootBlockNumber) { this.channelRootBlockNumber = channelRootBlockNumber; }
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
public String getChatTitle() { return chatTitle; }
public void setChatTitle(String chatTitle) { this.chatTitle = chatTitle; }
}
public static class MessageItem {
private String authorLogin;
private String authorBlockchainName;
private int blockNumber;
private String blockHash;
private long createdAtMs;
private String text;
public String getAuthorLogin() { return authorLogin; }
public void setAuthorLogin(String authorLogin) { this.authorLogin = authorLogin; }
public String getAuthorBlockchainName() { return authorBlockchainName; }
public void setAuthorBlockchainName(String authorBlockchainName) { this.authorBlockchainName = authorBlockchainName; }
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
}
}

View File

@ -0,0 +1,11 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_ListGroupChats200_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}

View File

@ -0,0 +1,46 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_ListGroupChats200_Response extends Net_Response {
private String login;
private List<Row> chats = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<Row> getChats() { return chats; }
public void setChats(List<Row> chats) { this.chats = chats; }
public static class Row {
private String ownerLogin;
private String ownerBlockchainName;
private int channelRootBlockNumber;
private String channelRootBlockHash;
private String channelName;
private String chatTitle;
private int membersCount;
private long updatedAtMs;
public String getOwnerLogin() { return ownerLogin; }
public void setOwnerLogin(String ownerLogin) { this.ownerLogin = ownerLogin; }
public String getOwnerBlockchainName() { return ownerBlockchainName; }
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
public int getChannelRootBlockNumber() { return channelRootBlockNumber; }
public void setChannelRootBlockNumber(int channelRootBlockNumber) { this.channelRootBlockNumber = channelRootBlockNumber; }
public String getChannelRootBlockHash() { return channelRootBlockHash; }
public void setChannelRootBlockHash(String channelRootBlockHash) { this.channelRootBlockHash = channelRootBlockHash; }
public String getChannelName() { return channelName; }
public void setChannelName(String channelName) { this.channelName = channelName; }
public String getChatTitle() { return chatTitle; }
public void setChatTitle(String chatTitle) { this.chatTitle = chatTitle; }
public int getMembersCount() { return membersCount; }
public void setMembersCount(int membersCount) { this.membersCount = membersCount; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}
}

View File

@ -47,6 +47,8 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
private String ownerBlockchainName; private String ownerBlockchainName;
private String channelName; private String channelName;
private String channelDescription; private String channelDescription;
private Integer channelTypeCode;
private Integer channelTypeVersion;
private boolean personal; private boolean personal;
private BlockRef channelRoot; private BlockRef channelRoot;
@ -62,6 +64,12 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public String getChannelDescription() { return channelDescription; } public String getChannelDescription() { return channelDescription; }
public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; } public void setChannelDescription(String channelDescription) { this.channelDescription = channelDescription; }
public Integer getChannelTypeCode() { return channelTypeCode; }
public void setChannelTypeCode(Integer channelTypeCode) { this.channelTypeCode = channelTypeCode; }
public Integer getChannelTypeVersion() { return channelTypeVersion; }
public void setChannelTypeVersion(Integer channelTypeVersion) { this.channelTypeVersion = channelTypeVersion; }
public boolean isPersonal() { return personal; } public boolean isPersonal() { return personal; }
public void setPersonal(boolean personal) { this.personal = personal; } public void setPersonal(boolean personal) { this.personal = personal; }

View File

@ -105,7 +105,10 @@ public class IT_03_AddBlock_NoAuth {
sender1.send(new CreateChannelBody( sender1.send(new CreateChannelBody(
0, // lineCode TECH 0, // lineCode TECH
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber, ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
"News" "News",
"",
CreateChannelBody.CHANNEL_TYPE_PUBLIC,
CreateChannelBody.CHANNEL_TYPE_VERSION_DEFAULT
), t); ), t);
newsRootBlock = st1.lastBlockNumber(); // root канала = blockNumber этого CREATE_CHANNEL newsRootBlock = st1.lastBlockNumber(); // root канала = blockNumber этого CREATE_CHANNEL
@ -286,4 +289,4 @@ public class IT_03_AddBlock_NoAuth {
toBlockHash32 toBlockHash32
), timeout); ), timeout);
} }
} }