From 1ee2a1cf62a9e3863f3fabf121fb717686019e14a8b4fa3b37ce7cb1f3eab878 Mon Sep 17 00:00:00 2001
From: AidarKC
Date: Sun, 12 Apr 2026 18:30:31 +0300
Subject: [PATCH 1/4] 12 -04-2026
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Сделал отдельную ветку для ai
---
shine-UI/js/app.js | 2 +
shine-UI/js/pages/chat-view.js | 27 +-
shine-UI/js/pages/contact-search-view.js | 54 +---
shine-UI/js/pages/messages-list.js | 60 ++++-
shine-UI/js/pages/network-view.js | 182 ++++++++-----
shine-UI/js/pages/profile-view.js | 27 +-
shine-UI/js/pages/user-profile-view.js | 243 ++++++++++++++++++
shine-UI/js/router.js | 19 +-
shine-UI/js/services/auth-service.js | 146 ++++++++++-
shine-UI/js/services/user-connections.js | 215 ++++++++++++++++
shine-UI/styles/components.css | 55 +++-
.../Net_GetUserConnectionsGraph_Handler.java | 16 +-
.../Net_GetUserConnectionsGraph_Response.java | 12 +
13 files changed, 909 insertions(+), 149 deletions(-)
create mode 100644 shine-UI/js/pages/user-profile-view.js
create mode 100644 shine-UI/js/services/user-connections.js
diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js
index 9ded35e..6063c91 100644
--- a/shine-UI/js/app.js
+++ b/shine-UI/js/app.js
@@ -39,6 +39,7 @@ import * as languageView from './pages/language-view.js';
import * as messagesList from './pages/messages-list.js';
import * as contactSearchView from './pages/contact-search-view.js';
import * as chatView from './pages/chat-view.js';
+import * as userProfileView from './pages/user-profile-view.js';
import * as channelsList from './pages/channels-list.js';
import * as channelView from './pages/channel-view.js';
import * as addChannelView from './pages/add-channel-view.js';
@@ -70,6 +71,7 @@ const routes = {
'messages-list': messagesList,
'contact-search-view': contactSearchView,
'chat-view': chatView,
+ 'user-profile-view': userProfileView,
'channels-list': channelsList,
'channel-view': channelView,
'add-channel-view': addChannelView,
diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js
index 96963ac..fa8b0b0 100644
--- a/shine-UI/js/pages/chat-view.js
+++ b/shine-UI/js/pages/chat-view.js
@@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
-import { addChatMessage, getChatMessages, authService, state } from '../state.js';
+import { addChatMessage, getChatMessages, authService } from '../state.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
@@ -31,26 +31,17 @@ export function render({ navigate, route }) {
renderHeader({
title: `Чат: ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
+ rightActions: [{
+ label: 'Позвонить',
+ onClick: () => {
+ const confirmed = window.confirm('Позвонить этому пользователю?');
+ if (!confirmed) return;
+ window.alert('Функция пока не реализована');
+ },
+ }],
})
);
- const isContact = state.contacts.includes(chatId);
- if (!isContact) {
- const warning = document.createElement('div');
- warning.className = 'card stack';
- warning.innerHTML = '
Пользователь не в контактах. Можно писать ему сразу (MVP).
';
- 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');
wrap.className = 'chat-wrap';
diff --git a/shine-UI/js/pages/contact-search-view.js b/shine-UI/js/pages/contact-search-view.js
index 838fceb..b7011ae 100644
--- a/shine-UI/js/pages/contact-search-view.js
+++ b/shine-UI/js/pages/contact-search-view.js
@@ -1,6 +1,5 @@
import { renderHeader } from '../components/header.js';
-import { directMessages } from '../mock-data.js';
-import { authService, ensureChat, setContacts, state } from '../state.js';
+import { authService } from '../state.js';
export const pageMeta = { id: 'contact-search-view', title: 'Поиск контактов' };
@@ -26,10 +25,7 @@ export function render({ navigate }) {
const resultsList = document.createElement('div');
resultsList.className = 'stack';
- let latestMatches = [];
-
const renderResults = (matches, query) => {
- latestMatches = matches;
resultsList.innerHTML = '';
resultsCard.hidden = false;
@@ -56,6 +52,9 @@ export function render({ navigate }) {
Поиск...
';
-
- try {
- const logins = await authService.searchUsers(query);
- holder.innerHTML = '';
- if (!logins.length) {
- holder.innerHTML = 'Пользователи не найдены.
';
- return;
- }
-
- logins.forEach((login) => {
- const row = document.createElement('article');
- row.className = 'list-item';
- row.innerHTML = `
-
${profile.avatarInitials}
-
-
${displayLogin}
+
+
${String(login || '').trim() || 'unknown'}
@@ -71,6 +80,17 @@ export function render({ navigate }) {
let currentFields = [];
let currentToggles = [];
+ const identityEl = topRow.querySelector('[data-profile-identity="true"]');
+
+ function syncIdentity() {
+ if (!identityEl) return;
+ const firstName = currentFields.find((field) => field.key === 'first_name')?.value || '';
+ const lastName = currentFields.find((field) => field.key === 'last_name')?.value || '';
+ const lines = buildIdentityLines({ login, firstName, lastName });
+ identityEl.innerHTML = lines.map((line, idx) => (
+ `
${escapeHtml(line)}
`
+ )).join('');
+ }
function updateToggleButton(button, prefix, enabled) {
button.textContent = `${prefix}: ${toggleText(enabled)}`;
@@ -123,6 +143,7 @@ export function render({ navigate }) {
currentFields = snapshot.fields;
currentToggles = snapshot.toggles;
+ syncIdentity();
renderFields(currentFields);
updateTogglesUi();
diff --git a/shine-UI/js/pages/user-profile-view.js b/shine-UI/js/pages/user-profile-view.js
new file mode 100644
index 0000000..15c5663
--- /dev/null
+++ b/shine-UI/js/pages/user-profile-view.js
@@ -0,0 +1,243 @@
+import { renderHeader } from '../components/header.js';
+import { authService, state } from '../state.js';
+import {
+ buildAvatarInitials,
+ buildIdentityLines,
+ loadRelationsForPair,
+ loadUserProfileCard,
+} from '../services/user-connections.js';
+
+export const pageMeta = { id: 'user-profile-view', title: 'Чужой профиль' };
+
+function escapeHtml(text) {
+ return String(text || '')
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function boolText(flag) {
+ return flag ? 'Да' : 'Нет';
+}
+
+function relationButtonLabel(kind, flags) {
+ if (kind === 'follow') return flags.outFollow ? 'Отписаться' : 'Подписаться';
+ if (kind === 'friend') return flags.outFriend ? 'Убрать из друзей' : 'Добавить в друзья';
+ return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
+}
+
+function relationNextState(kind, flags) {
+ if (kind === 'follow') return !flags.outFollow;
+ if (kind === 'friend') return !flags.outFriend;
+ return !flags.outContact;
+}
+
+function relationConfirmLabel(kind) {
+ if (kind === 'follow') return 'подписку';
+ if (kind === 'friend') return 'дружбу';
+ return 'контакт';
+}
+
+function renderIdentity(card) {
+ const lines = buildIdentityLines({
+ login: card.login,
+ firstName: card.firstName,
+ lastName: card.lastName,
+ });
+
+ return `
+
+
${escapeHtml(buildAvatarInitials(card))}
+
+ ${lines.map((line, idx) => (
+ `
${escapeHtml(line)}
`
+ )).join('')}
+
+
+ `;
+}
+
+function renderReadOnlyBadges(card) {
+ return `
+
+ Официальный: ${card.official ? 'Yes' : 'No'}
+ Сияющий: ${card.shine ? 'Yes' : 'No'}
+
+ `;
+}
+
+function renderRelations(flags) {
+ return `
+
+
Вы подписаны:${boolText(flags.outFollow)}
+
Подписан на вас:${boolText(flags.inFollow)}
+
Вы добавили в друзья:${boolText(flags.outFriend)}
+
Добавил вас в друзья:${boolText(flags.inFriend)}
+
Вы добавили в контакты:${boolText(flags.outContact)}
+
Добавил вас в контакты:${boolText(flags.inContact)}
+
+ `;
+}
+
+function renderReadOnlyParams(card) {
+ const rows = [
+ { label: 'Имя', value: card.firstName },
+ { label: 'Фамилия', value: card.lastName },
+ { label: 'Адрес', value: card.address },
+ { label: 'Web', value: card.web },
+ { label: 'Телефон', value: card.phone },
+ ];
+
+ return `
+
+ ${rows.map((row) => `
+
+
${row.label}: ${escapeHtml(String(row.value || '').trim() || 'не заполнено')}
+
+ `).join('')}
+
+ `;
+}
+
+export function render({ navigate, route }) {
+ const requestedLogin = String(route.params.login || '').trim();
+ const fromPage = String(route.params.fromPage || 'messages-list').trim() || 'messages-list';
+ const sessionLogin = String(state.session.login || '').trim();
+
+ const screen = document.createElement('section');
+ screen.className = 'stack';
+
+ const status = document.createElement('div');
+ status.className = 'status-line';
+ status.textContent = 'Загрузка профиля...';
+
+ const body = document.createElement('div');
+ body.className = 'stack';
+
+ screen.append(
+ renderHeader({
+ title: 'Профиль пользователя',
+ leftAction: { label: '←', onClick: () => navigate(fromPage) },
+ rightActions: [{ label: 'Обновить', onClick: () => refresh() }],
+ }),
+ status,
+ body,
+ );
+
+ let currentCard = null;
+ let currentFlags = null;
+ let isBusy = false;
+
+ function syncActionButtons() {
+ const followBtn = body.querySelector('[data-relation-action="follow"]');
+ const friendBtn = body.querySelector('[data-relation-action="friend"]');
+ const contactBtn = body.querySelector('[data-relation-action="contact"]');
+ if (!followBtn || !friendBtn || !contactBtn || !currentFlags) return;
+ const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
+ followBtn.textContent = relationButtonLabel('follow', currentFlags);
+ friendBtn.textContent = relationButtonLabel('friend', currentFlags);
+ contactBtn.textContent = relationButtonLabel('contact', currentFlags);
+ followBtn.disabled = Boolean(isSelf);
+ friendBtn.disabled = Boolean(isSelf);
+ contactBtn.disabled = Boolean(isSelf);
+ }
+
+ async function refresh() {
+ if (!requestedLogin) {
+ status.className = 'status-line is-unavailable';
+ status.textContent = 'Не передан login пользователя.';
+ return;
+ }
+
+ isBusy = true;
+ status.className = 'status-line';
+ status.textContent = 'Загрузка профиля...';
+
+ try {
+ const card = await loadUserProfileCard(requestedLogin);
+ const flags = await loadRelationsForPair({
+ currentLogin: sessionLogin,
+ targetLogin: card.login,
+ });
+
+ currentCard = card;
+ currentFlags = flags;
+
+ body.innerHTML = `
+
+ ${renderIdentity(card)}
+
+ ${renderReadOnlyBadges(card)}
+ ${renderRelations(flags)}
+ ${renderReadOnlyParams(card)}
+
+
+
+
+
+ `;
+
+ syncActionButtons();
+ status.className = 'status-line is-available';
+ status.textContent = 'Профиль обновлён.';
+ } catch (error) {
+ status.className = 'status-line is-unavailable';
+ status.textContent = `Ошибка загрузки профиля: ${error.message || 'unknown'}`;
+ window.alert(`Не удалось загрузить профиль: ${error.message || 'unknown'}`);
+ } finally {
+ isBusy = false;
+ }
+ }
+
+ async function onRelationAction(kind) {
+ if (isBusy || !currentCard || !currentFlags) return;
+ if (!sessionLogin) {
+ window.alert('Для изменения связей нужен активный вход.');
+ return;
+ }
+ if (!state.session.storagePwdInMemory) {
+ window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
+ return;
+ }
+
+ const nextEnabled = relationNextState(kind, currentFlags);
+ const confirmed = window.confirm(
+ `Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
+ 'Будет отправлен AddBlock CONNECTION.',
+ );
+ if (!confirmed) return;
+
+ isBusy = true;
+ status.className = 'status-line';
+ status.textContent = 'Сохранение отношения в блокчейн...';
+
+ try {
+ await authService.setUserRelation({
+ login: sessionLogin,
+ toLogin: currentCard.login,
+ kind,
+ enabled: nextEnabled,
+ storagePwd: state.session.storagePwdInMemory,
+ });
+ await refresh();
+ } catch (error) {
+ status.className = 'status-line is-unavailable';
+ status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
+ window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
+ isBusy = false;
+ }
+ }
+
+ body.addEventListener('click', (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const kind = target.dataset.relationAction;
+ if (!kind) return;
+ onRelationAction(kind);
+ });
+
+ refresh();
+ return screen;
+}
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 78a339e..c554d7a 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -19,18 +19,28 @@ export function getRoute() {
return { pageId: '', params: {} };
}
- const [pageId, dynamicId] = raw.split('/');
+ const [pageId, dynamicId, extraId] = raw.split('/');
if (pageId === 'chat-view') {
- return { pageId, params: { chatId: dynamicId || '' } };
+ return { pageId, params: { chatId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
}
if (pageId === 'channel-view') {
- return { pageId, params: { channelId: dynamicId || '' } };
+ return { pageId, params: { channelId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
}
if (pageId === 'device-session-view') {
- return { pageId, params: { sessionId: dynamicId || '' } };
+ return { pageId, params: { sessionId: dynamicId ? decodeURIComponent(dynamicId) : '' } };
+ }
+
+ if (pageId === 'user-profile-view') {
+ return {
+ pageId,
+ params: {
+ login: dynamicId ? decodeURIComponent(dynamicId) : '',
+ fromPage: extraId ? decodeURIComponent(extraId) : 'messages-list',
+ },
+ };
}
return { pageId, params: {} };
@@ -57,6 +67,7 @@ export function resolveToolbarActive(pageId) {
return 'profile-view';
}
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
+ if (pageId === 'user-profile-view') return 'messages-list';
if (pageId === 'channel-view' || pageId === 'add-channel-view') return 'channels-list';
return 'profile-view';
}
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index 141714e..8f3d344 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -20,6 +20,13 @@ import {
} from './key-vault.js';
const BCH_SUFFIX = '001';
+const ZERO_HASH_HEX = '0'.repeat(64);
+
+const CONNECTION_SUBTYPES = Object.freeze({
+ friend: { on: 10, off: 11 },
+ contact: { on: 20, off: 21 },
+ follow: { on: 30, off: 31 },
+});
function normalizeServerUrl(url) {
const value = (url || '').trim();
@@ -88,6 +95,10 @@ function int64Bytes(value) {
return bytes;
}
+function uint8Bytes(value) {
+ return new Uint8Array([Number(value) & 0xff]);
+}
+
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
const keyBytes = utf8Bytes(String(key || ''));
const valueBytes = utf8Bytes(String(value || ''));
@@ -107,6 +118,39 @@ function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thi
);
}
+function makeConnectionBodyBytes({
+ lineCode,
+ prevLineNumber,
+ prevLineHashHex,
+ thisLineNumber,
+ toBlockchainName,
+ toBlockNumber,
+ toBlockHashHex,
+}) {
+ const cleanBchName = String(toBlockchainName || '').trim();
+ if (!cleanBchName) throw new Error('Пустой toBlockchainName для CONNECTION');
+ const toBchBytes = utf8Bytes(cleanBchName);
+ if (!toBchBytes.length || toBchBytes.length > 255) {
+ throw new Error('toBlockchainName должен быть 1..255 байт UTF-8');
+ }
+
+ const prevHashBytes = hexToBytes(prevLineHashHex);
+ const toBlockHashBytes = hexToBytes(toBlockHashHex);
+ if (prevHashBytes.length !== 32) throw new Error('prevLineHash должен быть 32 байта');
+ if (toBlockHashBytes.length !== 32) throw new Error('toBlockHash должен быть 32 байта');
+
+ return concatBytes(
+ int32Bytes(lineCode),
+ int32Bytes(prevLineNumber),
+ prevHashBytes,
+ int32Bytes(thisLineNumber),
+ uint8Bytes(toBchBytes.length),
+ toBchBytes,
+ int32Bytes(toBlockNumber),
+ toBlockHashBytes,
+ );
+}
+
export class AuthService {
constructor(serverUrl) {
this.serverUrl = normalizeServerUrl(serverUrl);
@@ -368,6 +412,14 @@ export class AuthService {
throw opError('GetUserParam', response);
}
+ async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
+ const cleanKind = String(kind || '').trim().toLowerCase();
+ const kinds = CONNECTION_SUBTYPES[cleanKind];
+ if (!kinds) throw new Error(`Неподдерживаемый тип связи: ${kind}`);
+ const subType = enabled ? kinds.on : kinds.off;
+ return this.addBlockConnection({ login, toLogin, subType, storagePwd });
+ }
+
async addBlockUserParam({ login, param, value, storagePwd }) {
const cleanLogin = (login || '').trim();
const cleanParam = (param || '').trim();
@@ -382,7 +434,7 @@ export class AuthService {
const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
const freshCursor = {
serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
- serverLastGlobalHash: freshHash.length === 64 ? freshHash : '0'.repeat(64),
+ serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
};
const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
@@ -395,7 +447,7 @@ export class AuthService {
const tryAdd = async (cursor) => {
const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
- const prevBlockHash = String(cursor?.serverLastGlobalHash || '0'.repeat(64));
+ const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
// Для USER_PARAM отправляем старт новой line-цепочки:
// prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
@@ -403,7 +455,7 @@ export class AuthService {
const bodyBytes = makeUserParamBodyBytes({
lineCode: 0,
prevLineNumber: -1,
- prevLineHashHex: '0'.repeat(64),
+ prevLineHashHex: ZERO_HASH_HEX,
thisLineNumber: -1,
key: cleanParam,
value: cleanValue,
@@ -450,6 +502,94 @@ export class AuthService {
return response.payload || {};
}
+ async addBlockConnection({ login, toLogin, subType, storagePwd }) {
+ const cleanLogin = (login || '').trim();
+ const cleanToLogin = (toLogin || '').trim();
+ const cleanSubType = Number(subType);
+ if (!cleanLogin || !cleanToLogin) throw new Error('Не переданы login/toLogin для CONNECTION.');
+ if (!Number.isFinite(cleanSubType)) throw new Error('Не передан subType для CONNECTION.');
+ if (!storagePwd) throw new Error('Не передан storagePwd для подписи AddBlock.');
+ if (cleanLogin.toLowerCase() === cleanToLogin.toLowerCase()) {
+ throw new Error('Нельзя создать связь на самого себя.');
+ }
+
+ const user = await this.getUser(cleanLogin);
+ if (user?.exists === false) throw new Error('Текущий пользователь не найден.');
+ const blockchainName = String(user?.blockchainName || `${cleanLogin}-${BCH_SUFFIX}`).trim();
+ const freshNum = Number(user?.serverLastGlobalNumber);
+ const freshHash = String(user?.serverLastGlobalHash || '').trim().toLowerCase();
+ const freshCursor = {
+ serverLastGlobalNumber: Number.isFinite(freshNum) ? freshNum : -1,
+ serverLastGlobalHash: freshHash.length === 64 ? freshHash : ZERO_HASH_HEX,
+ };
+
+ const targetUser = await this.getUser(cleanToLogin);
+ if (!targetUser?.exists) throw new Error('Пользователь цели не найден.');
+ const toBlockchainName = String(targetUser?.blockchainName || `${cleanToLogin}-${BCH_SUFFIX}`).trim();
+
+ const savedKeys = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
+ const blockchainPrivatePkcs8 = savedKeys?.blockchainKey;
+ if (!blockchainPrivatePkcs8) {
+ throw new Error('На устройстве нет сохраненного приватного blockchainKey');
+ }
+ const privateKey = await importPkcs8Ed25519(blockchainPrivatePkcs8);
+
+ const tryAdd = async (cursor) => {
+ const blockNumber = Number(cursor?.serverLastGlobalNumber ?? -1) + 1;
+ const prevBlockHash = String(cursor?.serverLastGlobalHash || ZERO_HASH_HEX);
+
+ // Для CONNECTION в UI-MVP всегда стартуем новую line-цепочку:
+ // prevLineNumber=-1, prevLineHash=0x00..00, thisLineNumber=-1.
+ // target для user-связей указывает на HEADER пользователя (blockNumber=0).
+ const bodyBytes = makeConnectionBodyBytes({
+ lineCode: 0,
+ prevLineNumber: -1,
+ prevLineHashHex: ZERO_HASH_HEX,
+ thisLineNumber: -1,
+ toBlockchainName,
+ toBlockNumber: 0,
+ toBlockHashHex: ZERO_HASH_HEX,
+ });
+
+ const preimage = concatBytes(
+ int16Bytes(0),
+ hexToBytes(prevBlockHash),
+ int32Bytes(2 + 32 + 4 + 4 + 8 + 2 + 2 + 2 + bodyBytes.length),
+ int32Bytes(blockNumber),
+ int64Bytes(Math.floor(Date.now() / 1000)),
+ int16Bytes(3),
+ int16Bytes(cleanSubType),
+ int16Bytes(1),
+ bodyBytes,
+ );
+
+ const hash32 = await sha256Bytes(preimage);
+ const signatureBytes = await signBytes(privateKey, hash32);
+ const fullBlock = concatBytes(preimage, int16Bytes(0x0100), signatureBytes);
+
+ return this.ws.request('AddBlock', {
+ blockchainName,
+ blockNumber,
+ prevBlockHash,
+ blockBytesB64: bytesToBase64(fullBlock),
+ });
+ };
+
+ let cursor = freshCursor;
+ let response = await tryAdd(cursor);
+ if (response.status !== 200) {
+ const knownNum = Number(response?.payload?.serverLastGlobalNumber);
+ const knownHash = String(response?.payload?.serverLastGlobalHash || '').trim().toLowerCase();
+ if (Number.isFinite(knownNum) && knownHash.length === 64) {
+ cursor = { serverLastGlobalNumber: knownNum, serverLastGlobalHash: knownHash };
+ response = await tryAdd(cursor);
+ }
+ }
+
+ if (response.status !== 200) throw opError('AddBlock', response);
+ return response.payload || {};
+ }
+
async reportClientError(details) {
try {
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
diff --git a/shine-UI/js/services/user-connections.js b/shine-UI/js/services/user-connections.js
new file mode 100644
index 0000000..c0c5fef
--- /dev/null
+++ b/shine-UI/js/services/user-connections.js
@@ -0,0 +1,215 @@
+import { authService, state } from '../state.js';
+import { loadProfileSnapshot } from './user-profile-params.js';
+
+function normalizeLogin(value) {
+ return String(value || '').trim();
+}
+
+function normKey(value) {
+ return normalizeLogin(value).toLowerCase();
+}
+
+function uniqueLogins(list) {
+ const out = [];
+ const seen = new Set();
+ (Array.isArray(list) ? list : []).forEach((item) => {
+ const login = normalizeLogin(item);
+ if (!login) return;
+ const key = normKey(login);
+ if (seen.has(key)) return;
+ seen.add(key);
+ out.push(login);
+ });
+ return out;
+}
+
+function listContainsLogin(list, login) {
+ const targetKey = normKey(login);
+ if (!targetKey) return false;
+ return uniqueLogins(list).some((value) => normKey(value) === targetKey);
+}
+
+function toFieldMap(snapshot) {
+ const map = {};
+ (snapshot?.fields || []).forEach((field) => {
+ map[field.key] = String(field.value || '').trim();
+ });
+ return map;
+}
+
+function toToggleMap(snapshot) {
+ const map = {};
+ (snapshot?.toggles || []).forEach((toggle) => {
+ map[toggle.key] = Boolean(toggle.enabled);
+ });
+ return map;
+}
+
+function readArray(payload, key) {
+ const value = payload?.[key];
+ return Array.isArray(value) ? uniqueLogins(value) : null;
+}
+
+function feedOwnerLogins(feedPayload) {
+ const rows = Array.isArray(feedPayload?.followedUsersChannels) ? feedPayload.followedUsersChannels : [];
+ const owners = rows
+ .map((row) => normalizeLogin(row?.channel?.ownerLogin))
+ .filter(Boolean);
+ return uniqueLogins(owners);
+}
+
+async function buildRelationsModel(login) {
+ const cleanLogin = normalizeLogin(login);
+ if (!cleanLogin) {
+ return {
+ outFriends: [],
+ inFriends: [],
+ outContacts: [],
+ inContacts: [],
+ outFollows: [],
+ inFollows: [],
+ };
+ }
+
+ const graph = await authService.getUserConnectionsGraph(cleanLogin);
+
+ let outContacts = readArray(graph, 'outContacts');
+ let outFollows = readArray(graph, 'outFollows');
+
+ const isCurrentSessionLogin = normKey(cleanLogin) === normKey(state.session.login);
+
+ if (outContacts === null && isCurrentSessionLogin) {
+ try {
+ const contacts = await authService.listContacts();
+ outContacts = uniqueLogins(contacts?.contacts || []);
+ } catch {
+ outContacts = [];
+ }
+ }
+ if (outContacts === null) outContacts = [];
+
+ if (outFollows === null) {
+ try {
+ const feed = await authService.listSubscriptionsFeed(cleanLogin, 200);
+ outFollows = feedOwnerLogins(feed);
+ } catch {
+ outFollows = [];
+ }
+ }
+
+ return {
+ outFriends: readArray(graph, 'outFriends') || [],
+ inFriends: readArray(graph, 'inFriends') || [],
+ outContacts,
+ inContacts: readArray(graph, 'inContacts') || [],
+ outFollows,
+ inFollows: readArray(graph, 'inFollows') || [],
+ };
+}
+
+export function buildIdentityLines({ login, firstName, lastName }) {
+ const lines = [];
+ const first = String(firstName || '').trim();
+ const last = String(lastName || '').trim();
+ const cleanLogin = normalizeLogin(login);
+
+ if (first) lines.push(first);
+ if (last) lines.push(last);
+ lines.push(cleanLogin || 'unknown');
+
+ return lines;
+}
+
+export function buildAvatarInitials({ login, firstName, lastName }) {
+ const first = String(firstName || '').trim();
+ const last = String(lastName || '').trim();
+ if (first || last) {
+ const a = (first[0] || '').toUpperCase();
+ const b = (last[0] || '').toUpperCase();
+ const initials = `${a}${b}`.trim();
+ if (initials) return initials;
+ }
+
+ const cleanLogin = normalizeLogin(login);
+ return (cleanLogin[0] || '?').toUpperCase();
+}
+
+export async function loadCurrentRelations() {
+ const login = normalizeLogin(state.session.login);
+ if (!login) {
+ return {
+ outFriends: [],
+ inFriends: [],
+ outContacts: [],
+ inContacts: [],
+ outFollows: [],
+ inFollows: [],
+ };
+ }
+ return buildRelationsModel(login);
+}
+
+export function relationFlagsForTarget(relations, targetLogin) {
+ return {
+ outFriend: listContainsLogin(relations?.outFriends, targetLogin),
+ inFriend: listContainsLogin(relations?.inFriends, targetLogin),
+ outContact: listContainsLogin(relations?.outContacts, targetLogin),
+ inContact: listContainsLogin(relations?.inContacts, targetLogin),
+ outFollow: listContainsLogin(relations?.outFollows, targetLogin),
+ inFollow: listContainsLogin(relations?.inFollows, targetLogin),
+ };
+}
+
+export async function loadUserProfileCard(login) {
+ const cleanLogin = normalizeLogin(login);
+ if (!cleanLogin) throw new Error('Пустой login');
+
+ const [user, snapshot] = await Promise.all([
+ authService.getUser(cleanLogin),
+ loadProfileSnapshot(cleanLogin),
+ ]);
+
+ if (!user?.exists) throw new Error('Пользователь не найден');
+
+ const canonicalLogin = normalizeLogin(user.login || cleanLogin);
+ const fields = toFieldMap(snapshot);
+ const toggles = toToggleMap(snapshot);
+
+ return {
+ login: canonicalLogin,
+ blockchainName: normalizeLogin(user.blockchainName),
+ firstName: fields.first_name || '',
+ lastName: fields.last_name || '',
+ address: fields.address || '',
+ web: fields.web || '',
+ phone: fields.phone || '',
+ official: Boolean(toggles.official),
+ shine: Boolean(toggles.shine),
+ };
+}
+
+export async function loadRelationsForPair({ currentLogin, targetLogin }) {
+ const cleanCurrent = normalizeLogin(currentLogin);
+ const cleanTarget = normalizeLogin(targetLogin);
+ const currentRelations = await buildRelationsModel(cleanCurrent);
+ let flags = relationFlagsForTarget(currentRelations, cleanTarget);
+
+ if (!flags.inContact || !flags.inFollow) {
+ try {
+ const targetRelations = await buildRelationsModel(cleanTarget);
+ const backFlags = relationFlagsForTarget(targetRelations, cleanCurrent);
+ flags = {
+ ...flags,
+ inContact: flags.inContact || backFlags.outContact,
+ inFollow: flags.inFollow || backFlags.outFollow,
+ };
+ } catch {
+ // ignore fallback failures for incoming direction
+ }
+ }
+
+ return {
+ ...flags,
+ source: currentRelations,
+ };
+}
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index 92cb1b3..b970259 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -97,6 +97,24 @@
background: rgba(83, 216, 251, 0.11);
}
+.badge.is-no {
+ border-color: rgba(170, 180, 205, 0.3);
+ color: #c5cedd;
+ background: rgba(152, 164, 190, 0.14);
+}
+
+.badge.is-yes-official {
+ border-color: rgba(132, 244, 161, 0.5);
+ color: #ddffe7;
+ background: rgba(132, 244, 161, 0.2);
+}
+
+.badge.is-yes-shine {
+ border-color: rgba(183, 122, 255, 0.6);
+ color: #f4e7ff;
+ background: rgba(176, 102, 255, 0.22);
+}
+
.badge.profile-toggle-btn.is-no {
border-color: rgba(170, 180, 205, 0.3);
color: #c5cedd;
@@ -160,6 +178,22 @@
gap: 8px;
}
+.profile-identity-lines {
+ display: grid;
+ gap: 4px;
+}
+
+.profile-identity-line {
+ line-height: 1.2;
+ color: #eef3ff;
+ font-size: 17px;
+}
+
+.profile-identity-login {
+ font-weight: 700;
+ font-size: 20px;
+}
+
.profile-param-item {
padding: 10px;
gap: 6px;
@@ -568,7 +602,7 @@
.contact-search-actions {
display: grid;
- grid-template-columns: 1fr 1fr;
+ grid-template-columns: 1fr;
gap: 8px;
}
@@ -746,6 +780,25 @@
color: #d6e2ff;
}
+.node-menu {
+ position: absolute;
+ z-index: 3;
+ min-width: 240px;
+ padding: 8px;
+}
+
+.user-relations-list {
+ gap: 6px;
+}
+
+.user-rel-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ color: #d8e3ff;
+ font-size: 14px;
+}
+
.tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
index b0e1d42..d3510ea 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java
@@ -31,16 +31,24 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, 404, "USER_NOT_FOUND", "Пользователь не найден");
}
- List
out = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
- List in = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
+ List outFriends = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
+ List inFriends = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FRIEND);
+ List outContacts = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
+ List inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
+ List outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
+ List inFollows = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(canonicalLogin);
- resp.setOutFriends(out);
- resp.setInFriends(in);
+ resp.setOutFriends(outFriends);
+ resp.setInFriends(inFriends);
+ resp.setOutContacts(outContacts);
+ resp.setInContacts(inContacts);
+ resp.setOutFollows(outFollows);
+ resp.setInFollows(inFollows);
return resp;
}
}
diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java
index 86d4ce5..120e5f7 100644
--- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java
+++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java
@@ -9,6 +9,10 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
private String login;
private List outFriends = new ArrayList<>();
private List inFriends = new ArrayList<>();
+ private List outContacts = new ArrayList<>();
+ private List inContacts = new ArrayList<>();
+ private List outFollows = new ArrayList<>();
+ private List inFollows = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
@@ -16,4 +20,12 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
public void setOutFriends(List outFriends) { this.outFriends = outFriends; }
public List getInFriends() { return inFriends; }
public void setInFriends(List inFriends) { this.inFriends = inFriends; }
+ public List getOutContacts() { return outContacts; }
+ public void setOutContacts(List outContacts) { this.outContacts = outContacts; }
+ public List getInContacts() { return inContacts; }
+ public void setInContacts(List inContacts) { this.inContacts = inContacts; }
+ public List getOutFollows() { return outFollows; }
+ public void setOutFollows(List outFollows) { this.outFollows = outFollows; }
+ public List getInFollows() { return inFollows; }
+ public void setInFollows(List inFollows) { this.inFollows = inFollows; }
}
From 62e55dbaecb6e955739003cd5e3fa286c5e7838807b7f983ef0068becee61e21 Mon Sep 17 00:00:00 2001
From: ai5590
Date: Sun, 12 Apr 2026 19:34:55 +0300
Subject: [PATCH 2/4] feat(dm): implement signed direct messaging with web push
fallback
---
.../01-ТЗ-web-push-direct-message.md | 153 ++++++++++++
shine-UI/firebase-messaging-sw.js | 42 ++--
shine-UI/index.html | 12 +-
shine-UI/js/app.js | 24 +-
shine-UI/js/pages/chat-view.js | 8 +-
shine-UI/js/services/auth-service.js | 73 +++++-
shine-UI/js/services/pwa-push-service.js | 68 +++---
.../java/shine/db/DatabaseInitializer.java | 39 +++
.../java/shine/db/dao/ActiveSessionsDAO.java | 19 ++
.../dao/SignedDirectMessagesHistoryDAO.java | 47 ++++
.../java/shine/db/dao/SignedDmReplayDAO.java | 50 ++++
.../SignedDirectMessageHistoryEntry.java | 35 +++
shine-server-net-protocol/build.gradle | 2 +-
.../Net_SendDirectMessage_Handler.java | 228 ++++++++++++------
.../messages/Net_UpsertPushToken_Handler.java | 31 ++-
.../messages/SignedDirectMessagePacket.java | 134 ++++++++++
.../Net_SendDirectMessage_Request.java | 9 +-
.../Net_SendDirectMessage_Response.java | 9 +-
.../entyties/Net_UpsertPushToken_Request.java | 18 +-
.../ws_protocol/JSON/push/WebPushSender.java | 57 +++++
src/main/resources/application.properties | 6 +-
21 files changed, 875 insertions(+), 189 deletions(-)
create mode 100644 TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md
create mode 100644 shine-server-db/src/main/java/shine/db/dao/SignedDirectMessagesHistoryDAO.java
create mode 100644 shine-server-db/src/main/java/shine/db/dao/SignedDmReplayDAO.java
create mode 100644 shine-server-db/src/main/java/shine/db/entities/SignedDirectMessageHistoryEntry.java
create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedDirectMessagePacket.java
create mode 100644 shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java
diff --git a/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md b/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md
new file mode 100644
index 0000000..7019aed
--- /dev/null
+++ b/TASKS/02-12.04.26-web-push-direct-message/01-ТЗ-web-push-direct-message.md
@@ -0,0 +1,153 @@
+# Задача 02: Web Push + подписанный API отправки личных сообщений
+
+## Контекст (по текущему состоянию проекта)
+- Уже есть JSON WebSocket API для личных сообщений: `SendDirectMessage`, `AckIncomingMessage`, `UpsertPushToken`.
+- Сейчас серверный fallback-пуш реализован через FCM (`FcmPushSender`) и ключ `fcm.server.key`.
+- Клиент уже регистрирует service worker и токен Firebase, затем аплоадит push token на сервер.
+
+## Цель
+Добавить полностью рабочий сценарий доставки личных сообщений с приоритетом:
+1) онлайн-доставка в активную WebSocket-сессию;
+2) если не подтверждено — Web Push;
+3) поддержать отдельный API отправки без авторизации, где доступ проверяется цифровой подписью Ed25519 по `deviceKey` отправителя.
+
+---
+
+## Предварительная спецификация подписанного пакета (v1)
+> ВАЖНО: финально фиксируется после уточнений по endian/кодировкам/лимитам.
+
+Пакет (binary):
+1. `prefix` — ASCII-константа, например `SHINE_MESSAGE`.
+2. `toLoginLen` — 1 байт.
+3. `toLogin` — ASCII, длина = `toLoginLen`.
+4. `fromLoginLen` — 1 байт.
+5. `fromLogin` — ASCII, длина = `fromLoginLen`.
+6. `timeMs` — 8 байт (unix ms).
+7. `nonce32` — 4 байта случайное число.
+8. `messageType` — 4 байта.
+9. `targetMode` — 1 байт:
+ - `0` = всем сессиям пользователя,
+ - `1` = конкретной сессии.
+10. Если `targetMode=1`:
+ - `sessionIdLen` — 1 байт,
+ - `sessionId` — ASCII.
+11. `messageLen` — 2 байта.
+12. `messageBytes` — бинарные данные длиной `messageLen`.
+13. `signature64` — 64 байта, Ed25519 подпись всего блока **без** `signature64`.
+
+Ограничения (первичный draft):
+- общий размер пакета ≤ 4000 байт;
+- логины/префикс/идентификатор сессии — ASCII;
+- повторы отсекаются по `(fromLogin, timeMs, nonce32)` в окне TTL.
+
+---
+
+## Сервер: что доработать
+
+### 1) Новый endpoint без авторизации
+Операция (через WS JSON обертку) условно `SendSignedDirectMessage`:
+- принимает пакет (base64 binary blob);
+- парсит и валидирует формат;
+- достает `fromLogin`, поднимает `deviceKey` пользователя;
+- проверяет подпись Ed25519;
+- проверяет анти-replay (time window + nonce);
+- отправляет сообщение по правилам маршрутизации;
+- пишет результат (messageId, каналы доставки, причины недоставки).
+
+### 2) Маршрутизация доставки
+Для `targetMode=1`:
+- если целевая сессия онлайн и ACK пришел вовремя — успех;
+- иначе отправка в Web Push этой сессии (если есть subscription).
+
+Для `targetMode=0`:
+- обход всех сессий пользователя;
+- сначала online delivery + ACK;
+- для непринятых/офлайн — Web Push по соответствующим subscription;
+- если subscription отсутствует — тихий skip.
+
+### 3) Миграция от FCM к Web Push
+- добавить конфиг VAPID (`webpush.public.key`, `webpush.private.key`, `webpush.subject`);
+- хранить на сервере не только token, а web-push subscription (endpoint + keys);
+- сделать отправщик Web Push и заменить/расширить текущий `FcmPushSender`.
+
+### 4) Безопасность
+- строгая ASCII-валидация логинов/sessionId;
+- лимиты длины всех полей;
+- rate limit на endpoint;
+- audit-лог неуспешных проверок подписи/формата;
+- защита от replay.
+
+---
+
+## Клиент (shine-UI): что доработать
+
+1. Перейти на стандартный Web Push flow:
+ - регистрация service worker;
+ - `PushManager.subscribe(...)` с VAPID public key;
+ - отправка subscription на сервер (`UpsertPushSubscription` или расширение `UpsertPushToken`).
+
+2. Service worker:
+ - `push` handler получает payload целиком;
+ - показывает системное уведомление;
+ - при клике открывает/фокусирует нужный чат.
+
+3. Online-сообщения:
+ - сохранить текущий event-канал `IncomingDirectMessage`;
+ - обязателен ACK (`AckIncomingMessage` уже есть).
+
+4. Keep-alive:
+ - UI отправляет `Ping` раз в 60 секунд при активной сессии.
+
+---
+
+## Документация
+Сделать отдельный документ настройки Web Push:
+- как сгенерировать VAPID ключи;
+- какие параметры прописать на сервере и в UI;
+- как проверить локально e2e (онлайн + офлайн пуш);
+- ограничения payload и рекомендации по ретраям.
+
+---
+
+## Этапы реализации (предложение)
+1. Зафиксировать бинарный формат + валидации.
+2. Реализовать серверный parser/validator/signature verify/replay guard.
+3. Реализовать Web Push sender + storage subscription.
+4. Подключить новый endpoint и маршрутизацию доставки.
+5. Обновить UI (subscription + service worker + ping timer).
+6. Добавить интеграционные тесты (online ACK / offline push / bad signature / replay / oversize).
+7. Добавить документацию.
+
+---
+
+## Что нужно уточнить до разработки
+1. Endian для `timeMs/nonce/messageType/messageLen` (big-endian или little-endian).
+2. Что именно подписывается: строго весь префикс..messageBytes (без подписи) — подтвердить.
+3. Диапазон допустимых `messageType`.
+4. TTL окна для анти-replay (например 5 минут / 15 минут).
+5. Лимиты длин для login/session/message.
+6. Можно ли временно оставить FCM как fallback, пока не готов Web Push в проде.
+7. Формат сообщения в `messageBytes`: opaque bytes или UTF-8 строка.
+
+
+## Статус реализации (12.04.2026)
+
+### Что уже внедрено в коде
+- `SendDirectMessage` переведён на signed-binary payload (`blobB64`) без обязательной авторизации WS-сессии.
+- Внедрён бинарный парсер пакета формата `SHiNE_msg + version(1) + ... + signature64`.
+- Проверка подписи Ed25519 делается по `deviceKey` отправителя через `shine-server-crypto` (`Ed25519Util`).
+- Добавлен anti-replay guard `(from_login, time_ms, nonce)` с TTL 15 минут.
+- Добавлено историческое хранилище `signed_direct_messages_history` с сырым пакетом `raw_packet`.
+- Логика доставки: сначала WS+ACK, затем fallback на Web Push (по подписке конкретной session).
+- Поле типа сообщения переведено на `uint16`, пока поддерживается только `1`.
+- Для `targetMode=1` при несуществующей сессии возвращается `success` с `sessionNotFound=true` и `delivered=0`.
+- UI переведён с Firebase/FCM на браузерный `PushManager.subscribe` + Service Worker `push`.
+- Добавлен keep-alive ping из UI раз в 60 секунд при авторизованной сессии.
+
+### Что настроить в окружении
+- В `application.properties` задать:
+ - `webpush.vapid.public`
+ - `webpush.vapid.private`
+ - `webpush.vapid.subject`
+- В `shine-UI/index.html` задать публичный VAPID ключ в `window.__SHINE_WEBPUSH_VAPID_PUBLIC_KEY__`.
+
diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js
index 28ce1aa..cf68f0a 100644
--- a/shine-UI/firebase-messaging-sw.js
+++ b/shine-UI/firebase-messaging-sw.js
@@ -1,30 +1,20 @@
-/* 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);
+self.addEventListener('push', (event) => {
+ let body = 'Новое сообщение SHiNE';
+ try {
+ if (event.data) {
+ const text = event.data.text();
+ body = text || body;
+ }
+ } catch {
+ // ignore
}
- 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);
- });
-}
+
+ event.waitUntil(self.registration.showNotification('SHiNE: входящее сообщение', {
+ body,
+ tag: 'shine-direct-message',
+ renotify: true,
+ }));
+});
diff --git a/shine-UI/index.html b/shine-UI/index.html
index 6272b58..e452966 100644
--- a/shine-UI/index.html
+++ b/shine-UI/index.html
@@ -27,17 +27,9 @@