diff --git a/AGENTS.md b/AGENTS.md index afe8a83..d5eb785 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,12 @@ ## Примечание - Если внешний инструмент/интеграция требует английский формат, допускается английский, но рядом желательно дать краткое пояснение на русском. +## Структура проекта (кратко) +- Серверный код SHiNE находится в папке `SHiNE-server/`. +- Код клиентского UI SHiNE находится в папке `shine-UI/`. +- Локальный Telegram-бот агента-кодера находится в папке `SHiNE-agent-bot-coder/` и не является кодом основного серверного приложения. +- Solana/Anchor-модуль находится в папке `shine-solana/shine/` и ведётся отдельно от основного server/UI деплоя. + ## Сервис агента-кодера - В проекте есть локальный Telegram-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`. - Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту. diff --git a/CLAUDE.md b/CLAUDE.md index e8bb80d..59206b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,2 +1,9 @@ @AGENTS.md @AGENT_DEBUG_RUNBOOK.md + +## Обязательно читать при работе с UI +@shine-UI/AGENTS.md + +## Справка по подпроектам +- При работе внутри `SHiNE-agent-bot-coder/` — читать `SHiNE-agent-bot-coder/AGENTS.md` и `SHiNE-agent-bot-coder/AGENT.md`. +- При работе внутри `shine-solana/shine/` — читать `shine-solana/shine/AGENTS.md`. diff --git a/Dev_Docs/Personal_Messages/README.md b/Dev_Docs/Personal_Messages/README.md index 1879112..72902f9 100644 --- a/Dev_Docs/Personal_Messages/README.md +++ b/Dev_Docs/Personal_Messages/README.md @@ -162,9 +162,13 @@ UI чата строится на этих типах: текстовые соо ## 7) Доставка и backlog - При сохранении пары сервер пытается сразу доставить в онлайн-сессии. -- Для офлайн/недоступных сессий остаётся pending-запись доставки. -- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`. -- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`. +- Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`. +- При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`: + - для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»; + - отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`; + - лимита на количество сообщений нет — передаётся вся история без ограничений. +- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует. +- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки. ## 8) Read-receipt логика @@ -183,17 +187,34 @@ UI чата строится на этих типах: текстовые соо ## 9) Логика UI-клиента -В UI: +### Хранилище сообщений + +- In-memory: `state.chats[chatId]` — массив сообщений по каждому диалогу. +- Персистентно: IndexedDB база `shine-ui-messages-v1`, object store `messages`, ключ `messageKey`. +- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`. + +### Жизненный цикл при старте/подключении + +1. `hydrateMessagesFromStore()` — читает все сообщения из IndexedDB в `state.chats` (до WebSocket-соединения). +2. После установки WebSocket-сессии сервер присылает backlog (`SignedMessageArrived(backlog=true)`) для всех недоставленных сообщений. +3. Клиент дедублирует через `knownMessageKeys` — уже имеющиеся в IndexedDB игнорируются. +4. Новые сообщения в реальном времени приходят теми же WebSocket-событиями. + +### Очистка при выходе и смене пользователя + +- При любом логауте (`terminateCurrentSession`) IndexedDB с сообщениями **удаляется полностью**. +- При входе нового пользователя через QR — IndexedDB удаляется явно до вызова `terminateCurrentSession`. +- При входе нового пользователя через логин/пароль — IndexedDB удаляется в `registration-keys-view.js` прямо перед `authorizeSession()`. +- Это гарантирует: при любом способе входа старые сообщения предыдущего пользователя не попадут к следующему. + +### UI-поведение -- чат хранится в `state.chats[chatId]`; -- `chatId` для `type=1` — `fromLogin`, для `type=2` — `toLogin`; - непрочитанные считаются по `from='in' && unread=true`; - доставка/прочтение исходящих: - - `firstTick` — сообщение принято в парный поток, + - `firstTick` — сообщение принято сервером, - `secondTick` — пришло подтверждение прочтения; - при открытии диалога UI автопрокручивает ленту в самый низ; -- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости; -- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`). +- после отправки нового сообщения UI сразу прокручивает ленту вниз. ## 10) Инварианты (обязательно соблюдать при доработках) diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java index a4aebfe..f0a46e6 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SignedMessagesV2DAO.java @@ -182,7 +182,7 @@ public final class SignedMessagesV2DAO { } } - public List listPendingForSession(String login, String sessionId, int limit) throws Exception { + public List listPendingForSession(String login, String sessionId) throws Exception { try (Connection c = db.getConnection()) { String fillSql = """ INSERT OR IGNORE INTO signed_message_session_delivery ( @@ -210,12 +210,10 @@ public final class SignedMessagesV2DAO { ON d.message_key = m.message_key WHERE d.session_id = ? AND d.delivered = 0 ORDER BY m.time_ms ASC, m.created_at_ms ASC - LIMIT ? """; List out = new ArrayList<>(); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, sessionId); - ps.setInt(2, Math.max(1, limit)); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index 97d482d..76019e3 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -19,8 +19,6 @@ import java.util.List; public final class SignedMessagesRealtime { private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class); private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final int LOGIN_BACKLOG_LIMIT = 500; - private SignedMessagesRealtime() {} static DeliveryCounters deliverToTargetSessions( @@ -57,7 +55,7 @@ public final class SignedMessagesRealtime { try { List pending = SignedMessagesV2DAO.getInstance() - .listPendingForSession(login, sessionId, LOGIN_BACKLOG_LIMIT); + .listPendingForSession(login, sessionId); for (SignedMessageV2Entry e : pending) { sendEventToSessionIfOnline(sessionId, e, true); } diff --git a/VERSION.properties b/VERSION.properties index 94754bc..ab8b1d5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.103 -server.version=1.2.97 +client.version=1.2.106 +server.version=1.2.99 diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index 292ba26..0121bd6 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -11,6 +11,7 @@ import { writeChannelNotificationsState, } from '../services/channels-ux.js'; import { makeShineChannelRoute } from '../services/shine-routes.js'; +import { navigateBack } from '../router.js'; export const pageMeta = { id: 'channels-list', title: 'Каналы' }; @@ -1176,26 +1177,12 @@ export function render({ navigate, route }) { const topBarLeft = document.createElement('div'); topBarLeft.className = 'channels-top-left'; - const backToFeedBtn = document.createElement('button'); - backToFeedBtn.type = 'button'; - backToFeedBtn.className = 'icon-btn channels-top-back-btn'; - backToFeedBtn.textContent = '←'; - backToFeedBtn.setAttribute('aria-label', 'Назад'); - backToFeedBtn.addEventListener('click', () => { - if (listState.activeTab === 'feed') return; - listState.activeTab = 'feed'; - rerenderList(); - }); - - const allChannelsBtn = document.createElement('button'); - allChannelsBtn.type = 'button'; - allChannelsBtn.className = 'secondary-btn channels-top-switch-btn'; - allChannelsBtn.textContent = 'Все каналы'; - allChannelsBtn.addEventListener('click', () => { - if (listState.activeTab === 'feed') return; - listState.activeTab = 'feed'; - rerenderList(); - }); + const backBtn = document.createElement('button'); + backBtn.type = 'button'; + backBtn.className = 'icon-btn channels-top-back-btn'; + backBtn.textContent = '←'; + backBtn.setAttribute('aria-label', 'Назад'); + backBtn.addEventListener('click', () => navigateBack()); const topTitle = document.createElement('strong'); topTitle.className = 'channels-top-title'; @@ -1227,7 +1214,17 @@ export function render({ navigate, route }) { createInMyBtn.setAttribute('aria-label', 'Создать канал'); createInMyBtn.addEventListener('click', () => navigate('add-channel-view')); - topBarLeft.append(backToFeedBtn, allChannelsBtn, topTitle); + const switchToAllBtn = document.createElement('button'); + switchToAllBtn.type = 'button'; + switchToAllBtn.className = 'secondary-btn channels-top-switch-btn'; + switchToAllBtn.textContent = 'Все каналы'; + switchToAllBtn.addEventListener('click', () => { + if (listState.activeTab === 'feed') return; + listState.activeTab = 'feed'; + rerenderList(); + }); + + topBarLeft.append(backBtn, topTitle); topBarRight.append(myChannelsBtn, findChannelBtn, createInMyBtn); topBarEl.append(topBarLeft, topBarRight); @@ -1259,19 +1256,21 @@ export function render({ navigate, route }) { }); if (listState.activeTab === 'my' && !isGuest) { - backToFeedBtn.style.display = ''; - allChannelsBtn.style.display = ''; myChannelsBtn.style.display = 'none'; topTitle.textContent = 'Мои каналы'; findChannelBtn.style.display = 'none'; + switchToAllBtn.style.display = ''; createInMyBtn.style.display = ''; + if (!switchToAllBtn.isConnected) topBarLeft.append(switchToAllBtn); + if (topTitle.parentElement !== topBarRight) topBarRight.prepend(topTitle); } else { - backToFeedBtn.style.display = 'none'; - allChannelsBtn.style.display = 'none'; myChannelsBtn.style.display = isGuest ? 'none' : ''; topTitle.textContent = 'Все каналы'; findChannelBtn.style.display = ''; + switchToAllBtn.style.display = 'none'; createInMyBtn.style.display = 'none'; + if (switchToAllBtn.isConnected) switchToAllBtn.remove(); + if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle); } updateBottomCta({ @@ -1317,6 +1316,9 @@ export function render({ navigate, route }) { isTabEmpty: true, }); + // Применяем корректное состояние хедера сразу на первом рендере, + // чтобы не показывать лишние кнопки до первой перерисовки. + rerenderList(); loadFeedAndRender({ screen, listState, contentEl, navigate }); screen.cleanup = () => { diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index 22feaac..8543d15 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -7,6 +7,7 @@ import { setAuthInfo, state, } from '../state.js'; +import { clearStoredMessages } from '../services/message-store.js'; import { toUserMessage } from '../services/ui-error-texts.js'; export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false }; @@ -111,6 +112,8 @@ export function render({ navigate }) { state.registrationDraft.pendingKeyBundle.blockchainPair = null; } + await clearStoredMessages().catch(() => {}); + authorizeSession({ login: state.registrationDraft.login, sessionId: state.registrationDraft.sessionId, diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index de16c63..fc2614e 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -1,5 +1,5 @@ import { AuthService } from './services/auth-service.js'; -import { listStoredMessages, putStoredMessage } from './services/message-store.js'; +import { listStoredMessages, putStoredMessage, clearStoredMessages } from './services/message-store.js'; import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js'; const clone = (value) => JSON.parse(JSON.stringify(value)); @@ -752,6 +752,7 @@ function resetStateForSignedOut() { export async function terminateCurrentSession({ infoMessage = '' } = {}) { clearStoredSession(); resetStateForSignedOut(); + await clearStoredMessages().catch(() => {}); authService.close(); if (infoMessage) { state.startHint = infoMessage;