Add handoff task document for PWA, chats, and connections

This commit is contained in:
ai5590 2026-04-05 12:13:05 +03:00
parent cf5460c5c7
commit bc8f4a0582
46 changed files with 1671 additions and 106 deletions

52
DOC/api/PWA_FCM_SETUP.md Normal file
View File

@ -0,0 +1,52 @@
# Настройка PWA + FCM для веб-клиента (Chrome/Edge/Firefox/Safari iOS)
## 1) Что нужно создать в Firebase
1. Создать проект Firebase.
2. Включить Cloud Messaging.
3. Создать Web App и получить конфиг:
- apiKey
- authDomain
- projectId
- messagingSenderId
- appId
4. В Cloud Messaging -> Web Push certificates сгенерировать VAPID key.
5. Для серверной отправки взять **Server key** (legacy) или настроить HTTP v1 (service account).
## 2) Куда вставить токены в клиенте
Файл: `shine-UI/index.html` и `shine-UI/firebase-messaging-sw.js`.
Заполнить:
- `window.__SHINE_FIREBASE_CONFIG__`
- `window.__SHINE_FIREBASE_VAPID_KEY__`
- `FIREBASE_CONFIG` (в service worker)
## 3) Куда вставить серверный ключ FCM
Файл: `src/main/resources/application.properties`
Добавить:
```
fcm.server.key=YOUR_FCM_SERVER_KEY
```
## 4) PWA требования
1. Открывать сайт только по HTTPS (или localhost).
2. Разрешить уведомления в браузере.
3. Убедиться, что `manifest.webmanifest` доступен.
4. Убедиться, что `firebase-messaging-sw.js` зарегистрирован.
## 5) Safari / iPhone (iOS)
- Нужен iOS 16.4+.
- Пользователь должен добавить сайт на Home Screen.
- После запуска PWA с Home Screen дать разрешение на уведомления.
- Без Home Screen web push в Safari iOS не работает.
## 6) Проверка
1. Логин в приложении.
2. Клиент вызывает `UpsertPushToken` и отправляет FCM токен на сервер.
3. Вызов `SendDirectMessage` пользователю без активной WS доставки.
4. Сервер шлет push через FCM.
## 7) Поддержка разных браузеров
- Chrome/Edge/Opera/Android Browser: FCM web push поддерживается нативно.
- Firefox: поддержка web push есть, но тестировать отдельно (поведение токенов отличается).
- Safari macOS/iOS: web push есть, но требуется PWA режим и Apple-ограничения.

181
TASKS/CHAT_PWA_HANDOFF.md Normal file
View File

@ -0,0 +1,181 @@
# Анонс для исполнителя (кратко)
Ниже описан текущий статус ветки по функциям:
- PWA + FCM уведомления
- личные сообщения (вторая слева вкладка, "Личные сообщения")
- связи / близкие друзья (центральная вкладка)
- server-push события (в том числе закрытие сессии)
## Что нужно проверить и довести до рабочего состояния
1. **PWA/FCM**: регистрация service worker, получение FCM token, отправка token на сервер, получение foreground/background уведомлений.
2. **Личные сообщения**: отправка первого сообщения любому пользователю, входящие по WS, ACK, fallback в FCM, дедупликация на клиенте.
3. **Связи / близкие друзья**: поиск пользователя, добавление в близкие, обновление графа связей сразу после добавления.
4. **SessionRevoked**: если сессию закрыли с другого устройства, клиент должен корректно завершить локальную сессию и показать понятное состояние.
## Главная цель handoff
Сделать так, чтобы все описанные сценарии стабильно работали end-to-end без ручных "подпинок".
---
# Подробное ТЗ и технические детали
## 1) Архитектура протокола и общие принципы
### 1.1 Формат обмена
Используется JSON-over-WebSocket.
- Для запрос-ответ: `op + requestId + payload`
- Для server-push событий: те же поля, но `event=true`, `requestId` используется как eventId.
### 1.2 ID сообщений/событий
ID генерируются в формате:
- `prefix-yyyyMMdd-HHmmss-SSS-random10`
Реализация: `NetIdGenerator`.
### 1.3 Роли каналов доставки
- **WS** — приоритетная доставка в активные сессии.
- **FCM** — fallback, если ACK по WS не получен или WS-сессия отсутствует.
---
## 2) PWA + FCM
## 2.1 Что уже добавлено
### Клиент
- `shine-UI/manifest.webmanifest`
- `shine-UI/firebase-messaging-sw.js`
- `shine-UI/js/services/pwa-push-service.js`
- `shine-UI/index.html` содержит placeholders:
- `window.__SHINE_FIREBASE_CONFIG__`
- `window.__SHINE_FIREBASE_VAPID_KEY__`
### Сервер
- API `UpsertPushToken`
- Таблица `user_push_tokens`
- Серверный FCM sender (legacy HTTP): `FcmPushSender`
- Конфиг: `fcm.server.key` в `application.properties`
## 2.2 Как должно работать (целевое поведение)
1. Клиент после авторизации регистрирует SW.
2. Просит permission на notifications.
3. Получает FCM token.
4. Если token новый/изменился — отправляет `UpsertPushToken`.
5. Сервер сохраняет token за конкретной сессией.
## 2.3 Что проверить/доделать
- Синхронизация Firebase config между `index.html` и `firebase-messaging-sw.js`.
- Работа iOS Safari (PWA через Home Screen).
- Поведение при смене token.
- Надёжность `FcmPushSender` (таймауты, логирование ошибок ответа).
---
## 3) Вторая слева вкладка: Личные сообщения / Чаты
## 3.1 Продуктовая логика (как должно быть)
1. Открытие списка диалогов на вкладке "Личные сообщения".
2. Кнопка `+` открывает поиск пользователя по префиксу логина (`SearchUsers`).
3. **Первое сообщение можно отправить любому пользователю**, даже если он не контакт.
4. Если пользователь не в контактах — в чате показывается действие "Добавить в контакты".
5. Входящее сообщение может прийти:
- напрямую по WS (`IncomingDirectMessage`)
- через FCM (fallback)
6. На клиенте дедупликация должна быть по `messageId`.
## 3.2 Что уже сделано технически
### Сервер
- `SendDirectMessage`
- `AckIncomingMessage`
- `direct_messages` таблица + DAO
- доставка по активным WS-сессиям + ожидание ACK + fallback в FCM
### Клиент
- `authService.sendDirectMessage(...)`
- `authService.ackIncomingMessage(...)`
- WS client поддерживает server events (`onEvent`)
- В `app.js` обработка `IncomingDirectMessage`
- В `state.js` добавлены `incomingDedup`, `addIncomingMessage`
## 3.3 Что глючит/что проверить
- Стабильность отображения новых чатов, если сообщение пришло от неизвестного пользователя.
- Согласованность списка диалогов и фактических сообщений.
- Поведение при быстрых дубликатах WS + FCM.
- Поведение при сетевых обрывах/повторном коннекте.
---
## 4) Центральная вкладка: Связи / Близкие друзья
## 4.1 Целевое поведение
1. Экран "Связи" загружает граф друзей (`GetUserConnectionsGraph`).
2. Кнопка "Добавить близкого друга" открывает модалку:
- поле логина/префикса
- кнопка "Поиск"
- кнопка "Назад"
3. Поиск идёт через `SearchUsers`.
4. По клику на найденного — подтверждение "Добавить? Да/Нет".
5. При "Да" вызывается `AddCloseFriend`, после успеха:
- закрыть модалку
- вернуться на экран связей
- обновить граф.
## 4.2 Что уже сделано
- UI-flow модалки реализован на `network-view.js`.
- API `AddCloseFriend` добавлен на сервере.
- `ConnectionsStateDAO.upsertRelation(...)` добавлен для upsert связи FRIEND.
## 4.3 Что проверить/доделать
- Валидация edge-cases (добавление самого себя, несуществующий логин).
- Корректность отрисовки графа после добавления.
- Согласованность данных с будущей "настоящей" записью в blockchain (сейчас MVP upsert в `connections_state`).
---
## 5) SessionRevoked и мультисессии
## 5.1 Целевое поведение
Если с другой сессии закрыли текущую сессию:
1. сервер шлёт событие `SessionRevoked`
2. клиент чистит локальную авторизацию
3. клиент возвращается в состояние неавторизованного входа.
## 5.2 Что проверить
- Что событие приходит до закрытия socket.
- Что UX не "зависает" на промежуточном экране.
---
## 6) API-операции (быстрый список)
### Уже используемые/добавленные в ветке
- `SearchUsers`
- `ListContacts`
- `GetUserConnectionsGraph`
- `AddCloseFriend`
- `UpsertPushToken`
- `SendDirectMessage`
- `AckIncomingMessage`
- `CloseActiveSession` (с server event `SessionRevoked`)
---
## 7) Минимальный чек-лист тестирования для нового исполнителя
1. Авторизация пользователя A и B в разных браузерах/устройствах.
2. A отправляет первое сообщение B (без контактов) — должно уйти.
3. B получает по WS, отправляется ACK, fallback FCM не должен дублировать.
4. Выключить WS у B и проверить fallback FCM.
5. Вкладка "Связи": добавить близкого друга через поиск, проверить обновление графа.
6. Закрыть сессию B с другого устройства и проверить `SessionRevoked` UX.
7. Перезапуск клиента: проверка повторной регистрации push-токена только при изменении.
---
## 8) Важное ограничение текущей реализации
Некоторые части реализованы как MVP и требуют стабилизации:
- местами UI/состояние могут рассинхронизироваться;
- fallback WS->FCM может требовать донастройки таймингов и ретраев;
- `AddCloseFriend` сейчас пишет прямое состояние связи (upsert), а не полный blockchain-поток создания блоков.
Это ожидаемо для handoff: задача следующего исполнителя — довести до production-стабильности.

