From e95f65ac782d818b7d11d0a70b2f7784520314c187c136493c418863b588641f Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 13 May 2026 01:17:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D0=BD=D0=B0=D0=BB=D1=8B:=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D1=8B=200/1/100/200,=20CreateChannel=20v3,=20state?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20chat200,=20=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B5=20API=20=D0=B8=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BD=D0=B0=20prod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 + .../01_Channel_Types_and_CreateChannel_v3.md | 36 +++ Dev_Docs/Blockchain/02_Channel_Commands.md | 29 +++ Dev_Docs/Blockchain/CHANGELOG.md | 12 + Dev_Docs/Blockchain/README.md | 16 ++ VERSION.properties | 4 +- shine-UI/js/components/toolbar.js | 98 +++++++- shine-UI/js/pages/add-channel-view.js | 45 +++- shine-UI/js/pages/channel-view.js | 89 +++---- shine-UI/js/pages/channels-list.js | 156 ++++++------ shine-UI/js/router.js | 9 + shine-UI/js/services/auth-service.js | 44 +++- shine-UI/styles/components.css | 31 +++ .../java/blockchain/BodyRecordParser.java | 4 +- .../blockchain/body/CreateChannelBody.java | 91 ++++++- .../java/shine/db/DatabaseInitializer.java | 50 +++- .../java/shine/db/SqliteDbController.java | 98 +++++++- .../shine/db/dao/ChannelNameStateDAO.java | 29 ++- .../db/entities/ChannelNameStateEntry.java | 18 ++ .../ws_protocol/JSON/JsonHandlerRegistry.java | 12 + .../blockchain/Net_AddBlock_Handler.java | 188 ++++++++++++-- .../ChannelNamesStateBootstrapper.java | 11 +- .../channels/ChannelsReadSupport.java | 233 +++++++++++++++++- .../Net_GetChannelMessages_Handler.java | 32 ++- .../Net_GetChannelsCounters_Handler.java | 107 ++++++++ .../channels/Net_GetGroupDialog_Handler.java | 217 ++++++++++++++++ .../Net_ListGroupChats200_Handler.java | 86 +++++++ .../Net_ListSubscriptionsFeed_Handler.java | 9 +- .../Net_GetChannelMessages_Response.java | 8 + .../Net_GetChannelsCounters_Request.java | 11 + .../Net_GetChannelsCounters_Response.java | 23 ++ .../entyties/Net_GetGroupDialog_Request.java | 24 ++ .../entyties/Net_GetGroupDialog_Response.java | 58 +++++ .../Net_ListGroupChats200_Request.java | 11 + .../Net_ListGroupChats200_Response.java | 46 ++++ .../Net_ListSubscriptionsFeed_Response.java | 8 + 36 files changed, 1764 insertions(+), 187 deletions(-) create mode 100644 Dev_Docs/Blockchain/01_Channel_Types_and_CreateChannel_v3.md create mode 100644 Dev_Docs/Blockchain/02_Channel_Commands.md create mode 100644 Dev_Docs/Blockchain/CHANGELOG.md create mode 100644 Dev_Docs/Blockchain/README.md create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelsCounters_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelsCounters_Response.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetGroupDialog_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetGroupDialog_Response.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListGroupChats200_Request.java create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListGroupChats200_Response.java diff --git a/AGENTS.md b/AGENTS.md index 3292d37..093e3f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,12 @@ ## Примечание - Если внешний инструмент/интеграция требует английский формат, допускается английский, но рядом желательно дать краткое пояснение на русском. +## Документация блокчейна +- Актуальная документация по форматам блокчейна находится в `Dev_Docs/Blockchain/README.md`. +- Это точка входа (оглавление), рядом расположены детальные файлы по форматам, типам каналов и командным сообщениям. +- При любом изменении кода, связанного с блокчейном (формат блока, типы каналов, правила чтения/записи, команды), обязательно обновлять соответствующие документы в `Dev_Docs/Blockchain/`. +- Дополнительно обязательно вести `Dev_Docs/Blockchain/CHANGELOG.md`: дописывать изменения построчно с указанием даты/времени и хэша коммита, после которого внесено изменение. + ## Версионирование - Единый файл версий проекта: `VERSION.properties` (в корне репозитория). - Перед каждым новым коммитом обязательно увеличивать версии в `VERSION.properties`: @@ -18,6 +24,8 @@ ## Deploy - Все документы и заметки по деплою хранить в папке `Deploy Server/`. - Для сервера `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`). - Если из запроса неясно, куда деплоить, обязательно сначала спросить пользователя, какой именно целевой контур нужен. diff --git a/Dev_Docs/Blockchain/01_Channel_Types_and_CreateChannel_v3.md b/Dev_Docs/Blockchain/01_Channel_Types_and_CreateChannel_v3.md new file mode 100644 index 0000000..d8017e2 --- /dev/null +++ b/Dev_Docs/Blockchain/01_Channel_Types_and_CreateChannel_v3.md @@ -0,0 +1,36 @@ +# Типы каналов и CreateChannel v3 + +## 1. Формат `CreateChannelBody v3` +Формат `TECH_CREATE_CHANNEL` поддерживает `version=3` и включает: + +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` (если обратный канал существует). diff --git a/Dev_Docs/Blockchain/02_Channel_Commands.md b/Dev_Docs/Blockchain/02_Channel_Commands.md new file mode 100644 index 0000000..448e7e6 --- /dev/null +++ b/Dev_Docs/Blockchain/02_Channel_Commands.md @@ -0,0 +1,29 @@ +# Командные сообщения каналов + +## 1. Общий префикс +Командные сообщения распознаются по префиксу: + +`/.` + +Пример: +- `/.desc Новый комментарий канала` + +## 2. Поддерживаемые команды + +### Для всех типов каналов (`0`, `1`, `100`, `200`) +- `/.desc ` — смена описания канала. + +Примечание: +- Описание канала в чтении определяется последней командой `/.desc` в линии канала. +- Если `/.desc` не было, используется описание из `CreateChannel`. + +### Дополнительно для `type=200` +- `/.add ` +- `/.remove ` + +Формат аргументов фиксирован: через пробел. + +## 3. Текущая модель применения +- Команды передаются как обычные `TEXT_POST` сообщения. +- Сервер уже применяет `/.desc` при вычислении актуального описания канала. +- Команды `/.add` и `/.remove` зарезервированы под расширенную модель участников `type=200` на уровне UI/агрегации. diff --git a/Dev_Docs/Blockchain/CHANGELOG.md b/Dev_Docs/Blockchain/CHANGELOG.md new file mode 100644 index 0000000..b44117f --- /dev/null +++ b/Dev_Docs/Blockchain/CHANGELOG.md @@ -0,0 +1,12 @@ +# История изменений документации блокчейна + +## 2026-05-13 00:02:32 +0300 +- Базовый коммит-ориентир: `f63f40f1eb2f`. +- Добавлен `CreateChannelBody v3` с полями `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/`. diff --git a/Dev_Docs/Blockchain/README.md b/Dev_Docs/Blockchain/README.md new file mode 100644 index 0000000..bf49d24 --- /dev/null +++ b/Dev_Docs/Blockchain/README.md @@ -0,0 +1,16 @@ +# Blockchain Docs (Актуально) + +## Назначение +Этот набор файлов — актуальная документация по текущему формату блокчейна SHiNE для каналов, их типов и командных сообщений. + +## Оглавление +1. [01_Channel_Types_and_CreateChannel_v3.md](./01_Channel_Types_and_CreateChannel_v3.md) + Формат `CreateChannelBody v3`, типы каналов, уникальность имён и правила `stories`. +2. [02_Channel_Commands.md](./02_Channel_Commands.md) + Командные сообщения в каналах: `/.desc`, `/.add`, `/.remove`. +3. [CHANGELOG.md](./CHANGELOG.md) + История изменений документации и блокчейн-правил. + +## Обязательное правило сопровождения +- Любое изменение блокчейн-кода (форматы, типы, правила чтения/записи, команды) должно сопровождаться обновлением файлов из этого каталога. +- Изменение обязательно фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания. diff --git a/VERSION.properties b/VERSION.properties index b102ae5..140d2ac 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.44 -server.version=1.2.38 +client.version=1.2.45 +server.version=1.2.39 diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index 69e9f41..67ef9dd 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -8,6 +8,12 @@ const ITEMS = [ { pageId: 'notifications-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() { const chats = Object.values(state.chats || {}); @@ -54,9 +60,99 @@ export function renderToolbar(currentPageId, navigate) { badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`); 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); }); 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) => ( + `` + )).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(); + }); +} diff --git a/shine-UI/js/pages/add-channel-view.js b/shine-UI/js/pages/add-channel-view.js index 475f40a..f773ab5 100644 --- a/shine-UI/js/pages/add-channel-view.js +++ b/shine-UI/js/pages/add-channel-view.js @@ -11,6 +11,9 @@ import { export const pageMeta = { id: 'add-channel-view', title: 'Создать канал' }; 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) { try { @@ -45,7 +48,15 @@ export function render({ navigate }) { form.innerHTML = ` Создание канала

