Обновить 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, ведёт историю диалога, ставит задачи в очередь и вызывает Codex CLI для обработки запросов по проекту.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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) Инварианты (обязательно соблюдать при доработках)
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
client.version=1.2.103
|
||||
server.version=1.2.97
|
||||
client.version=1.2.106
|
||||
server.version=1.2.99
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user