Add WS push events, PWA/FCM scaffolding, and direct messaging MVP

This commit is contained in:
ai5590
2026-04-04 18:10:25 +03:00
parent cf5460c5c7
commit 32c046233b
40 changed files with 1300 additions and 114 deletions
+30
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);
});
}
+13 -1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<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>
<link rel="stylesheet" href="./styles/main.css?v=20260403081123" />
<link rel="stylesheet" href="./styles/layout.css?v=20260403081123" />
@@ -11,10 +12,21 @@
<body>
<div class="app-shell">
<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>
<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>
</body>
</html>
+30 -10
View File
@@ -1,7 +1,7 @@
import { navigate, getRoute, PRE_AUTH_PAGES } from './router.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 { initPwaPush } from './services/pwa-push-service.js?v=20260403081123';
import {
authService,
authorizeSession,
@@ -10,7 +10,8 @@ import {
setSessionResetHandler,
state,
terminateCurrentSession,
togglePageLabel,
addIncomingMessage,
setContacts,
} from './state.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 labelEl = document.getElementById('page-label-slot');
const toolbarEl = document.getElementById('toolbar-slot');
let currentCleanup = null;
@@ -140,16 +140,9 @@ function renderApp() {
const showAppChrome = page.pageMeta?.showAppChrome !== false;
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
labelEl.innerHTML = '';
toolbarEl.innerHTML = '';
if (showAppChrome) {
labelEl.append(
renderPageLabel(page.pageMeta.title, page.pageMeta.id, state.pageLabelCollapsed, () => {
togglePageLabel();
renderApp();
}),
);
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);
authorizeSession(resumed);
await refreshSessions();
try {
const contacts = await authService.listContacts();
setContacts(contacts.contacts || []);
} catch {}
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
@@ -174,7 +171,30 @@ async function init() {
setSessionResetHandler(() => {
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();
if (state.session.isAuthorized) {
await initPwaPush({ authService });
}
if (!window.location.hash) {
navigate(state.session.isAuthorized ? 'profile-view' : 'start-view');
+35 -4
View File
@@ -1,6 +1,6 @@
import { renderHeader } from '../components/header.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: 'Чат' };
@@ -18,7 +18,11 @@ function renderLog(list, chatId) {
export function render({ navigate, route }) {
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');
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">Пользователь не в контактах. Можно отвечать, если он уже писал вам.</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');
wrap.className = 'chat-wrap';
@@ -43,12 +64,22 @@ export function render({ navigate, route }) {
<button class="primary-btn" type="submit">Отправить</button>
`;
form.addEventListener('submit', (event) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const input = form.elements.message;
addChatMessage(chatId, input.value);
const text = input.value.trim();
if (!text) return;
addChatMessage(chatId, text);
input.value = '';
renderLog(log, chatId);
try {
await authService.sendDirectMessage(chatId, text);
} catch (e) {
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
renderLog(log, chatId);
}
});
renderLog(log, chatId);
+32 -31
View File
@@ -1,18 +1,9 @@
import { renderHeader } from '../components/header.js?v=20260403081123';
import { contactDirectory, directMessages } from '../mock-data.js?v=20260403081123';
import { ensureChat } from '../state.js?v=20260403081123';
import { directMessages } from '../mock-data.js?v=20260403081123';
import { authService, ensureChat, setContacts, state } from '../state.js?v=20260403081123';
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 }) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -21,7 +12,7 @@ export function render({ navigate }) {
input.className = 'input';
input.type = 'text';
input.name = 'contact';
input.placeholder = 'Введите имя контакта';
input.placeholder = 'Введите начало логина';
input.autocomplete = 'off';
input.maxLength = 80;
@@ -43,7 +34,7 @@ export function render({ navigate }) {
resultsCard.hidden = false;
if (!query.trim()) {
status.textContent = 'Введите первые буквы имени, чтобы найти контакт.';
status.textContent = 'Введите начало логина пользователя.';
return;
}
@@ -54,16 +45,16 @@ export function render({ navigate }) {
status.textContent = `Найдено пользователей: ${matches.length}`;
matches.forEach((contact) => {
matches.forEach((login) => {
const row = document.createElement('article');
row.className = 'list-item';
row.innerHTML = `
<div class="avatar">${contact.initials}</div>
<div class="avatar">${(login[0] || '?').toUpperCase()}</div>
<div>
<strong>${contact.name}</strong>
<p class="meta-muted" style="margin-top:4px;">${contact.about}</p>
<strong>${login}</strong>
<p class="meta-muted" style="margin-top:4px;">Пользователь сервера</p>
</div>
<div class="meta-muted">Контакт</div>
<div class="meta-muted">Профиль</div>
`;
resultsList.append(row);
});
@@ -72,38 +63,48 @@ export function render({ navigate }) {
const searchButton = document.createElement('button');
searchButton.className = 'primary-btn';
searchButton.type = 'button';
searchButton.textContent = 'Найти';
searchButton.addEventListener('click', () => {
renderResults(getMatches(input.value), input.value);
searchButton.textContent = 'Поиск';
searchButton.addEventListener('click', async () => {
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');
addButton.className = 'ghost-btn';
addButton.type = 'button';
addButton.textContent = 'Добавить';
addButton.textContent = 'Открыть чат';
addButton.addEventListener('click', () => {
if (!latestMatches.length) {
status.textContent = 'Сначала выполните поиск, чтобы добавить контакт.';
status.textContent = 'Сначала выполните поиск.';
resultsCard.hidden = false;
return;
}
const contact = latestMatches[0];
const exists = directMessages.some((item) => item.id === contact.id);
const login = latestMatches[0];
const exists = directMessages.some((item) => item.id === login);
if (!exists) {
directMessages.unshift({
id: contact.id,
name: contact.name,
initials: contact.initials,
lastMessage: 'Новый контакт добавлен. Можно начинать диалог.',
id: login,
name: login,
initials: (login[0] || '?').toUpperCase(),
lastMessage: 'Диалог создан. Пользователь пока не в контактах.',
time: 'сейчас',
unread: 0,
});
}
ensureChat(contact.id);
navigate(`chat-view/${contact.id}`);
if (!state.contacts.includes(login)) {
setContacts([...state.contacts, login]);
}
ensureChat(login);
navigate(`chat-view/${login}`);
});
const controls = document.createElement('div');
+57 -59
View File
@@ -1,77 +1,75 @@
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: 'Связи' };
function toPoint(v) {
return `${v.x}%`;
}
function showHelpModal() {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="network-help-modal">
<div class="modal-card stack">
<h3 style="font-size:18px;">Справка по схеме связей</h3>
<p class="meta-muted">В центре находишься ты.</p>
<p class="meta-muted">Рядом показаны друзья первого уровня.</p>
<p class="meta-muted">Далее могут существовать друзья второго уровня.</p>
<p class="meta-muted">При одном нажатии на узел можно показать его связи.</p>
<p class="meta-muted">При двойном нажатии узел может переместиться в центр.</p>
<p class="meta-muted">При долгом удержании может открываться меню действий.</p>
<p class="meta-muted">Логика схемы строится на одном запросе связей пользователя, дальше дерево достраивается на его основе.</p>
<button class="primary-btn" id="close-network-help">Понятно</button>
</div>
</div>
`;
root.querySelector('#close-network-help').addEventListener('click', () => {
root.innerHTML = '';
});
function makeNode(name, cls = '') {
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;
}
export function render() {
const screen = document.createElement('section');
screen.className = 'stack';
const header = renderHeader({
title: 'Связи',
rightActions: [{ label: 'Справка', onClick: showHelpModal }],
});
const board = document.createElement('div');
board.className = 'network-board';
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);
});
board.style.height = 'calc(100dvh - 170px)';
const note = document.createElement('p');
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'}`;
}
};
load();
screen.append(renderHeader({ title: 'Связи' }), board, note);
return screen;
}
+41
View File
@@ -235,6 +235,47 @@ export class AuthService {
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 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) {
try {
const response = await this.ws.request('ClientErrorLog', details || {}, 3000);
+51
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
}
}
+29 -1
View File
@@ -21,6 +21,7 @@ export class WsJsonClient {
this.ws = null;
this.pending = new Map();
this.openPromise = null;
this.eventListeners = new Map();
}
async open() {
@@ -53,6 +54,7 @@ export class WsJsonClient {
});
}).finally(() => {
this.openPromise = null;
this.eventListeners = new Map();
});
return this.openPromise;
@@ -116,14 +118,40 @@ export class WsJsonClient {
}
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);
if (!slot) return;
this.pending.delete(requestId);
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) {
const pendingOps = [...this.pending.values()]
.map((slot) => slot.op)
+16
View File
@@ -41,6 +41,8 @@ function createInitialState({ withStoredSession = true } = {}) {
const storedSession = withStoredSession ? loadStoredSession() : null;
return {
chats: clone(chatMessages),
contacts: [],
incomingDedup: {},
notificationsTab: 'replies',
pageLabelCollapsed: false,
session: {
@@ -121,6 +123,20 @@ export function addChatMessage(chatId, text) {
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() {
state.pageLabelCollapsed = !state.pageLabelCollapsed;
}
+20
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"
}
]
}
+1 -8
View File
@@ -19,7 +19,7 @@ body {
top: 0;
left: 0;
right: 0;
bottom: 118px;
bottom: 74px;
overflow-y: auto;
padding: 14px 14px 24px;
}
@@ -29,13 +29,6 @@ body {
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 {
position: absolute;