Обновить UI каналов, логаут DM и документацию
- Исправлена вкладка Каналы: стабильные режимы Все/Мои, корректные кнопки и навигация назад. - Зафиксирована доработка по личным сообщениям: при logout очищается локальная база/кеш DM на устройстве. - Обновлены AGENTS/CLAUDE и документация Personal_Messages. - Обновлены версии в VERSION.properties (client 1.2.106, server 1.2.99).
This commit is contained in:
parent
5899bd2f77
commit
e3c1cbf1c0
@ -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-бот-сервис агента-кодера в папке `SHiNE-agent-bot-coder/`.
|
||||||
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
- Сервис принимает сообщения из Telegram, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
||||||
|
|||||||
@ -1,2 +1,9 @@
|
|||||||
@AGENTS.md
|
@AGENTS.md
|
||||||
@AGENT_DEBUG_RUNBOOK.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`.
|
||||||
|
|||||||
@ -162,9 +162,13 @@ UI чата строится на этих типах: текстовые соо
|
|||||||
## 7) Доставка и backlog
|
## 7) Доставка и backlog
|
||||||
|
|
||||||
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
- При сохранении пары сервер пытается сразу доставить в онлайн-сессии.
|
||||||
- Для офлайн/недоступных сессий остаётся pending-запись доставки.
|
- Для офлайн/недоступных сессий остаётся pending-запись доставки в таблице `signed_message_session_delivery`.
|
||||||
- При подключении сессии сервер догружает pending (`listPendingForSession`) и шлёт `SignedMessageArrived(backlog=true)`.
|
- При подключении сессии сервер автоматически вызывает `dispatchPendingForSession`:
|
||||||
- После получения клиент должен отправить `AckSessionDelivery`, чтобы отметить `delivered=1`.
|
- для новой сессии регистрирует все существующие сообщения адресата как «недоставленные»;
|
||||||
|
- отправляет **все** pending через WebSocket событием `SignedMessageArrived(backlog=true)`;
|
||||||
|
- лимита на количество сообщений нет — передаётся вся история без ограничений.
|
||||||
|
- Клиент дедублирует входящие через `knownMessageKeys`: если `messageKey` уже есть локально — игнорирует.
|
||||||
|
- После получения клиент отправляет `AckSessionDelivery`, чтобы отметить `delivered=1` в таблице доставки.
|
||||||
|
|
||||||
## 8) Read-receipt логика
|
## 8) Read-receipt логика
|
||||||
|
|
||||||
@ -183,17 +187,34 @@ UI чата строится на этих типах: текстовые соо
|
|||||||
|
|
||||||
## 9) Логика 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`;
|
- непрочитанные считаются по `from='in' && unread=true`;
|
||||||
- доставка/прочтение исходящих:
|
- доставка/прочтение исходящих:
|
||||||
- `firstTick` — сообщение принято в парный поток,
|
- `firstTick` — сообщение принято сервером,
|
||||||
- `secondTick` — пришло подтверждение прочтения;
|
- `secondTick` — пришло подтверждение прочтения;
|
||||||
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
- при открытии диалога UI автопрокручивает ленту в самый низ;
|
||||||
- после отправки нового сообщения UI сразу автопрокручивает ленту вниз, чтобы новое сообщение было в зоне видимости;
|
- после отправки нового сообщения UI сразу прокручивает ленту вниз.
|
||||||
- сообщения дополнительно кешируются в IndexedDB (`shine-ui-messages-v1`, store `messages`).
|
|
||||||
|
|
||||||
## 10) Инварианты (обязательно соблюдать при доработках)
|
## 10) Инварианты (обязательно соблюдать при доработках)
|
||||||
|
|
||||||
|
|||||||
@ -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()) {
|
try (Connection c = db.getConnection()) {
|
||||||
String fillSql = """
|
String fillSql = """
|
||||||
INSERT OR IGNORE INTO signed_message_session_delivery (
|
INSERT OR IGNORE INTO signed_message_session_delivery (
|
||||||
@ -210,12 +210,10 @@ public final class SignedMessagesV2DAO {
|
|||||||
ON d.message_key = m.message_key
|
ON d.message_key = m.message_key
|
||||||
WHERE d.session_id = ? AND d.delivered = 0
|
WHERE d.session_id = ? AND d.delivered = 0
|
||||||
ORDER BY m.time_ms ASC, m.created_at_ms ASC
|
ORDER BY m.time_ms ASC, m.created_at_ms ASC
|
||||||
LIMIT ?
|
|
||||||
""";
|
""";
|
||||||
List<SignedMessageV2Entry> out = new ArrayList<>();
|
List<SignedMessageV2Entry> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, sessionId);
|
ps.setString(1, sessionId);
|
||||||
ps.setInt(2, Math.max(1, limit));
|
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) out.add(mapRow(rs));
|
while (rs.next()) out.add(mapRow(rs));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,6 @@ import java.util.List;
|
|||||||
public final class SignedMessagesRealtime {
|
public final class SignedMessagesRealtime {
|
||||||
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
private static final Logger log = LoggerFactory.getLogger(SignedMessagesRealtime.class);
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
private static final int LOGIN_BACKLOG_LIMIT = 500;
|
|
||||||
|
|
||||||
private SignedMessagesRealtime() {}
|
private SignedMessagesRealtime() {}
|
||||||
|
|
||||||
static DeliveryCounters deliverToTargetSessions(
|
static DeliveryCounters deliverToTargetSessions(
|
||||||
@ -57,7 +55,7 @@ public final class SignedMessagesRealtime {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
List<SignedMessageV2Entry> pending = SignedMessagesV2DAO.getInstance()
|
||||||
.listPendingForSession(login, sessionId, LOGIN_BACKLOG_LIMIT);
|
.listPendingForSession(login, sessionId);
|
||||||
for (SignedMessageV2Entry e : pending) {
|
for (SignedMessageV2Entry e : pending) {
|
||||||
sendEventToSessionIfOnline(sessionId, e, true);
|
sendEventToSessionIfOnline(sessionId, e, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.103
|
client.version=1.2.106
|
||||||
server.version=1.2.97
|
server.version=1.2.99
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
writeChannelNotificationsState,
|
writeChannelNotificationsState,
|
||||||
} from '../services/channels-ux.js';
|
} from '../services/channels-ux.js';
|
||||||
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
import { makeShineChannelRoute } from '../services/shine-routes.js';
|
||||||
|
import { navigateBack } from '../router.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
|
||||||
|
|
||||||
@ -1176,26 +1177,12 @@ export function render({ navigate, route }) {
|
|||||||
const topBarLeft = document.createElement('div');
|
const topBarLeft = document.createElement('div');
|
||||||
topBarLeft.className = 'channels-top-left';
|
topBarLeft.className = 'channels-top-left';
|
||||||
|
|
||||||
const backToFeedBtn = document.createElement('button');
|
const backBtn = document.createElement('button');
|
||||||
backToFeedBtn.type = 'button';
|
backBtn.type = 'button';
|
||||||
backToFeedBtn.className = 'icon-btn channels-top-back-btn';
|
backBtn.className = 'icon-btn channels-top-back-btn';
|
||||||
backToFeedBtn.textContent = '←';
|
backBtn.textContent = '←';
|
||||||
backToFeedBtn.setAttribute('aria-label', 'Назад');
|
backBtn.setAttribute('aria-label', 'Назад');
|
||||||
backToFeedBtn.addEventListener('click', () => {
|
backBtn.addEventListener('click', () => navigateBack());
|
||||||
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 topTitle = document.createElement('strong');
|
const topTitle = document.createElement('strong');
|
||||||
topTitle.className = 'channels-top-title';
|
topTitle.className = 'channels-top-title';
|
||||||
@ -1227,7 +1214,17 @@ export function render({ navigate, route }) {
|
|||||||
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
createInMyBtn.setAttribute('aria-label', 'Создать канал');
|
||||||
createInMyBtn.addEventListener('click', () => navigate('add-channel-view'));
|
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);
|
topBarRight.append(myChannelsBtn, findChannelBtn, createInMyBtn);
|
||||||
topBarEl.append(topBarLeft, topBarRight);
|
topBarEl.append(topBarLeft, topBarRight);
|
||||||
|
|
||||||
@ -1259,19 +1256,21 @@ export function render({ navigate, route }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (listState.activeTab === 'my' && !isGuest) {
|
if (listState.activeTab === 'my' && !isGuest) {
|
||||||
backToFeedBtn.style.display = '';
|
|
||||||
allChannelsBtn.style.display = '';
|
|
||||||
myChannelsBtn.style.display = 'none';
|
myChannelsBtn.style.display = 'none';
|
||||||
topTitle.textContent = 'Мои каналы';
|
topTitle.textContent = 'Мои каналы';
|
||||||
findChannelBtn.style.display = 'none';
|
findChannelBtn.style.display = 'none';
|
||||||
|
switchToAllBtn.style.display = '';
|
||||||
createInMyBtn.style.display = '';
|
createInMyBtn.style.display = '';
|
||||||
|
if (!switchToAllBtn.isConnected) topBarLeft.append(switchToAllBtn);
|
||||||
|
if (topTitle.parentElement !== topBarRight) topBarRight.prepend(topTitle);
|
||||||
} else {
|
} else {
|
||||||
backToFeedBtn.style.display = 'none';
|
|
||||||
allChannelsBtn.style.display = 'none';
|
|
||||||
myChannelsBtn.style.display = isGuest ? 'none' : '';
|
myChannelsBtn.style.display = isGuest ? 'none' : '';
|
||||||
topTitle.textContent = 'Все каналы';
|
topTitle.textContent = 'Все каналы';
|
||||||
findChannelBtn.style.display = '';
|
findChannelBtn.style.display = '';
|
||||||
|
switchToAllBtn.style.display = 'none';
|
||||||
createInMyBtn.style.display = 'none';
|
createInMyBtn.style.display = 'none';
|
||||||
|
if (switchToAllBtn.isConnected) switchToAllBtn.remove();
|
||||||
|
if (topTitle.parentElement !== topBarLeft) topBarLeft.append(topTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBottomCta({
|
updateBottomCta({
|
||||||
@ -1317,6 +1316,9 @@ export function render({ navigate, route }) {
|
|||||||
isTabEmpty: true,
|
isTabEmpty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||||
|
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||||
|
rerenderList();
|
||||||
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||||
|
|
||||||
screen.cleanup = () => {
|
screen.cleanup = () => {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
setAuthInfo,
|
setAuthInfo,
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
|
import { clearStoredMessages } from '../services/message-store.js';
|
||||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||||
|
|
||||||
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
export const pageMeta = { id: 'registration-keys-view', title: 'Сохранение ключей', showAppChrome: false };
|
||||||
@ -111,6 +112,8 @@ export function render({ navigate }) {
|
|||||||
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
state.registrationDraft.pendingKeyBundle.blockchainPair = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await clearStoredMessages().catch(() => {});
|
||||||
|
|
||||||
authorizeSession({
|
authorizeSession({
|
||||||
login: state.registrationDraft.login,
|
login: state.registrationDraft.login,
|
||||||
sessionId: state.registrationDraft.sessionId,
|
sessionId: state.registrationDraft.sessionId,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { AuthService } from './services/auth-service.js';
|
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';
|
import { SOLANA_ENDPOINT_DEFAULT } from './solana-programs.js';
|
||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
@ -752,6 +752,7 @@ function resetStateForSignedOut() {
|
|||||||
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
||||||
clearStoredSession();
|
clearStoredSession();
|
||||||
resetStateForSignedOut();
|
resetStateForSignedOut();
|
||||||
|
await clearStoredMessages().catch(() => {});
|
||||||
authService.close();
|
authService.close();
|
||||||
if (infoMessage) {
|
if (infoMessage) {
|
||||||
state.startHint = infoMessage;
|
state.startHint = infoMessage;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user