Обновить UI каналов, логаут DM и документацию

- Исправлена вкладка Каналы: стабильные режимы Все/Мои, корректные кнопки и навигация назад.

- Зафиксирована доработка по личным сообщениям: при logout очищается локальная база/кеш DM на устройстве.

- Обновлены AGENTS/CLAUDE и документация Personal_Messages.

- Обновлены версии в VERSION.properties (client 1.2.106, server 1.2.99).
This commit is contained in:
AidarKC 2026-05-31 20:30:31 +04:00
parent 5899bd2f77
commit e3c1cbf1c0
9 changed files with 79 additions and 43 deletions

View File

@ -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 для обработки запросов по проекту.

View File

@ -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`.

View File

@ -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) Инварианты (обязательно соблюдать при доработках)

View File

@ -182,7 +182,7 @@ public final class SignedMessagesV2DAO {
}
}
public List<SignedMessageV2Entry> listPendingForSession(String login, String sessionId, int limit) throws Exception {
public List<SignedMessageV2Entry> 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<SignedMessageV2Entry> 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));
}

View File

@ -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<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
.listPendingForSession(login, sessionId, LOGIN_BACKLOG_LIMIT);
.listPendingForSession(login, sessionId);
for (SignedMessageV2Entry e : pending) {
sendEventToSessionIfOnline(sessionId, e, true);
}

View File

@ -1,2 +1,2 @@
client.version=1.2.103
server.version=1.2.97
client.version=1.2.106
server.version=1.2.99

View File

@ -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 = () => {

View File

@ -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,

View File

@ -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;