View File

@ -0,0 +1,30 @@
/* global importScripts, firebase */
importScripts('https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.12.2/firebase-messaging-compat.js');
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
// Заполните теми же значениями, что и в shine-UI/index.html
const FIREBASE_CONFIG = {
apiKey: '',
authDomain: '',
projectId: '',
messagingSenderId: '',
appId: '',
};
if (FIREBASE_CONFIG.apiKey && firebase && firebase.messaging) {
if (!firebase.apps.length) {
firebase.initializeApp(FIREBASE_CONFIG);
}
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
const title = payload?.notification?.title || 'Новое сообщение';
const options = {
body: payload?.notification?.body || '',
data: payload?.data || {},
};
self.registration.showNotification(title, options);
});
}

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="manifest" href="./manifest.webmanifest" />
<title>Shine UI Demo</title> <title>Shine UI Demo</title>
<link rel="stylesheet" href="./styles/main.css?v=20260403081123" /> <link rel="stylesheet" href="./styles/main.css?v=20260403081123" />
<link rel="stylesheet" href="./styles/layout.css?v=20260403081123" /> <link rel="stylesheet" href="./styles/layout.css?v=20260403081123" />
@ -11,10 +12,21 @@
<body> <body>
<div class="app-shell"> <div class="app-shell">
<main id="app-screen" class="screen-content"></main> <main id="app-screen" class="screen-content"></main>
<div id="page-label-slot" class="page-label-slot"></div>
<div id="toolbar-slot" class="toolbar-slot"></div> <div id="toolbar-slot" class="toolbar-slot"></div>
</div> </div>
<div id="modal-root"></div> <div id="modal-root"></div>
<script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.12.2/firebase-messaging-compat.js"></script>
<script>
window.__SHINE_FIREBASE_CONFIG__ = {
apiKey: '',
authDomain: '',
projectId: '',
messagingSenderId: '',
appId: ''
};
window.__SHINE_FIREBASE_VAPID_KEY__ = '';
</script>
<script type="module" src="./js/app.js?v=20260403081123"></script> <script type="module" src="./js/app.js?v=20260403081123"></script>
</body> </body>
</html> </html>

View File