Можно использовать только латиницу, цифры, _ и -.

-

Длина названия: от 3 до 32 символов. Название уникально во всей системе.

+

Длина названия: от 3 до 32 символов.

+ + + +
Публичный канал видят все. Писать может только владелец.
@@ -64,6 +75,8 @@ export function render({ navigate }) { `; 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 nameErrorEl = form.querySelector('#channel-name-error'); const descriptionErrorEl = form.querySelector('#channel-description-error'); @@ -79,10 +92,27 @@ export function render({ navigate }) { submitEl.disabled = submitInFlight; cancelEl.disabled = submitInFlight; nameEl.disabled = submitInFlight; + typeEl.disabled = submitInFlight; descriptionEl.disabled = 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 nameCheck = validateChannelDisplayName(nameEl.value); const descriptionCheck = validateDescription(descriptionEl.value); @@ -124,11 +154,22 @@ export function render({ navigate }) { errorEl.textContent = ''; 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({ login, storagePwd, channelName: normalizeChannelDisplayName(check.name), channelDescription: normalizeChannelDescription(check.description), + channelType, + channelTypeVersion: 1, }); persistCreateSuccessFlash(`Канал "${normalizeChannelDisplayName(check.name)}" создан.`); @@ -141,9 +182,11 @@ export function render({ navigate }) { }); cancelEl.addEventListener('click', () => navigate('channels-list')); + typeEl.addEventListener('change', updateTypeHint); screen.append(form); nameEl.focus(); + updateTypeHint(); updateValidation(); return screen; } diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index c2cc1ee..66b47c5 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -518,6 +518,7 @@ function renderPostCard(post, { navigate, routeKey, selector, + canWrite, onToggleLike, onReply, onShare, @@ -579,52 +580,55 @@ function renderPostCard(post, { if (!post.messageRef || !selector) return card; - const actionKey = makeReactionActionKey(post.messageRef); - const isPending = actionKey ? pendingReactionActions.has(actionKey) : false; - const actions = document.createElement('div'); actions.className = 'channel-message-actions'; - const likeButton = document.createElement('button'); - likeButton.type = 'button'; - likeButton.className = 'channel-action-item channel-action-like'; - const isLiked = post.reactionState === 'liked'; - if (isLiked) likeButton.classList.add('is-liked'); - likeButton.innerHTML = ` - - ${isPending ? 'Лайк...' : 'Лайк'} - ${post.likesCount || 0} - `; - likeButton.disabled = isPending; - likeButton.addEventListener('click', async (event) => { - animatePress(event.currentTarget); - if (isPending) return; - if (!isLiked) { - const ok = window.confirm('Поставить лайк?'); - if (!ok) return; - } - revealCounters(); - await longPressFeel(event.currentTarget, 130); - likeButton.disabled = true; - const labelEl = likeButton.querySelector('.channel-action-label'); - if (labelEl) labelEl.textContent = 'Лайк...'; - await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton }); - }); + if (canWrite) { + const actionKey = makeReactionActionKey(post.messageRef); + const isPending = actionKey ? pendingReactionActions.has(actionKey) : false; - const replyButton = document.createElement('button'); - replyButton.type = 'button'; - replyButton.className = 'channel-action-item channel-action-reply'; - replyButton.innerHTML = ` - - Ответить - `; - replyButton.addEventListener('click', (event) => { - animatePress(event.currentTarget); - revealCounters(); - openReplyModal({ - onSubmit: async (text) => onReply(post.messageRef, text), + const likeButton = document.createElement('button'); + likeButton.type = 'button'; + likeButton.className = 'channel-action-item channel-action-like'; + const isLiked = post.reactionState === 'liked'; + if (isLiked) likeButton.classList.add('is-liked'); + likeButton.innerHTML = ` + + ${isPending ? 'Лайк...' : 'Лайк'} + ${post.likesCount || 0} + `; + likeButton.disabled = isPending; + likeButton.addEventListener('click', async (event) => { + animatePress(event.currentTarget); + if (isPending) return; + if (!isLiked) { + const ok = window.confirm('Поставить лайк?'); + if (!ok) return; + } + revealCounters(); + await longPressFeel(event.currentTarget, 130); + likeButton.disabled = true; + const labelEl = likeButton.querySelector('.channel-action-label'); + if (labelEl) labelEl.textContent = 'Лайк...'; + 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 = ` + + Ответить + `; + 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'); openThreadButton.type = 'button'; @@ -656,7 +660,7 @@ function renderPostCard(post, { await onShare(route); }); - actions.append(likeButton, replyButton, openThreadButton, shareButton); + actions.append(openThreadButton, shareButton); card.append(actions); return card; } @@ -704,6 +708,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { navigate, routeKey, selector: channelData.selector, + canWrite: channelData.isOwnChannel, onToggleLike: handlers.onToggleLike, onReply: handlers.onReply, onShare: handlers.onShare, diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 5a25d19..6afd491 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -15,6 +15,9 @@ export const pageMeta = { id: 'channels-list', title: 'Каналы' }; const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; 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() { try { @@ -40,16 +43,18 @@ function normalizeLoginInput(value) { } 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 channelName = String(summary?.channel?.channelName || '').trim(); if (ownerLogin && channelName) { return `channel/${encodeRoutePart(ownerLogin)}/${encodeRoutePart(channelName)}`; } - const ownerBch = summary?.channel?.ownerBlockchainName; - const rootBlockNumber = summary?.channel?.channelRoot?.blockNumber; - const rootBlockHash = normalizeHash(summary?.channel?.channelRoot?.blockHash); - if (!ownerBch || rootBlockNumber == null) return `channel-view/${fallbackId}`; - return `channel-view/${encodeRoutePart(ownerBch)}/${Number(rootBlockNumber)}/${rootBlockHash}`; + return `channel-view/${fallbackId}`; } function avatarLetterFromName(name = '') { @@ -526,7 +531,11 @@ function mapMockGroups() { const mapRow = (channel) => ({ ...channel, 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 || 'Ждем ваших начинаний', isSubscribed: channel.kind !== 'own' && channel.kind !== 'own-personal', isOwnChannel: channel.kind === 'own' || channel.kind === 'own-personal', @@ -545,11 +554,11 @@ function mapMockGroups() { const followedUserChannels = mockChannels .filter((channel) => channel.kind === 'followed-user-channel') - .map((item) => ({ ...mapRow(item), tabCategory: 'authors' })); + .map((item) => ({ ...mapRow(item), tabCategory: 'feed' })); const subscribedChannels = mockChannels .filter((channel) => channel.kind === 'subscribed') - .map((item) => ({ ...mapRow(item), tabCategory: 'subscriptions' })); + .map((item) => ({ ...mapRow(item), tabCategory: 'feed' })); return { ownChannels, followedUserChannels, subscribedChannels, index: {} }; } @@ -561,18 +570,14 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) { const ownerLogin = summary?.channel?.ownerLogin || 'неизвестно'; const channelName = summary?.channel?.channelName || '(без названия)'; 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 tabCategory = bucketKey === 'own' - ? 'my' - : bucketKey === 'followedUsers' - ? 'authors' - : 'subscriptions'; + const tabCategory = isOwn + ? (channelTypeCode === CHANNEL_TYPE_PERSONAL ? 'dialogs' : 'my') + : 'feed'; - const title = isOwn - ? channelName - : tabCategory === 'authors' - ? channelName - : `${ownerLogin}/${channelName}`; + const title = isOwn ? channelName : `${ownerLogin}/${channelName}`; return { id: rowId, @@ -585,6 +590,8 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) { title, channelName, channelDescription, + channelTypeCode, + channelTypeVersion, messagePreview: summary?.lastMessage?.text || 'Ждем ваших начинаний', messagesCount: Number(summary?.messagesCount || 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() { try { const value = String(sessionStorage.getItem(CREATE_CHANNEL_FLASH_KEY) || '').trim(); @@ -624,8 +617,7 @@ function pullCreateSuccessFlash() { function mapApiFeed(feed, notificationsState) { const index = {}; const ownChannels = (feed?.ownedChannels || []) - .map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState)) - .filter((row) => !isSyntheticDefaultChannel(row)); + .map((it, idx) => mapApiChannelRow(it, 'own', 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)); @@ -645,12 +637,14 @@ function renderEmptyState(activeTab, navigate) { wrap.className = 'channels-empty-state channels-empty-state--compact channels-empty-state--silent'; const text = document.createElement('p'); text.className = 'meta-muted'; - if (activeTab === 'subscriptions') { - text.textContent = 'Вы пока не подписаны на каналы.'; + if (activeTab === 'feed') { + text.textContent = 'Нет подписок и найденных каналов.'; + } else if (activeTab === 'dialogs') { + text.textContent = 'У вас пока нет персональных каналов.'; } else if (activeTab === 'my') { text.textContent = 'У вас пока нет каналов.'; } else { - text.textContent = 'Пока нет каналов авторов.'; + text.textContent = 'Пусто.'; } wrap.append(text); @@ -882,7 +876,7 @@ function renderChannelMain(channel, activeTab) { const main = document.createElement('div'); main.className = 'channel-row-main'; - if (activeTab === 'authors') { + if (activeTab === 'feed') { const author = document.createElement('p'); author.className = 'channel-row-author'; author.textContent = `@${channel.ownerName}`; @@ -907,7 +901,7 @@ function renderChannelMain(channel, activeTab) { title.className = 'channel-row-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'); desc.className = 'channel-row-description'; desc.textContent = channel.channelDescription; @@ -1009,7 +1003,7 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f const tab = listState.activeTab; const baseClass = `primary-btn channels-bottom-action${isTabEmpty && tab !== 'my' ? ' is-empty-lift' : ''}`; - if (tab === 'subscriptions') { + if (tab === 'feed') { button.textContent = 'Подписаться на канал'; button.className = baseClass; button.onclick = () => openSimpleSubscribeModal({ @@ -1021,16 +1015,23 @@ function updateBottomCta({ button, listState, navigate, onReload, isTabEmpty = f return; } - if (tab === 'authors') { - button.textContent = '🔍 Поиск каналов'; + if (tab === 'dialogs') { + button.textContent = 'Новый персональный канал'; button.className = baseClass; - button.onclick = () => openChannelFinderModal({ navigate }); + button.onclick = () => navigate('add-channel-view'); return; } - button.textContent = 'Создать канал'; + if (tab === 'my') { + button.textContent = 'Создать канал'; + button.className = baseClass; + button.onclick = () => navigate('add-channel-view'); + return; + } + + button.textContent = 'Поиск каналов'; button.className = baseClass; - button.onclick = () => navigate('add-channel-view'); + button.onclick = () => openChannelFinderModal({ 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'); screen.className = 'stack channels-screen channels-screen--list'; const appScreen = document.getElementById('app-screen'); @@ -1075,7 +1076,9 @@ export function render({ navigate }) { const notificationsState = readChannelNotificationsState(); const listState = { - activeTab: 'subscriptions', + activeTab: TAB_ORDER.includes(String(route?.params?.mode || '').trim()) + ? String(route?.params?.mode).trim() + : 'dialogs', openMenuId: null, notificationsState, revealedCounters: new Set(), @@ -1083,9 +1086,6 @@ export function render({ navigate }) { menuCleanup: null, }; - const tabs = document.createElement('div'); - tabs.className = 'channels-tabs channels-tabs--sticky'; - const contentEl = document.createElement('div'); contentEl.className = 'channels-list-content'; @@ -1095,12 +1095,16 @@ export function render({ navigate }) { const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate }); 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 btn = tabs.querySelector(`[data-tab="${tab.key}"]`); - if (btn) btn.classList.toggle('is-active', tab.key === listState.activeTab); - }); + const isTabEmpty = !(listState.channels || []).some((channel) => channel.tabCategory === listState.activeTab); closeChannelMenu(listState); @@ -1121,28 +1125,28 @@ export function render({ navigate }) { }); }; - const tabItems = [ - { key: 'subscriptions', label: 'Каналы' }, - { key: 'my', label: 'Мои' }, - { key: 'authors', label: 'Авторы' }, - ]; + let touchStartX = 0; + let touchStartY = 0; + contentEl.addEventListener('touchstart', (event) => { + 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) => { - 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); + screen.append(contentEl, bottomCta); if (createSuccessFlash) { showToast(createSuccessFlash); diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index 40f6aa4..423e245 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -123,6 +123,15 @@ export function getRoute() { }; } + if (pageId === 'channels-list') { + return { + pageId, + params: { + mode: segments[1] ? decodePart(segments[1]) : '', + }, + }; + } + return { pageId, params: {} }; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index ea54d9a..02ab181 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -42,7 +42,12 @@ const MSG_SUBTYPE_REACTION_LIKE = 1; const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; const MSG_SUBTYPE_CONNECTION_UNFOLLOW = 31; -const CREATE_CHANNEL_BODY_VERSION = 2; +const CREATE_CHANNEL_BODY_VERSION = 3; +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({ // Legacy alias: friend == close_friend. Оба ключа ведут на один и тот же код 10/11. @@ -404,13 +409,15 @@ function normalizeChannelDescription(value) { return text; } -function makeCreateChannelBodyV2Bytes({ +function makeCreateChannelBodyV3Bytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, channelName, channelDescription = '', + channelType = CHANNEL_TYPE_PUBLIC, + channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT, }) { const check = validateChannelDisplayName(channelName); if (!check.ok) throw new Error(channelNameErrorText(check.code)); @@ -426,6 +433,14 @@ function makeCreateChannelBodyV2Bytes({ if (descriptionBytes.length > 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( int32Bytes(lineCode), @@ -436,6 +451,8 @@ function makeCreateChannelBodyV2Bytes({ nameBytes, int16Bytes(descriptionBytes.length), descriptionBytes, + uint16Bytes(typeCode), + uint16Bytes(typeVer), ); } @@ -1004,11 +1021,19 @@ export class AuthService { rootBlockNumber: Number(item?.channel?.channelRoot?.blockNumber), rootBlockHash: normalizeHex32(item?.channel?.channelRoot?.blockHash, ZERO64), channelName: String(item?.channel?.channelName || ''), + channelTypeCode: Number(item?.channel?.channelTypeCode ?? CHANNEL_TYPE_PUBLIC), })) .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(); if (!cleanLogin) throw new Error('Missing login'); @@ -1018,7 +1043,9 @@ export class AuthService { const cleanChannelDescription = normalizeChannelDescription(channelDescription); 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 () => { const user = await this.ensureChainInitializedForLineOps(cleanLogin, storagePwd); const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim(); @@ -1053,13 +1080,15 @@ export class AuthService { msgType: MSG_TYPE_TECH, msgSubType: MSG_SUBTYPE_TECH_CREATE_CHANNEL, msgVersion: CREATE_CHANNEL_BODY_VERSION, - bodyBytes: makeCreateChannelBodyV2Bytes({ + bodyBytes: makeCreateChannelBodyV3Bytes({ lineCode: 0, prevLineNumber, prevLineHashHex, thisLineNumber, channelName: cleanChannelName, channelDescription: cleanChannelDescription, + channelType: typeCode, + channelTypeVersion: typeVersion, }), }); @@ -1099,11 +1128,6 @@ export class AuthService { if (ownerBlockchainName !== blockchainName) { 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); if (rootHashHex === ZERO64) { diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 88bc549..8bb3eb2 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -3548,6 +3548,37 @@ textarea.input { 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) ===== */ .channels-screen--list .channel-row .avatar, .profile-screen .avatar.large { diff --git a/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java b/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java index 3c06221..4e82cfc 100644 --- a/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java +++ b/shine-server-blockchain/src/main/java/blockchain/BodyRecordParser.java @@ -22,7 +22,9 @@ public final class BodyRecordParser { return new HeaderBody(subType, version, bodyBytes).check(); } if (st == (CreateChannelBody.SUBTYPE & 0xFFFF) - && (v == (CreateChannelBody.VER & 0xFFFF) || v == (CreateChannelBody.VER2 & 0xFFFF))) { + && (v == (CreateChannelBody.VER & 0xFFFF) + || v == (CreateChannelBody.VER2 & 0xFFFF) + || v == (CreateChannelBody.VER3 & 0xFFFF))) { return new CreateChannelBody(subType, version, bodyBytes).check(); } throw new IllegalArgumentException( diff --git a/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java b/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java index d0833f9..be8c2fc 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/CreateChannelBody.java @@ -28,18 +28,38 @@ import java.util.Objects; * [N] channelName UTF-8 * [2] channelDescriptionLen * [M] channelDescription UTF-8 (0..200 bytes) + * + * v3 body bytes: + * [4] lineCode + * [4] prevLineNumber + * [32] prevLineHash32 + * [4] thisLineNumber + * [1] channelNameLen + * [N] channelName UTF-8 + * [2] channelDescriptionLen + * [M] channelDescription UTF-8 (0..200 bytes) + * [2] channelTypeCode (uint16) + * [2] channelTypeVersion (uint16) */ public final class CreateChannelBody implements BodyRecord, BodyHasLine { public static final short TYPE = 0; public static final short VER = 1; public static final short VER2 = 2; + public static final short VER3 = 3; public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF); public static final int KEY_V2 = ((TYPE & 0xFFFF) << 16) | (VER2 & 0xFFFF); + public static final int KEY_V3 = ((TYPE & 0xFFFF) << 16) | (VER3 & 0xFFFF); 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 int MAX_NAME_LENGTH = 32; private static final int MAX_DESCRIPTION_UTF8_LEN = 200; @@ -54,6 +74,8 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { public final String channelName; public final String channelDescription; + public final short channelTypeCode; + public final short channelTypeVersion; public CreateChannelBody(short subType, short version, byte[] bodyBytes) { Objects.requireNonNull(bodyBytes, "bodyBytes == null"); @@ -62,8 +84,8 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { this.version = version; int ver = this.version & 0xFFFF; - if (ver != (VER & 0xFFFF) && ver != (VER2 & 0xFFFF)) { - throw new IllegalArgumentException("CreateChannelBody version must be 1 or 2, got=" + ver); + if (ver != (VER & 0xFFFF) && ver != (VER2 & 0xFFFF) && ver != (VER3 & 0xFFFF)) { + throw new IllegalArgumentException("CreateChannelBody version must be 1, 2 or 3, got=" + ver); } if ((this.subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) { throw new IllegalArgumentException("CreateChannelBody subType must be TECH_CREATE_CHANNEL(1), got=" + (this.subType & 0xFFFF)); @@ -93,9 +115,9 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { bb.get(nameBytes); this.channelName = new String(nameBytes, StandardCharsets.UTF_8); - if (ver == (VER2 & 0xFFFF)) { + if (ver == (VER2 & 0xFFFF) || ver == (VER3 & 0xFFFF)) { if (bb.remaining() < 2) { - throw new IllegalArgumentException("CreateChannelBody v2 missing channelDescriptionLen"); + throw new IllegalArgumentException("CreateChannelBody v2/v3 missing channelDescriptionLen"); } int descriptionLen = Short.toUnsignedInt(bb.getShort()); @@ -113,6 +135,17 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { bb.get(descriptionBytes); this.channelDescription = normalizeDescription(new String(descriptionBytes, StandardCharsets.UTF_8)); } + if (ver == (VER3 & 0xFFFF)) { + if (bb.remaining() < 4) { + throw new IllegalArgumentException("CreateChannelBody v3 missing channelTypeCode/channelTypeVersion"); + } + this.channelTypeCode = bb.getShort(); + this.channelTypeVersion = bb.getShort(); + } else { + this.channelTypeCode = CHANNEL_TYPE_PUBLIC; + this.channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT; + } + if (bb.remaining() != 0) { throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); } @@ -120,6 +153,8 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { } this.channelDescription = ""; + this.channelTypeCode = CHANNEL_TYPE_PUBLIC; + this.channelTypeVersion = CHANNEL_TYPE_VERSION_DEFAULT; if (bb.remaining() != 0) { throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); } @@ -142,6 +177,18 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, channelDescription, VER2); } + public CreateChannelBody(int lineCode, + int prevLineNumber, + byte[] prevLineHash32, + int thisLineNumber, + String channelName, + String channelDescription, + short channelTypeCode, + short channelTypeVersion) { + this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, channelDescription, + channelTypeCode, channelTypeVersion, VER3); + } + private CreateChannelBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, @@ -149,6 +196,19 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { String channelName, String channelDescription, short version) { + this(lineCode, prevLineNumber, prevLineHash32, thisLineNumber, channelName, channelDescription, + CHANNEL_TYPE_PUBLIC, CHANNEL_TYPE_VERSION_DEFAULT, version); + } + + private CreateChannelBody(int lineCode, + int prevLineNumber, + byte[] prevLineHash32, + int thisLineNumber, + String channelName, + String channelDescription, + short channelTypeCode, + short channelTypeVersion, + short version) { Objects.requireNonNull(channelName, "channelName == null"); if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0"); @@ -162,6 +222,8 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { this.channelName = channelName; this.channelDescription = channelDescription == null ? "" : channelDescription; + this.channelTypeCode = channelTypeCode; + this.channelTypeVersion = channelTypeVersion; } @Override @@ -188,6 +250,15 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { throw new IllegalArgumentException("channelDescription utf8 len must be <=200"); } + int typeCode = Short.toUnsignedInt(channelTypeCode); + int typeVer = Short.toUnsignedInt(channelTypeVersion); + if (typeCode < 0 || typeCode > 0xFFFF) { + throw new IllegalArgumentException("channelTypeCode invalid"); + } + if (typeVer < 0 || typeVer > 0xFFFF) { + throw new IllegalArgumentException("channelTypeVersion invalid"); + } + if (prevLineNumber < 0) { throw new IllegalArgumentException("prevLineNumber must be >=0 for CreateChannelBody"); } @@ -219,12 +290,15 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { } boolean isV2 = (version & 0xFFFF) == (VER2 & 0xFFFF); + boolean isV3 = (version & 0xFFFF) == (VER3 & 0xFFFF); byte[] descriptionUtf8 = normalizeDescription(channelDescription).getBytes(StandardCharsets.UTF_8); if (descriptionUtf8.length > MAX_DESCRIPTION_UTF8_LEN) { 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 + + ((isV2 || isV3) ? 2 + descriptionUtf8.length : 0) + + (isV3 ? 4 : 0); ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN); bb.putInt(lineCode); @@ -235,13 +309,18 @@ public final class CreateChannelBody implements BodyRecord, BodyHasLine { bb.put((byte) nameUtf8.length); bb.put(nameUtf8); - if (isV2) { + if (isV2 || isV3) { bb.putShort((short) (descriptionUtf8.length & 0xFFFF)); if (descriptionUtf8.length > 0) { bb.put(descriptionUtf8); } } + if (isV3) { + bb.putShort(channelTypeCode); + bb.putShort(channelTypeVersion); + } + return bb.array(); } diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index a9c73b9..3145c0e 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -397,11 +397,13 @@ public final class DatabaseInitializer { // 9) channel_names_state (global normalized channel names) st.executeUpdate(""" CREATE TABLE IF NOT EXISTS channel_names_state ( - slug TEXT NOT NULL PRIMARY KEY, + slug TEXT NOT NULL, display_name TEXT NOT NULL, channel_description TEXT NOT NULL DEFAULT '', owner_login 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_hash BLOB NOT NULL, created_at_ms INTEGER NOT NULL @@ -409,8 +411,8 @@ public final class DatabaseInitializer { """); st.executeUpdate(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug - ON channel_names_state (slug); + CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug + ON channel_names_state (owner_bch_name, channel_type_code, slug); """); st.executeUpdate(""" @@ -423,6 +425,48 @@ public final class DatabaseInitializer { 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 st.executeUpdate(""" CREATE TABLE IF NOT EXISTS direct_messages ( diff --git a/shine-server-db/src/main/java/shine/db/SqliteDbController.java b/shine-server-db/src/main/java/shine/db/SqliteDbController.java index 715f72a..4f3fbf2 100644 --- a/shine-server-db/src/main/java/shine/db/SqliteDbController.java +++ b/shine-server-db/src/main/java/shine/db/SqliteDbController.java @@ -14,7 +14,7 @@ import java.sql.Statement; public final class SqliteDbController { private static volatile SqliteDbController instance; - private static final int LATEST_SCHEMA_VERSION = 2; + private static final int LATEST_SCHEMA_VERSION = 3; private final String jdbcUrl; @@ -85,6 +85,7 @@ public final class SqliteDbController { switch (targetVersion) { case 1 -> migrateToV1(); case 2 -> migrateToV2(); + case 3 -> migrateToV3(); default -> throw new RuntimeException("Unknown DB migration target version: " + targetVersion); } } @@ -107,6 +108,7 @@ public final class SqliteDbController { } ensureChannelNamesDescriptionColumn(c, st); + ensureChannelNamesTypeColumns(c, st); ensureSignedMessageReceiptUniq(c, st); DatabaseTriggersInstaller.createAllTriggers(st); 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() { try (Connection c = DriverManager.getConnection(jdbcUrl)) { if (!tableExists(c, DatabaseInitializer.DB_SCHEMA_VERSION_TABLE)) { @@ -256,11 +321,13 @@ public final class SqliteDbController { private static void ensureChannelNamesStateTable(Statement st) throws SQLException { st.executeUpdate(""" CREATE TABLE IF NOT EXISTS channel_names_state ( - slug TEXT NOT NULL PRIMARY KEY, + slug TEXT NOT NULL, display_name TEXT NOT NULL, channel_description TEXT NOT NULL DEFAULT '', owner_login 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_hash BLOB 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 { + 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(""" - CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_slug - ON channel_names_state (slug); + CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_owner_type_slug + ON channel_names_state (owner_bch_name, channel_type_code, slug); """); st.executeUpdate(""" CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_names_state_target diff --git a/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java b/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java index f4d42ea..a0b0727 100644 --- a/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +++ b/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java @@ -24,19 +24,26 @@ public final class ChannelNameStateDAO { return instance; } - public boolean existsBySlug(Connection c, String slug) throws SQLException { - String sql = "SELECT 1 FROM channel_names_state WHERE slug = ? LIMIT 1"; + public boolean existsByOwnerTypeAndSlug(Connection c, String ownerBchName, int channelTypeCode, String slug) throws SQLException { + 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)) { - ps.setString(1, slug); + ps.setString(1, ownerBchName); + ps.setInt(2, channelTypeCode); + ps.setString(3, slug); try (ResultSet rs = ps.executeQuery()) { 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()) { - return existsBySlug(c, slug); + return existsByOwnerTypeAndSlug(c, ownerBchName, channelTypeCode, slug); } } @@ -54,10 +61,12 @@ public final class ChannelNameStateDAO { channel_description, owner_login, owner_bch_name, + channel_type_code, + channel_type_version, channel_root_block_number, channel_root_block_hash, created_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, entry.getSlug()); @@ -65,9 +74,11 @@ public final class ChannelNameStateDAO { ps.setString(3, entry.getChannelDescription() == null ? "" : entry.getChannelDescription()); ps.setString(4, entry.getOwnerLogin()); ps.setString(5, entry.getOwnerBlockchainName()); - ps.setInt(6, entry.getChannelRootBlockNumber()); - ps.setBytes(7, entry.getChannelRootBlockHash()); - ps.setLong(8, entry.getCreatedAtMs()); + ps.setInt(6, entry.getChannelTypeCode()); + ps.setInt(7, entry.getChannelTypeVersion()); + ps.setInt(8, entry.getChannelRootBlockNumber()); + ps.setBytes(9, entry.getChannelRootBlockHash()); + ps.setLong(10, entry.getCreatedAtMs()); ps.executeUpdate(); } } diff --git a/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java b/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java index 34c6191..ec7fb49 100644 --- a/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java +++ b/shine-server-db/src/main/java/shine/db/entities/ChannelNameStateEntry.java @@ -8,6 +8,8 @@ public class ChannelNameStateEntry { private String channelDescription; private String ownerLogin; private String ownerBlockchainName; + private int channelTypeCode; + private int channelTypeVersion; private int channelRootBlockNumber; private byte[] channelRootBlockHash; private long createdAtMs; @@ -52,6 +54,22 @@ public class ChannelNameStateEntry { 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() { return channelRootBlockNumber; } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 464002b..1c2176d 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -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.Net_GetChannelMessages_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.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_GetGroupDialog_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.connections.Net_GetUserConnectionsGraph_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("GetChannelMessages", new Net_GetChannelMessages_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("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_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("GetChannelMessages", Net_GetChannelMessages_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("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index 2989a67..a1cfda4 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -6,6 +6,7 @@ import blockchain.MsgSubType; import blockchain.body.BodyHasLine; import blockchain.body.BodyHasTarget; import blockchain.body.CreateChannelBody; +import blockchain.body.TextLineBody; import blockchain.body.UserParamBody; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,6 +35,8 @@ import utils.blockchain.BlockchainNameUtil; import java.util.Arrays; import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; 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 "bad_prev_line_hash" -> "Некорректный prevLineHash"; case "db_error_prev_line_check" -> "Ошибка БД при проверке prevLine"; - case "channel_zero_writes_disabled" -> "Запись в канал 0 временно отключена"; case "channel_name_already_exists" -> "Такое название канала уже занято"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; default -> "Ошибка: " + code; @@ -245,6 +247,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } ChannelNameStateEntry channelNameStateEntry = null; + Chat200CreateSeed chat200CreateSeed = null; if (block.body instanceof CreateChannelBody createChannelBody) { final String normalizedName; 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); } + int channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); + int channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion); + try { - if (channelNameStateDAO.existsBySlug(slug)) { + if (channelNameStateDAO.existsByOwnerTypeAndSlug(blockchainName, channelTypeCode, slug)) { return new AddBlockResult(409, "channel_name_already_exists", serverLastNum, serverLastHashHex); } } catch (Exception e) { @@ -275,9 +281,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { ); channelNameStateEntry.setOwnerLogin(login); channelNameStateEntry.setOwnerBlockchainName(blockchainName); + channelNameStateEntry.setChannelTypeCode(channelTypeCode); + channelNameStateEntry.setChannelTypeVersion(channelTypeVersion); channelNameStateEntry.setChannelRootBlockNumber(block.blockNumber); channelNameStateEntry.setChannelRootBlockHash(block.getHash32()); 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 @@ -338,17 +358,6 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { prevLineHash32 = bl.prevLineBlockHash32(); 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) if (prevLineNumber != null && prevLineNumber == -1) { lineCode = null; @@ -433,6 +442,11 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } dbWriter.appendBlockAndState(blockchainName, block, st, be, upsertedParam, channelNameStateEntry); + + if (chat200CreateSeed != null) { + upsertChat200StateFromCreate(chat200CreateSeed); + } + maybeApplyChat200Command(blockchainName, block); } catch (Exception e) { if (isChannelSlugConflict(e)) { 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; while (cur != null) { String message = String.valueOf(cur.getMessage()); - if (message.contains("channel_names_state.slug") - || message.contains("uq_channel_names_state_slug")) { + if (message.contains("uq_channel_names_state_owner_type_slug") + || message.contains("channel_names_state.owner_bch_name") + || message.contains("channel_names_state.slug")) { return true; } cur = cur.getCause(); @@ -472,6 +487,149 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { 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) { long r = a + b; if (((a ^ r) & (b ^ r)) < 0) throw new ArithmeticException("long overflow"); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java index f6258dd..4b61c9d 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelNamesStateBootstrapper.java @@ -77,19 +77,24 @@ public final class ChannelNamesStateBootstrapper { final String displayName; final String slug; final String channelDescription; + final int channelTypeCode; + final int channelTypeVersion; try { displayName = ChannelNameRules.normalizeDisplayName(createChannelBody.channelName); slug = ChannelNameRules.toCanonicalSlug(displayName); channelDescription = ChannelNameRules.normalizeDisplayName(createChannelBody.channelDescription); + channelTypeCode = Short.toUnsignedInt(createChannelBody.channelTypeCode); + channelTypeVersion = Short.toUnsignedInt(createChannelBody.channelTypeVersion); } catch (Exception badName) { skipped.add(ownerBch + "#" + blockNumber + " (invalid_name)"); continue; } 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)) { - conflicts.add("slug=\"" + slug + "\" conflicts: " + existing + " vs " + identity); + conflicts.add("owner/type/slug=\"" + ownerTypeSlug + "\" conflicts: " + existing + " vs " + identity); continue; } @@ -99,6 +104,8 @@ public final class ChannelNamesStateBootstrapper { entry.setChannelDescription(channelDescription == null ? "" : channelDescription); entry.setOwnerLogin(ownerLogin); entry.setOwnerBlockchainName(ownerBch); + entry.setChannelTypeCode(channelTypeCode); + entry.setChannelTypeVersion(channelTypeVersion); entry.setChannelRootBlockNumber(blockNumber); entry.setChannelRootBlockHash(blockHash); entry.setCreatedAtMs(parsed.timestamp * 1000L); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index 4789c93..86ca0d5 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -6,6 +6,7 @@ import blockchain.body.CreateChannelBody; import blockchain.body.TextBody; import blockchain.body.TextLineBody; import blockchain.body.TextReplyBody; +import shine.db.channels.ChannelNameRules; import shine.db.MsgSubType; import java.sql.Connection; @@ -13,12 +14,18 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; final class ChannelsReadSupport { static final int MSG_TYPE_TEXT = 1; static final int MSG_TYPE_REACTION = 2; 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() {} @@ -32,22 +39,51 @@ final class ChannelsReadSupport { } } - static String detectChannelName(Connection c, String ownerBch, int rootNumber) throws SQLException { - if (rootNumber == 0) return "news"; + static ChannelMeta detectChannelMeta(Connection c, String ownerBch, int rootNumber) throws SQLException { + 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"; try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); ps.setInt(2, rootNumber); 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"); BchBlockEntry e = new BchBlockEntry(bytes); BodyRecord body = e.body; - if (body instanceof CreateChannelBody ccb) return ccb.channelName; - return null; + if (body instanceof CreateChannelBody ccb) { + 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) { - 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 mergeSortedByTime(List source) { + List 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 versionsForPost(Connection c, String ownerBch, int originalBlock, byte[] originalHash) throws SQLException { String sql = """ 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 { - 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). String stateSql = """ @@ -227,7 +275,9 @@ final class ChannelsReadSupport { ps.setInt(2, rootNumber); try (ResultSet rs = ps.executeQuery()) { 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) { @@ -244,7 +294,11 @@ final class ChannelsReadSupport { byte[] bytes = rs.getBytes("block_bytes"); BchBlockEntry e = new BchBlockEntry(bytes); 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 ""; } catch (Exception ignored) { 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 { if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) { return false; @@ -313,4 +506,26 @@ final class ChannelsReadSupport { String text = ""; 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; + } } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index 64d5e3d..52f8be2 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.SqliteDbController; import utils.blockchain.BlockchainNameUtil; +import blockchain.body.CreateChannelBody; import java.sql.Connection; 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(); channel.setOwnerBlockchainName(ownerBch); channel.setOwnerLogin(BlockchainNameUtil.loginFromBlockchainName(ownerBch)); - channel.setChannelName(ChannelsReadSupport.detectChannelName(c, ownerBch, lineCode)); - channel.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, ownerBch, lineCode)); + ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(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(); rootRef.setBlockNumber(lineCode); rootRef.setBlockHash(req.getChannel().getChannelRootBlockHash()); channel.setChannelRoot(rootRef); resp.setChannel(channel); - List posts = ChannelsReadSupport.channelPosts(c, ownerBch, lineCode, limit, asc); + List 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 items = new ArrayList<>(); for (ChannelsReadSupport.PostBlock post : posts) { diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java new file mode 100644 index 0000000..9f775da --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelsCounters_Handler.java @@ -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; + } +} diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java new file mode 100644 index 0000000..c4e23f2 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetGroupDialog_Handler.java @@ -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 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 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 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 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 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 toOut(List rows) { + List 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; + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java new file mode 100644 index 0000000..6b5af1c --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListGroupChats200_Handler.java @@ -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 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 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; + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index 42f0891..aaeb677 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -61,11 +61,14 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { for (ChannelKey key : keys) { Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary(); 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.setOwnerBlockchainName(key.ownerBch); - channelRef.setChannelName(ChannelsReadSupport.detectChannelName(c, key.ownerBch, key.rootNumber)); - channelRef.setChannelDescription(ChannelsReadSupport.detectChannelDescription(c, key.ownerBch, key.rootNumber)); - channelRef.setPersonal(key.rootNumber == 0); + channelRef.setChannelName(meta.channelName); + channelRef.setChannelDescription(meta.channelDescription); + channelRef.setChannelTypeCode(meta.channelTypeCode); + channelRef.setChannelTypeVersion(meta.channelTypeVersion); + channelRef.setPersonal(meta.channelTypeCode == 100); Net_ListSubscriptionsFeed_Response.BlockRef rootRef = new Net_ListSubscriptionsFeed_Response.BlockRef(); rootRef.setBlockNumber(key.rootNumber); diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index 1b3ae57..926f745 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -21,6 +21,8 @@ public class Net_GetChannelMessages_Response extends Net_Response { private String ownerBlockchainName; private String channelName; private String channelDescription; + private Integer channelTypeCode; + private Integer channelTypeVersion; private BlockRef channelRoot; public String getOwnerLogin() { return ownerLogin; } @@ -35,6 +37,12 @@ public class Net_GetChannelMessages_Response extends Net_Response { public String getChannelDescription() { return 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 void setChannelRoot(BlockRef channelRoot) { this.channelRoot = channelRoot; } } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelsCounters_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelsCounters_Request.java new file mode 100644 index 0000000..06f31b3 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelsCounters_Request.java @@ -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; } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelsCounters_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelsCounters_Response.java new file mode 100644 index 0000000..ddb4dd2 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelsCounters_Response.java @@ -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; } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetGroupDialog_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetGroupDialog_Request.java new file mode 100644 index 0000000..cafbaa2 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetGroupDialog_Request.java @@ -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; } + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetGroupDialog_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetGroupDialog_Response.java new file mode 100644 index 0000000..f821095 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetGroupDialog_Response.java @@ -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 messages = new ArrayList<>(); + + public GroupInfo getGroup() { return group; } + public void setGroup(GroupInfo group) { this.group = group; } + public List getMessages() { return messages; } + public void setMessages(List 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; } + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListGroupChats200_Request.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListGroupChats200_Request.java new file mode 100644 index 0000000..9abec8a --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListGroupChats200_Request.java @@ -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; } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListGroupChats200_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListGroupChats200_Response.java new file mode 100644 index 0000000..41911d1 --- /dev/null +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListGroupChats200_Response.java @@ -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 chats = new ArrayList<>(); + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public List getChats() { return chats; } + public void setChats(List 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; } + } +} + diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java index 62b851b..0d30472 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java @@ -47,6 +47,8 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { private String ownerBlockchainName; private String channelName; private String channelDescription; + private Integer channelTypeCode; + private Integer channelTypeVersion; private boolean personal; private BlockRef channelRoot; @@ -62,6 +64,12 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public String getChannelDescription() { return 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 void setPersonal(boolean personal) { this.personal = personal; }