@ -1,7 +1,7 @@
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260403081123'; import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js?v=20260403081123';
import { renderToolbar } from './components/toolbar.js?v=20260403081123'; import { renderToolbar } from './components/toolbar.js?v=20260403081123';
import { renderPageLabel } from './components/page-label.js?v=20260403081123';
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js?v=20260403081123'; import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js?v=20260403081123';
import { initPwaPush } from './services/pwa-push-service.js?v=20260403081123';
import { import {
authService, authService,
authorizeSession, authorizeSession,
@ -10,7 +10,8 @@ import {
setSessionResetHandler, setSessionResetHandler,
state, state,
terminateCurrentSession, terminateCurrentSession,
togglePageLabel, addIncomingMessage,
setContacts,
} from './state.js?v=20260403081123'; } from './state.js?v=20260403081123';
import * as startView from './pages/start-view.js?v=20260403081123'; import * as startView from './pages/start-view.js?v=20260403081123';
@ -77,7 +78,6 @@ const routes = {
}; };
const screenEl = document.getElementById('app-screen'); const screenEl = document.getElementById('app-screen');
const labelEl = document.getElementById('page-label-slot');
const toolbarEl = document.getElementById('toolbar-slot'); const toolbarEl = document.getElementById('toolbar-slot');
let currentCleanup = null; let currentCleanup = null;
@ -140,16 +140,9 @@ function renderApp() {
const showAppChrome = page.pageMeta?.showAppChrome !== false; const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome); screenEl.classList.toggle('no-app-chrome', !showAppChrome);
labelEl.innerHTML = '';
toolbarEl.innerHTML = ''; toolbarEl.innerHTML = '';
if (showAppChrome) { if (showAppChrome) {
labelEl.append(
renderPageLabel(page.pageMeta.title, page.pageMeta.id, state.pageLabelCollapsed, () => {
togglePageLabel();
renderApp();
}),
);
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate)); toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
} }
} }
@ -161,6 +154,10 @@ async function tryAutoLogin() {
const resumed = await authService.resumeSession(state.session.login, state.session.sessionId); const resumed = await authService.resumeSession(state.session.login, state.session.sessionId);
authorizeSession(resumed); authorizeSession(resumed);
await refreshSessions(); await refreshSessions();
try {
const contacts = await authService.listContacts();
setContacts(contacts.contacts || []);
} catch {}
} catch (error) { } catch (error) {
if (isSessionInvalidError(error)) { if (isSessionInvalidError(error)) {
await terminateCurrentSession({ await terminateCurrentSession({
@ -174,7 +171,30 @@ async function init() {
setSessionResetHandler(() => { setSessionResetHandler(() => {
navigate('start-view'); navigate('start-view');
}); });
authService.onEvent('SessionRevoked', async () => {
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
});
authService.onEvent('IncomingDirectMessage', async (evt) => {
const payload = evt?.payload || {};
const fromLogin = payload.fromLogin || 'unknown';
const messageId = payload.messageId || '';
const eventId = payload.eventId || evt?.requestId || '';
const added = addIncomingMessage(fromLogin, payload.text || '', messageId);
if (added && Notification.permission === 'granted') {
try {
new Notification(`Сообщение от ${fromLogin}`, { body: payload.text || '' });
} catch {}
}
if (eventId) {
try { await authService.ackIncomingMessage(eventId, messageId); } catch {}
}
});
await tryAutoLogin(); await tryAutoLogin();
if (state.session.isAuthorized) {
await initPwaPush({ authService });
}
if (!window.location.hash) { if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view'); navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');

View File

@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.js?v=20260403081123'; import { renderHeader } from '../components/header.js?v=20260403081123';
import { directMessages } from '../mock-data.js?v=20260403081123'; import { directMessages } from '../mock-data.js?v=20260403081123';
import { addChatMessage, getChatMessages } from '../state.js?v=20260403081123'; import { addChatMessage, getChatMessages, authService, state } from '../state.js?v=20260403081123';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; export const pageMeta = { id: 'chat-view', title: 'Чат' };
@ -18,7 +18,11 @@ function renderLog(list, chatId) {
export function render({ navigate, route }) { export function render({ navigate, route }) {
const chatId = route.params.chatId || 'u1'; const chatId = route.params.chatId || 'u1';
const contact = directMessages.find((d) => d.id === chatId) || directMessages[0]; const contact = directMessages.find((d) => d.id === chatId) || {
id: chatId,
name: chatId,
initials: (chatId[0] || '?').toUpperCase(),
};
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -30,6 +34,23 @@ export function render({ navigate, route }) {
}) })
); );
const isContact = state.contacts.includes(chatId);
if (!isContact) {
const warning = document.createElement('div');
warning.className = 'card stack';
warning.innerHTML = '<p class="meta-muted">Пользователь не в контактах. Можно писать ему сразу (MVP).</p>';
const btn = document.createElement('button');
btn.className = 'primary-btn';
btn.type = 'button';
btn.textContent = 'Добавить в контакты';
btn.addEventListener('click', () => {
state.contacts = [...state.contacts, chatId];
warning.remove();
});
warning.append(btn);
screen.append(warning);
}
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'chat-wrap'; wrap.className = 'chat-wrap';
@ -43,12 +64,22 @@ export function render({ navigate, route }) {
<button class="primary-btn" type="submit">Отправить</button> <button class="primary-btn" type="submit">Отправить</button>
`; `;
form.addEventListener('submit', (event) => { form.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const input = form.elements.message; const input = form.elements.message;
addChatMessage(chatId, input.value); const text = input.value.trim();
if (!text) return;
addChatMessage(chatId, text);
input.value = ''; input.value = '';
renderLog(log, chatId); renderLog(log, chatId);
try {
await authService.sendDirectMessage(chatId, text);
} catch (e) {
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
renderLog(log, chatId);
}
}); });
renderLog(log, chatId); renderLog(log, chatId);

View File

@ -1,18 +1,9 @@
import { renderHeader } from '../components/header.js?v=20260403081123'; import { renderHeader } from '../components/header.js?v=20260403081123';
import { contactDirectory, directMessages } from '../mock-data.js?v=20260403081123'; import { directMessages } from '../mock-data.js?v=20260403081123';
import { ensureChat } from '../state.js?v=20260403081123'; import { authService, ensureChat, setContacts, state } from '../state.js?v=20260403081123';
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' }; export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
function getMatches(query) {
const normalized = query.trim().toLowerCase();
if (!normalized) return [];
return contactDirectory
.filter((contact) => contact.name.toLowerCase().startsWith(normalized))
.slice(0, 5);
}
export function render({ navigate }) { export function render({ navigate }) {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
@ -21,7 +12,7 @@ export function render({ navigate }) {
input.className = 'input'; input.className = 'input';
input.type = 'text'; input.type = 'text';
input.name = 'contact'; input.name = 'contact';
input.placeholder = 'Введите имя контакта'; input.placeholder = 'Введите начало логина';
input.autocomplete = 'off'; input.autocomplete = 'off';
input.maxLength = 80; input.maxLength = 80;
@ -43,7 +34,7 @@ export function render({ navigate }) {
resultsCard.hidden = false; resultsCard.hidden = false;
if (!query.trim()) { if (!query.trim()) {
status.textContent = 'Введите первые буквы имени, чтобы найти контакт.'; status.textContent = 'Введите начало логина пользователя.';
return; return;
} }
@ -54,16 +45,16 @@ export function render({ navigate }) {
status.textContent = `Найдено пользователей: ${matches.length}`; status.textContent = `Найдено пользователей: ${matches.length}`;
matches.forEach((contact) => { matches.forEach((login) => {
const row = document.createElement('article'); const row = document.createElement('article');
row.className = 'list-item'; row.className = 'list-item';
row.innerHTML = ` row.innerHTML = `
<div class="avatar">${contact.initials}</div> <div class="avatar">${(login[0] || '?').toUpperCase()}</div>
<div> <div>
<strong>${contact.name}</strong> <strong>${login}</strong>
<p class="meta-muted" style="margin-top:4px;">${contact.about}</p> <p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
</div> </div>
<div class="meta-muted">Контакт</div> <div class="meta-muted">Профиль</div>
`; `;
resultsList.append(row); resultsList.append(row);
}); });
@ -72,38 +63,48 @@ export function render({ navigate }) {
const searchButton = document.createElement('button'); const searchButton = document.createElement('button');
searchButton.className = 'primary-btn'; searchButton.className = 'primary-btn';
searchButton.type = 'button'; searchButton.type = 'button';
searchButton.textContent = 'Найти'; searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', () => { searchButton.addEventListener('click', async () => {
renderResults(getMatches(input.value), input.value); try {
const logins = await authService.searchUsers(input.value.trim());
renderResults(logins, input.value);
} catch (e) {
status.textContent = `Ошибка поиска: ${e.message || 'unknown'}`;
resultsCard.hidden = false;
}
}); });
const addButton = document.createElement('button'); const addButton = document.createElement('button');
addButton.className = 'ghost-btn'; addButton.className = 'ghost-btn';
addButton.type = 'button'; addButton.type = 'button';
addButton.textContent = 'Добавить'; addButton.textContent = 'Открыть чат';
addButton.addEventListener('click', () => { addButton.addEventListener('click', () => {
if (!latestMatches.length) { if (!latestMatches.length) {
status.textContent = 'Сначала выполните поиск, чтобы добавить контакт.'; status.textContent = 'Сначала выполните поиск.';
resultsCard.hidden = false; resultsCard.hidden = false;
return; return;
} }
const contact = latestMatches[0]; const login = latestMatches[0];
const exists = directMessages.some((item) => item.id === contact.id); const exists = directMessages.some((item) => item.id === login);
if (!exists) { if (!exists) {
directMessages.unshift({ directMessages.unshift({
id: contact.id, id: login,
name: contact.name, name: login,
initials: contact.initials, initials: (login[0] || '?').toUpperCase(),
lastMessage: 'Новый контакт добавлен. Можно начинать диалог.', lastMessage: 'Диалог создан. Пользователь пока не в контактах.',
time: 'сейчас', time: 'сейчас',
unread: 0, unread: 0,
}); });
} }
ensureChat(contact.id); if (!state.contacts.includes(login)) {
navigate(`chat-view/${contact.id}`); setContacts([...state.contacts, login]);
}
ensureChat(login);
navigate(`chat-view/${login}`);
}); });
const controls = document.createElement('div'); const controls = document.createElement('div');

View File

@ -1,32 +1,71 @@
import { renderHeader } from '../components/header.js?v=20260403081123'; import { renderHeader } from '../components/header.js?v=20260403081123';
import { networkGraph } from '../mock-data.js?v=20260403081123'; import { authService, state } from '../state.js?v=20260403081123';
export const pageMeta = { id: 'network-view', title: 'Связи' }; export const pageMeta = { id: 'network-view', title: 'Связи' };
function toPoint(v) { function makeNode(name, cls = '') {
return `${v.x}%`; const n = document.createElement('div');
n.className = `node ${cls}`.trim();
n.innerHTML = `<div class="node-dot">${(name[0] || '?').toUpperCase()}</div><div class="node-label">${name}</div>`;
return n;
} }
function showHelpModal() { function showAddCloseFriendModal({ onAdded }) {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
root.innerHTML = ` root.innerHTML = `
<div class="modal" id="network-help-modal"> <div class="modal" id="close-friend-modal">
<div class="modal-card stack"> <div class="modal-card stack">
<h3 style="font-size:18px;">Справка по схеме связей</h3> <h3 style="font-size:18px;">Добавить близкого друга</h3>
<p class="meta-muted">В центре находишься ты.</p> <input class="input" id="close-friend-query" placeholder="Логин или начало логина" maxlength="80" />
<p class="meta-muted">Рядом показаны друзья первого уровня.</p> <div class="row" style="gap:8px;">
<p class="meta-muted">Далее могут существовать друзья второго уровня.</p> <button class="primary-btn" id="close-friend-search">Поиск</button>
<p class="meta-muted">При одном нажатии на узел можно показать его связи.</p> <button class="ghost-btn" id="close-friend-back">Назад</button>
<p class="meta-muted">При двойном нажатии узел может переместиться в центр.</p> </div>
<p class="meta-muted">При долгом удержании может открываться меню действий.</p> <div class="stack" id="close-friend-results"></div>
<p class="meta-muted">Логика схемы строится на одном запросе связей пользователя, дальше дерево достраивается на его основе.</p>
<button class="primary-btn" id="close-network-help">Понятно</button>
</div> </div>
</div> </div>
`; `;
root.querySelector('#close-network-help').addEventListener('click', () => { const close = () => { root.innerHTML = ''; };
root.innerHTML = ''; root.querySelector('#close-friend-back').addEventListener('click', close);
root.querySelector('#close-friend-search').addEventListener('click', async () => {
const query = root.querySelector('#close-friend-query').value.trim();
const holder = root.querySelector('#close-friend-results');
holder.innerHTML = '<p class="meta-muted">Поиск...</p>';
try {
const logins = await authService.searchUsers(query);
holder.innerHTML = '';
if (!logins.length) {
holder.innerHTML = '<p class="meta-muted">Пользователи не найдены.</p>';
return;
}
logins.forEach((login) => {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
<div><strong>${login}</strong><p class="meta-muted" style="margin-top:4px;">Пользователь</p></div>
<div class="meta-muted">Добавить</div>
`;
row.addEventListener('click', async () => {
const yes = window.confirm(`Добавить ${login} в близкие друзья?`);
if (!yes) return;
try {
await authService.addCloseFriend(login);
close();
if (typeof onAdded === 'function') await onAdded();
} catch (e) {
window.alert(`Ошибка добавления: ${e.message || 'unknown'}`);
}
});
holder.append(row);
});
} catch (e) {
holder.innerHTML = `<p class="meta-muted">Ошибка поиска: ${e.message || 'unknown'}</p>`;
}
}); });
} }
@ -34,44 +73,68 @@ export function render() {
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack';
const header = renderHeader({
title: 'Связи',
rightActions: [{ label: 'Справка', onClick: showHelpModal }],
});
const board = document.createElement('div'); const board = document.createElement('div');
board.className = 'network-board'; board.className = 'network-board';
board.style.height = 'calc(100dvh - 170px)';
const lines = networkGraph.peers
.map(
(peer) =>
`<line x1="${toPoint(networkGraph.center)}" y1="${networkGraph.center.y}%" x2="${peer.x}%" y2="${peer.y}%" stroke="rgba(125,170,255,0.55)" stroke-width="1.5"/>`
)
.join('');
board.innerHTML = `<svg class="network-svg" viewBox="0 0 100 100" preserveAspectRatio="none">${lines}</svg>`;
const centerNode = document.createElement('div');
centerNode.className = 'node center';
centerNode.style.left = `${networkGraph.center.x}%`;
centerNode.style.top = `${networkGraph.center.y}%`;
centerNode.innerHTML = `<div class="node-dot">${networkGraph.center.initials}</div><div class="node-label">${networkGraph.center.name}</div>`;
board.append(centerNode);
networkGraph.peers.forEach((peer) => {
const node = document.createElement('div');
node.className = 'node';
node.style.left = `${peer.x}%`;
node.style.top = `${peer.y}%`;
node.innerHTML = `<div class="node-dot">${peer.initials}</div><div class="node-label">${peer.name}</div>`;
board.append(node);
});
const note = document.createElement('p'); const note = document.createElement('p');
note.className = 'meta-muted'; note.className = 'meta-muted';
note.textContent = 'Схема статичная для демо, архитектура подготовлена под дальнейшую интерактивность.'; note.textContent = 'Загрузка связей...';
screen.append(header, board, note); const load = async (centerLogin) => {
try {
const graph = await authService.getUserConnectionsGraph(centerLogin || state.session.login);
board.innerHTML = '';
const center = makeNode(graph.login || state.session.login, 'center');
center.style.left = '50%';
center.style.top = '50%';
board.append(center);
const all = [...new Set([...(graph.outFriends || []), ...(graph.inFriends || [])])];
const left = all.slice(0, Math.ceil(all.length / 2));
const right = all.slice(Math.ceil(all.length / 2));
const mk = (name, side, idx, total) => {
const node = makeNode(name);
const y = 15 + ((idx + 1) * 70) / (Math.max(total, 1) + 1);
node.style.left = side === 'left' ? '20%' : '80%';
node.style.top = `${y}%`;
node.addEventListener('click', () => load(name));
board.append(node);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', '50');
line.setAttribute('y1', '50');
line.setAttribute('x2', side === 'left' ? '20' : '80');
line.setAttribute('y2', String(y));
line.setAttribute('stroke', 'rgba(125,170,255,0.6)');
line.setAttribute('stroke-width', '1.5');
return line;
};
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'network-svg');
svg.setAttribute('viewBox', '0 0 100 100');
svg.setAttribute('preserveAspectRatio', 'none');
left.forEach((name, i) => svg.append(mk(name, 'left', i, left.length)));
right.forEach((name, i) => svg.append(mk(name, 'right', i, right.length)));
board.prepend(svg);
note.textContent = 'Нажмите на узел, чтобы перестроить связи вокруг выбранного пользователя.';
} catch (e) {
note.textContent = `Ошибка загрузки связей: ${e.message || 'unknown'}`;
}
};
const addBtn = document.createElement('button');
addBtn.className = 'primary-btn';
addBtn.type = 'button';
addBtn.textContent = 'Добавить близкого друга';
addBtn.addEventListener('click', () => showAddCloseFriendModal({ onAdded: () => load() }));
load();
screen.append(renderHeader({ title: 'Связи' }), addBtn, board, note);
return screen; return screen;
} }

View File

@ -1,5 +1,6 @@
import { renderHeader } from '../components/header.js?v=20260403081123'; import { renderHeader } from '../components/header.js?v=20260403081123';
import { profile } from '../mock-data.js?v=20260403081123'; import { profile } from '../mock-data.js?v=20260403081123';
import { state } from '../state.js?v=20260403081123';
export const pageMeta = { id: 'profile-view', title: 'Профиль' }; export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@ -40,7 +41,7 @@ export function render({ navigate }) {
</div> </div>
<div> <div>
<h2 style="font-size:22px; margin-bottom:2px;">${profile.name}</h2> <h2 style="font-size:22px; margin-bottom:2px;">${profile.name}</h2>
<p class="meta-muted">${profile.login}</p> <p class="meta-muted">${state.session.login || profile.login}</p>
</div> </div>
<div class="stack" style="gap:8px;"> <div class="stack" style="gap:8px;">
<div class="card" style="padding:10px;"><span class="meta-muted">Телефон:</span> ${profile.phone}</div> <div class="card" style="padding:10px;"><span class="meta-muted">Телефон:</span> ${profile.phone}</div>

View File

@ -235,6 +235,54 @@ export class AuthService {
return response.payload || {}; return response.payload || {};
} }
onEvent(op, handler) {
return this.ws.onEvent(op, handler);
}
async upsertPushToken({ tokenId, token, provider = 'fcm', platform = 'web', userAgent = navigator.userAgent || '' }) {
const response = await this.ws.request('UpsertPushToken', { tokenId, token, provider, platform, userAgent });
if (response.status !== 200) throw opError('UpsertPushToken', response);
return response.payload || {};
}
async sendDirectMessage(toLogin, text) {
const response = await this.ws.request('SendDirectMessage', { toLogin, text });
if (response.status !== 200) throw opError('SendDirectMessage', response);
return response.payload || {};
}
async ackIncomingMessage(eventId, messageId) {
const response = await this.ws.request('AckIncomingMessage', { eventId, messageId });
if (response.status !== 200) throw opError('AckIncomingMessage', response);
return response.payload || {};
}
async listContacts() {
const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response);
return response.payload || {};
}
async addCloseFriend(toLogin) {
const response = await this.ws.request('AddCloseFriend', { toLogin });
if (response.status !== 200) throw opError('AddCloseFriend', response);
return response.payload || {};
}
async getUserConnectionsGraph(login) {
const response = await this.ws.request('GetUserConnectionsGraph', { login });
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
return response.payload || {};
}
async searchUsers(prefix) {
const response = await this.ws.request('SearchUsers', { prefix });
if (response.status !== 200) throw opError('SearchUsers', response);
return response.payload?.logins || [];
}
async reportClientError(details) { async reportClientError(details) {
try { try {
const response = await this.ws.request('ClientErrorLog', details || {}, 3000); const response = await this.ws.request('ClientErrorLog', details || {}, 3000);

View File

@ -0,0 +1,51 @@
const LS_KEY = 'shine-ui-fcm-token-v1';
export async function initPwaPush({ authService }) {
if (!('serviceWorker' in navigator)) return;
try {
await navigator.serviceWorker.register('./firebase-messaging-sw.js');
} catch {
return;
}
if (!window.firebase || !window.firebase.messaging) return;
try {
const config = window.__SHINE_FIREBASE_CONFIG__ || null;
if (!config) return;
if (!window.firebase.apps.length) {
window.firebase.initializeApp(config);
}
const messaging = window.firebase.messaging();
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const vapidKey = window.__SHINE_FIREBASE_VAPID_KEY__ || '';
const token = await messaging.getToken({ vapidKey });
if (!token) return;
const prev = localStorage.getItem(LS_KEY);
if (prev === token) return;
localStorage.setItem(LS_KEY, token);
const tokenId = `tok-${new Date().toISOString().replace(/[-:.TZ]/g, '')}-${Math.random().toString(36).slice(2, 12)}`;
await authService.upsertPushToken({
tokenId,
token,
provider: 'fcm',
platform: 'web',
userAgent: navigator.userAgent || '',
});
messaging.onMessage((payload) => {
const title = payload?.notification?.title || 'Новое сообщение';
const body = payload?.notification?.body || '';
try {
new Notification(title, { body });
} catch {}
});
} catch {
// silent for MVP
}
}

View File

@ -21,6 +21,7 @@ export class WsJsonClient {
this.ws = null; this.ws = null;
this.pending = new Map(); this.pending = new Map();
this.openPromise = null; this.openPromise = null;
this.eventListeners = new Map();
} }
async open() { async open() {
@ -53,6 +54,7 @@ export class WsJsonClient {
}); });
}).finally(() => { }).finally(() => {
this.openPromise = null; this.openPromise = null;
this.eventListeners = new Map();
}); });
return this.openPromise; return this.openPromise;
@ -116,14 +118,40 @@ export class WsJsonClient {
} }
const requestId = data?.requestId; const requestId = data?.requestId;
if (!requestId) return; const isEvent = data?.event === true || (requestId && !this.pending.has(requestId));
if (isEvent) {
this.emitEvent(data?.op || '', data);
return;
}
if (!requestId) return;
const slot = this.pending.get(requestId); const slot = this.pending.get(requestId);
if (!slot) return; if (!slot) return;
this.pending.delete(requestId); this.pending.delete(requestId);
slot.resolve(data); slot.resolve(data);
} }
onEvent(op, callback) {
if (!op || typeof callback !== 'function') return () => {};
if (!this.eventListeners.has(op)) {
this.eventListeners.set(op, new Set());
}
const set = this.eventListeners.get(op);
set.add(callback);
return () => {
set.delete(callback);
if (!set.size) this.eventListeners.delete(op);
};
}
emitEvent(op, data) {
const listeners = this.eventListeners.get(op);
if (!listeners) return;
listeners.forEach((cb) => {
try { cb(data); } catch {}
});
}
failPending(message) { failPending(message) {
const pendingOps = [...this.pending.values()] const pendingOps = [...this.pending.values()]
.map((slot) => slot.op) .map((slot) => slot.op)

View File

@ -41,6 +41,8 @@ function createInitialState({ withStoredSession = true } = {}) {
const storedSession = withStoredSession ? loadStoredSession() : null; const storedSession = withStoredSession ? loadStoredSession() : null;
return { return {
chats: clone(chatMessages), chats: clone(chatMessages),
contacts: [],
incomingDedup: {},
notificationsTab: 'replies', notificationsTab: 'replies',
pageLabelCollapsed: false, pageLabelCollapsed: false,
session: { session: {
@ -121,6 +123,20 @@ export function addChatMessage(chatId, text) {
getChatMessages(chatId).push({ from: 'out', text: message }); getChatMessages(chatId).push({ from: 'out', text: message });
} }
export function addIncomingMessage(chatId, text, messageId = '') {
const msg = text?.trim();
if (!msg) return false;
if (messageId && state.incomingDedup[messageId]) return false;
if (messageId) state.incomingDedup[messageId] = true;
getChatMessages(chatId).push({ from: 'in', text: msg, messageId });
return true;
}
export function setContacts(list) {
state.contacts = Array.isArray(list) ? [...list] : [];
}
export function togglePageLabel() { export function togglePageLabel() {
state.pageLabelCollapsed = !state.pageLabelCollapsed; state.pageLabelCollapsed = !state.pageLabelCollapsed;
} }

View File

@ -0,0 +1,20 @@
{
"name": "Shine UI",
"short_name": "Shine",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#0b1020",
"theme_color": "#0b1020",
"icons": [
{
"src": "./img/logo.jpg",
"sizes": "192x192",
"type": "image/jpeg"
},
{
"src": "./img/logo.jpg",
"sizes": "512x512",
"type": "image/jpeg"
}
]
}

View File

@ -19,7 +19,7 @@ body {
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 118px; bottom: 74px;
overflow-y: auto; overflow-y: auto;
padding: 14px 14px 24px; padding: 14px 14px 24px;
} }
@ -29,13 +29,6 @@ body {
padding-bottom: calc(24px + env(safe-area-inset-bottom)); padding-bottom: calc(24px + env(safe-area-inset-bottom));
} }
.page-label-slot {
position: absolute;
left: 0;
right: 0;
bottom: 99px;
padding: 0 14px;
}
.toolbar-slot { .toolbar-slot {
position: absolute; position: absolute;

View File

@ -328,6 +328,54 @@ public final class DatabaseInitializer {
ON message_stats (to_login); ON message_stats (to_login);
"""); """);
// 9) direct_messages
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS direct_messages (
message_id TEXT NOT NULL PRIMARY KEY,
from_login TEXT NOT NULL,
to_login TEXT NOT NULL,
text TEXT NOT NULL,
created_at_ms INTEGER NOT NULL,
FOREIGN KEY (from_login) REFERENCES solana_users(login),
FOREIGN KEY (to_login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_direct_messages_to_login
ON direct_messages (to_login, created_at_ms);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_direct_messages_from_login
ON direct_messages (from_login, created_at_ms);
""");
// 10) user_push_tokens
st.executeUpdate("""
CREATE TABLE IF NOT EXISTS user_push_tokens (
token_id TEXT NOT NULL PRIMARY KEY,
login TEXT NOT NULL,
session_id TEXT NOT NULL,
provider TEXT NOT NULL,
token TEXT NOT NULL,
platform TEXT,
user_agent TEXT,
updated_at_ms INTEGER NOT NULL,
FOREIGN KEY (login) REFERENCES solana_users(login)
);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login
ON user_push_tokens (login);
""");
st.executeUpdate("""
CREATE INDEX IF NOT EXISTS idx_user_push_tokens_login_session
ON user_push_tokens (login, session_id);
""");
DatabaseTriggersInstaller.createAllTriggers(st); DatabaseTriggersInstaller.createAllTriggers(st);
} }
} }

View File

@ -125,4 +125,32 @@ public final class ConnectionsStateDAO {
} }
return out; return out;
} }
public void upsertRelation(Connection c,
String login,
int relType,
String toLogin,
String toBchName,
Integer toBlockNumber,
byte[] toBlockHash) throws SQLException {
String sql = """
INSERT INTO connections_state (login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(login, rel_type, to_login) DO UPDATE SET
to_bch_name=excluded.to_bch_name,
to_block_number=excluded.to_block_number,
to_block_hash=excluded.to_block_hash
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, relType);
ps.setString(3, toLogin);
ps.setString(4, toBchName);
if (toBlockNumber == null) ps.setNull(5, java.sql.Types.INTEGER); else ps.setInt(5, toBlockNumber);
ps.setBytes(6, toBlockHash);
ps.executeUpdate();
}
}
} }

View File

@ -0,0 +1,53 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.DirectMessageEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
public final class DirectMessagesDAO {
private static volatile DirectMessagesDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private DirectMessagesDAO() {}
public static DirectMessagesDAO getInstance() {
if (instance == null) {
synchronized (DirectMessagesDAO.class) {
if (instance == null) instance = new DirectMessagesDAO();
}
}
return instance;
}
public void insert(DirectMessageEntry entry) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO direct_messages (
message_id, from_login, to_login, text, created_at_ms
) VALUES (?, ?, ?, ?, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getMessageId());
ps.setString(2, entry.getFromLogin());
ps.setString(3, entry.getToLogin());
ps.setString(4, entry.getText());
ps.setLong(5, entry.getCreatedAtMs());
ps.executeUpdate();
}
}
}
public boolean existsFromTo(String fromLogin, String toLogin) throws Exception {
try (Connection c = db.getConnection()) {
String sql = "SELECT 1 FROM direct_messages WHERE from_login = ? AND to_login = ? LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, fromLogin);
ps.setString(2, toLogin);
return ps.executeQuery().next();
}
}
}
}

View File

@ -0,0 +1,116 @@
package shine.db.dao;
import shine.db.SqliteDbController;
import shine.db.entities.PushTokenEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public final class PushTokensDAO {
private static volatile PushTokensDAO instance;
private final SqliteDbController db = SqliteDbController.getInstance();
private PushTokensDAO() {}
public static PushTokensDAO getInstance() {
if (instance == null) {
synchronized (PushTokensDAO.class) {
if (instance == null) instance = new PushTokensDAO();
}
}
return instance;
}
public void upsert(PushTokenEntry entry) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
INSERT INTO user_push_tokens (
token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(token_id) DO UPDATE SET
login=excluded.login,
session_id=excluded.session_id,
provider=excluded.provider,
token=excluded.token,
platform=excluded.platform,
user_agent=excluded.user_agent,
updated_at_ms=excluded.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, entry.getTokenId());
ps.setString(2, entry.getLogin());
ps.setString(3, entry.getSessionId());
ps.setString(4, entry.getProvider());
ps.setString(5, entry.getToken());
ps.setString(6, entry.getPlatform());
ps.setString(7, entry.getUserAgent());
ps.setLong(8, entry.getUpdatedAtMs());
ps.executeUpdate();
}
}
}
public List<PushTokenEntry> listByLogin(String login) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
SELECT token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
FROM user_push_tokens
WHERE login = ?
ORDER BY updated_at_ms DESC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
List<PushTokenEntry> out = new ArrayList<>();
while (rs.next()) {
PushTokenEntry e = new PushTokenEntry();
e.setTokenId(rs.getString("token_id"));
e.setLogin(rs.getString("login"));
e.setSessionId(rs.getString("session_id"));
e.setProvider(rs.getString("provider"));
e.setToken(rs.getString("token"));
e.setPlatform(rs.getString("platform"));
e.setUserAgent(rs.getString("user_agent"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
out.add(e);
}
return out;
}
}
}
}
public List<PushTokenEntry> listByLoginAndSession(String login, String sessionId) throws Exception {
try (Connection c = db.getConnection()) {
String sql = """
SELECT token_id, login, session_id, provider, token, platform, user_agent, updated_at_ms
FROM user_push_tokens
WHERE login = ? AND session_id = ?
ORDER BY updated_at_ms DESC
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setString(2, sessionId);
try (ResultSet rs = ps.executeQuery()) {
List<PushTokenEntry> out = new ArrayList<>();
while (rs.next()) {
PushTokenEntry e = new PushTokenEntry();
e.setTokenId(rs.getString("token_id"));
e.setLogin(rs.getString("login"));
e.setSessionId(rs.getString("session_id"));
e.setProvider(rs.getString("provider"));
e.setToken(rs.getString("token"));
e.setPlatform(rs.getString("platform"));
e.setUserAgent(rs.getString("user_agent"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
out.add(e);
}
return out;
}
}
}
}
}

View File

@ -0,0 +1,24 @@
package shine.db.entities;
public class DirectMessageEntry {
private String messageId;
private String fromLogin;
private String toLogin;
private String text;
private long createdAtMs;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public String getFromLogin() { return fromLogin; }
public void setFromLogin(String fromLogin) { this.fromLogin = fromLogin; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}

View File

@ -0,0 +1,36 @@
package shine.db.entities;
public class PushTokenEntry {
private String tokenId;
private String login;
private String sessionId;
private String provider;
private String token;
private String platform;
private String userAgent;
private long updatedAtMs;
public String getTokenId() { return tokenId; }
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public String getProvider() { return provider; }
public void setProvider(String provider) { this.provider = provider; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getUserAgent() { return userAgent; }
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}

View File

@ -51,6 +51,18 @@ import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_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;
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
import server.logic.ws_protocol.JSON.messages.Net_AckIncomingMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_SendDirectMessage_Handler;
import server.logic.ws_protocol.JSON.messages.Net_UpsertPushToken_Handler;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Request;
// --- NEW: Ping --- // --- NEW: Ping ---
import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler; import server.logic.ws_protocol.JSON.handlers.system.Net_GetServerInfo_Handler;
@ -96,6 +108,14 @@ public final class JsonHandlerRegistry {
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()), Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
// --- direct messages / push ---
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
Map.entry("SendDirectMessage", new Net_SendDirectMessage_Handler()),
Map.entry("AckIncomingMessage", new Net_AckIncomingMessage_Handler()),
// --- system --- // --- system ---
Map.entry("Ping", new Net_Ping_Handler()), Map.entry("Ping", new Net_Ping_Handler()),
@ -134,6 +154,14 @@ public final class JsonHandlerRegistry {
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class), Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
// --- direct messages / push ---
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
Map.entry("SendDirectMessage", Net_SendDirectMessage_Request.class),
Map.entry("AckIncomingMessage", Net_AckIncomingMessage_Request.class),
// --- system --- // --- system ---
Map.entry("Ping", Net_Ping_Request.class), Map.entry("Ping", Net_Ping_Request.class),

View File

@ -1,5 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.auth; package server.logic.ws_protocol.JSON.handlers.auth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
@ -10,6 +12,8 @@ import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CloseActiveSession_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import server.ws.WsConnectionUtils; import server.ws.WsConnectionUtils;
import shine.db.dao.ActiveSessionsDAO; import shine.db.dao.ActiveSessionsDAO;
@ -32,6 +36,7 @@ import java.sql.SQLException;
public class Net_CloseActiveSession_Handler implements JsonMessageHandler { public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class); private static final Logger log = LoggerFactory.getLogger(Net_CloseActiveSession_Handler.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override @Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
@ -122,6 +127,14 @@ public class Net_CloseActiveSession_Handler implements JsonMessageHandler {
if (ctxToClose == null) return; if (ctxToClose == null) return;
String eventId = NetIdGenerator.eventId("evt");
ObjectNode payload = MAPPER.createObjectNode();
payload.put("eventId", eventId);
payload.put("sessionId", targetSessionId);
payload.put("reason", "closed_from_another_device");
payload.put("timeMs", System.currentTimeMillis());
WsEventSender.sendEvent(ctxToClose, "SessionRevoked", eventId, payload);
if (isCurrentSession && ctxToClose == currentCtx) { if (isCurrentSession && ctxToClose == currentCtx) {
new Thread(() -> { new Thread(() -> {
try { Thread.sleep(50); } catch (InterruptedException ignored) {} try { Thread.sleep(50); } catch (InterruptedException ignored) {}

View File

@ -0,0 +1,85 @@
package server.logic.ws_protocol.JSON.handlers.connections;
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.connections.entyties.Net_AddCloseFriend_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.dao.ConnectionsStateDAO;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class Net_AddCloseFriend_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_AddCloseFriend_Request req = (Net_AddCloseFriend_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getToLogin() == null || req.getToLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin обязателен");
}
String from = ctx.getLogin();
String toLogin = req.getToLogin().trim();
if (from.equalsIgnoreCase(toLogin)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "Нельзя добавить себя");
}
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
String canonicalTo = findCanonicalLogin(c, toLogin);
if (canonicalTo == null) {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
String targetBch = findPrimaryBlockchain(c, canonicalTo);
if (targetBch == null) {
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "У пользователя нет blockchain");
}
ConnectionsStateDAO.getInstance().upsertRelation(
c,
from,
MsgSubType.CONNECTION_FRIEND,
canonicalTo,
targetBch,
0,
new byte[32]
);
Net_AddCloseFriend_Response resp = new Net_AddCloseFriend_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(from);
resp.setToLogin(canonicalTo);
resp.setRelation("close_friend");
return resp;
}
}
private String findCanonicalLogin(Connection c, String login) throws Exception {
String sql = "SELECT login FROM solana_users WHERE login = ? COLLATE NOCASE LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("login") : null;
}
}
}
private String findPrimaryBlockchain(Connection c, String login) throws Exception {
String sql = "SELECT blockchain_name FROM blockchain_state WHERE login=? ORDER BY blockchain_name LIMIT 1";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("blockchain_name") : null;
}
}
}
}

View File

@ -0,0 +1,40 @@
package server.logic.ws_protocol.JSON.handlers.connections;
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.connections.entyties.Net_GetUserConnectionsGraph_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.dao.ConnectionsStateDAO;
import java.sql.Connection;
import java.util.List;
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_GetUserConnectionsGraph_Request req = (Net_GetUserConnectionsGraph_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String login = (req.getLogin() == null || req.getLogin().isBlank()) ? ctx.getLogin() : req.getLogin().trim();
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
List<String> out = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, login, MsgSubType.CONNECTION_FRIEND);
List<String> in = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, login, MsgSubType.CONNECTION_FRIEND);
Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
resp.setOutFriends(out);
resp.setInFriends(in);
return resp;
}
}
}

View File

@ -0,0 +1,36 @@
package server.logic.ws_protocol.JSON.handlers.connections;
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.connections.entyties.Net_ListContacts_Request;
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.dao.ConnectionsStateDAO;
import java.sql.Connection;
import java.util.List;
public class Net_ListContacts_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_ListContacts_Request req = (Net_ListContacts_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
try (Connection c = shine.db.SqliteDbController.getInstance().getConnection()) {
List<String> contacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, ctx.getLogin(), MsgSubType.CONNECTION_CONTACT);
Net_ListContacts_Response resp = new Net_ListContacts_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(ctx.getLogin());
resp.setContacts(contacts);
return resp;
}
}
}

View File

@ -0,0 +1,10 @@
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_AddCloseFriend_Request extends Net_Request {
private String toLogin;
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
}

View File

@ -0,0 +1,16 @@
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_AddCloseFriend_Response extends Net_Response {
private String login;
private String toLogin;
private String relation;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public String getRelation() { return relation; }
public void setRelation(String relation) { this.relation = relation; }
}

View File

@ -0,0 +1,10 @@
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetUserConnectionsGraph_Request extends Net_Request {
private String login;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
}

View File

@ -0,0 +1,19 @@
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private String login;
private List<String> outFriends = new ArrayList<>();
private List<String> inFriends = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<String> getOutFriends() { return outFriends; }
public void setOutFriends(List<String> outFriends) { this.outFriends = outFriends; }
public List<String> getInFriends() { return inFriends; }
public void setInFriends(List<String> inFriends) { this.inFriends = inFriends; }
}

View File

@ -0,0 +1,6 @@
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_ListContacts_Request extends Net_Request {
}

View File

@ -0,0 +1,16 @@
package server.logic.ws_protocol.JSON.handlers.connections.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_ListContacts_Response extends Net_Response {
private String login;
private List<String> contacts = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public List<String> getContacts() { return contacts; }
public void setContacts(List<String> contacts) { this.contacts = contacts; }
}

View File

@ -0,0 +1,34 @@
package server.logic.ws_protocol.JSON.messages;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
public final class DeliveryTracker {
private static final DeliveryTracker INSTANCE = new DeliveryTracker();
public static DeliveryTracker getInstance() { return INSTANCE; }
private final ConcurrentHashMap<String, CompletableFuture<Boolean>> waiters = new ConcurrentHashMap<>();
private DeliveryTracker() {}
public CompletableFuture<Boolean> register(String eventId) {
CompletableFuture<Boolean> f = new CompletableFuture<>();
waiters.put(eventId, f);
return f;
}
public void ack(String eventId) {
CompletableFuture<Boolean> f = waiters.remove(eventId);
if (f != null) f.complete(true);
}
public void fail(String eventId) {
CompletableFuture<Boolean> f = waiters.remove(eventId);
if (f != null) f.complete(false);
}
public void remove(String eventId) {
waiters.remove(eventId);
}
}

View File

@ -0,0 +1,29 @@
package server.logic.ws_protocol.JSON.messages;
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.messages.entyties.Net_AckIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_AckIncomingMessage_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
public class Net_AckIncomingMessage_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_AckIncomingMessage_Request req = (Net_AckIncomingMessage_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getEventId() != null && !req.getEventId().isBlank()) {
DeliveryTracker.getInstance().ack(req.getEventId());
}
Net_AckIncomingMessage_Response resp = new Net_AckIncomingMessage_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
return resp;
}
}

View File

@ -0,0 +1,123 @@
package server.logic.ws_protocol.JSON.messages;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
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.messages.entyties.Net_SendDirectMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendDirectMessage_Response;
import server.logic.ws_protocol.JSON.push.FcmPushSender;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.JSON.utils.NetIdGenerator;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.DirectMessagesDAO;
import shine.db.dao.PushTokensDAO;
import shine.db.entities.DirectMessageEntry;
import shine.db.entities.PushTokenEntry;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
public class Net_SendDirectMessage_Handler implements JsonMessageHandler {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_SendDirectMessage_Request req = (Net_SendDirectMessage_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getToLogin() == null || req.getToLogin().isBlank() || req.getText() == null || req.getText().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "toLogin и text обязательны");
}
String from = ctx.getLogin();
String to = req.getToLogin().trim();
String text = req.getText().trim();
if (!canSend(from, to)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NO_PERMISSION", "Можно писать только контактам или тем, кто уже писал вам");
}
String messageId = NetIdGenerator.eventId("msg");
DirectMessageEntry entry = new DirectMessageEntry();
entry.setMessageId(messageId);
entry.setFromLogin(from);
entry.setToLogin(to);
entry.setText(text);
entry.setCreatedAtMs(System.currentTimeMillis());
DirectMessagesDAO.getInstance().insert(entry);
Set<ConnectionContext> activeSessions = ActiveConnectionsRegistry.getInstance().getByLogin(to);
List<PushTokenEntry> tokens = PushTokensDAO.getInstance().listByLogin(to);
int wsDelivered = 0;
int fcmDelivered = 0;
Set<String> activeSessionIds = new HashSet<>();
for (ConnectionContext targetCtx : activeSessions) {
activeSessionIds.add(targetCtx.getSessionId());
String eventId = NetIdGenerator.eventId("evt");
CompletableFuture<Boolean> waiter = DeliveryTracker.getInstance().register(eventId);
ObjectNode payload = MAPPER.createObjectNode();
payload.put("eventId", eventId);
payload.put("messageId", messageId);
payload.put("fromLogin", from);
payload.put("toLogin", to);
payload.put("text", text);
payload.put("timeMs", entry.getCreatedAtMs());
boolean sent = WsEventSender.sendEvent(targetCtx, "IncomingDirectMessage", eventId, payload);
boolean acked = false;
if (sent) {
try {
acked = waiter.get(1200, TimeUnit.MILLISECONDS);
} catch (Exception ignored) {
acked = false;
}
}
DeliveryTracker.getInstance().remove(eventId);
if (acked) {
wsDelivered++;
continue;
}
for (PushTokenEntry token : tokens) {
if (!targetCtx.getSessionId().equals(token.getSessionId())) continue;
boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId);
if (pushed) {
fcmDelivered++;
break;
}
}
}
for (PushTokenEntry token : tokens) {
if (activeSessionIds.contains(token.getSessionId())) continue;
boolean pushed = FcmPushSender.sendNotification(token.getToken(), "Новое сообщение", text, messageId);
if (pushed) fcmDelivered++;
}
Net_SendDirectMessage_Response resp = new Net_SendDirectMessage_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setMessageId(messageId);
resp.setDeliveredWsSessions(wsDelivered);
resp.setDeliveredFcmSessions(fcmDelivered);
return resp;
}
private boolean canSend(String from, String to) {
return from != null && !from.isBlank() && to != null && !to.isBlank();
}
}

View File

@ -0,0 +1,44 @@
package server.logic.ws_protocol.JSON.messages;
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.messages.entyties.Net_UpsertPushToken_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_UpsertPushToken_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.PushTokensDAO;
import shine.db.entities.PushTokenEntry;
public class Net_UpsertPushToken_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) throws Exception {
Net_UpsertPushToken_Request req = (Net_UpsertPushToken_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getTokenId() == null || req.getTokenId().isBlank() || req.getToken() == null || req.getToken().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_FIELDS", "tokenId и token обязательны");
}
PushTokenEntry e = new PushTokenEntry();
e.setTokenId(req.getTokenId().trim());
e.setLogin(ctx.getLogin());
e.setSessionId((req.getSessionId() == null || req.getSessionId().isBlank()) ? ctx.getSessionId() : req.getSessionId().trim());
e.setProvider(req.getProvider() == null || req.getProvider().isBlank() ? "fcm" : req.getProvider().trim());
e.setToken(req.getToken().trim());
e.setPlatform(req.getPlatform());
e.setUserAgent(req.getUserAgent());
e.setUpdatedAtMs(System.currentTimeMillis());
PushTokensDAO.getInstance().upsert(e);
Net_UpsertPushToken_Response resp = new Net_UpsertPushToken_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setTokenId(e.getTokenId());
resp.setUpdatedAtMs(e.getUpdatedAtMs());
return resp;
}
}

View File

@ -0,0 +1,13 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_AckIncomingMessage_Request extends Net_Request {
private String eventId;
private String messageId;
public String getEventId() { return eventId; }
public void setEventId(String eventId) { this.eventId = eventId; }
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
}

View File

@ -0,0 +1,6 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_AckIncomingMessage_Response extends Net_Response {
}

View File

@ -0,0 +1,13 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SendDirectMessage_Request extends Net_Request {
private String toLogin;
private String text;
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public String getText() { return text; }
public void setText(String text) { this.text = text; }
}

View File

@ -0,0 +1,16 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_SendDirectMessage_Response extends Net_Response {
private String messageId;
private int deliveredWsSessions;
private int deliveredFcmSessions;
public String getMessageId() { return messageId; }
public void setMessageId(String messageId) { this.messageId = messageId; }
public int getDeliveredWsSessions() { return deliveredWsSessions; }
public void setDeliveredWsSessions(int deliveredWsSessions) { this.deliveredWsSessions = deliveredWsSessions; }
public int getDeliveredFcmSessions() { return deliveredFcmSessions; }
public void setDeliveredFcmSessions(int deliveredFcmSessions) { this.deliveredFcmSessions = deliveredFcmSessions; }
}

View File

@ -0,0 +1,25 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_UpsertPushToken_Request extends Net_Request {
private String tokenId;
private String sessionId;
private String provider;
private String token;
private String platform;
private String userAgent;
public String getTokenId() { return tokenId; }
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
public String getSessionId() { return sessionId; }
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
public String getProvider() { return provider; }
public void setProvider(String provider) { this.provider = provider; }
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getUserAgent() { return userAgent; }
public void setUserAgent(String userAgent) { this.userAgent = userAgent; }
}

View File

@ -0,0 +1,13 @@
package server.logic.ws_protocol.JSON.messages.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_UpsertPushToken_Response extends Net_Response {
private String tokenId;
private long updatedAtMs;
public String getTokenId() { return tokenId; }
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}

View File

@ -0,0 +1,52 @@
package server.logic.ws_protocol.JSON.push;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import utils.config.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class FcmPushSender {
private static final Logger log = LoggerFactory.getLogger(FcmPushSender.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final HttpClient HTTP = HttpClient.newHttpClient();
private FcmPushSender() {}
public static boolean sendNotification(String token, String title, String body, String messageId) {
try {
String serverKey = AppConfig.getInstance().getStringOrEmpty("fcm.server.key");
if (serverKey.isBlank()) {
log.warn("fcm.server.key is empty, skip FCM send");
return false;
}
ObjectNode root = MAPPER.createObjectNode();
root.put("to", token);
ObjectNode notif = root.putObject("notification");
notif.put("title", title);
notif.put("body", body);
ObjectNode data = root.putObject("data");
data.put("messageId", messageId);
HttpRequest req = HttpRequest.newBuilder(URI.create("https://fcm.googleapis.com/fcm/send"))
.timeout(Duration.ofSeconds(5))
.header("Authorization", "key=" + serverKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(root.toString()))
.build();
HttpResponse<String> resp = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return resp.statusCode() >= 200 && resp.statusCode() < 300;
} catch (Exception e) {
log.warn("FCM send failed", e);
return false;
}
}
}

View File

@ -0,0 +1,35 @@
package server.logic.ws_protocol.JSON.push;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.eclipse.jetty.websocket.api.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
public final class WsEventSender {
private static final Logger log = LoggerFactory.getLogger(WsEventSender.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private WsEventSender() {}
public static boolean sendEvent(ConnectionContext ctx, String op, String eventId, ObjectNode payload) {
if (ctx == null) return false;
Session session = ctx.getWsSession();
if (session == null || !session.isOpen()) return false;
try {
ObjectNode root = MAPPER.createObjectNode();
root.put("op", op);
root.put("requestId", eventId);
root.put("status", 200);
root.put("ok", true);
root.put("event", true);
root.set("payload", payload == null ? MAPPER.createObjectNode() : payload);
session.getRemote().sendString(root.toString());
return true;
} catch (Exception e) {
log.warn("Failed to send ws event op={} sessionId={}", op, ctx.getSessionId(), e);
return false;
}
}
}

View File

@ -0,0 +1,28 @@
package server.logic.ws_protocol.JSON.utils;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ThreadLocalRandom;
public final class NetIdGenerator {
private static final String ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS");
private NetIdGenerator() {}
public static String eventId(String prefix) {
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
return (prefix == null || prefix.isBlank() ? "evt" : prefix)
+ "-" + now.format(FMT)
+ "-" + randomSuffix(10);
}
private static String randomSuffix(int len) {
StringBuilder sb = new StringBuilder(len);
for (int i = 0; i < len; i++) {
sb.append(ALPHABET.charAt(ThreadLocalRandom.current().nextInt(ALPHABET.length())));
}
return sb.toString();
}
}

View File

@ -12,3 +12,6 @@ server.info.physicalRegion=
server.info.description= server.info.description=
server.info.origin= server.info.origin=
server.info.extraInfo= server.info.extraInfo=
# FCM (legacy HTTP)
fcm.server.key=