SHA256
WIP: новая схема сообщений и push (не проверено)
This commit is contained in:
+85
-36
@@ -7,13 +7,15 @@ import {
|
||||
authService,
|
||||
addAppLogEntry,
|
||||
authorizeSession,
|
||||
hydrateMessagesFromStore,
|
||||
isSessionInvalidError,
|
||||
refreshSessions,
|
||||
setSessionAuthorizedHandler,
|
||||
setSessionResetHandler,
|
||||
state,
|
||||
terminateCurrentSession,
|
||||
addIncomingMessage,
|
||||
addSignedMessageToChat,
|
||||
markOutgoingReadByBaseKey,
|
||||
setContacts,
|
||||
} from './state.js';
|
||||
|
||||
@@ -338,48 +340,94 @@ async function init() {
|
||||
await terminateCurrentSession({ infoMessage: 'Сессия закрыта с другого устройства.' });
|
||||
});
|
||||
|
||||
authService.onEvent('IncomingDirectMessage', async (evt) => {
|
||||
authService.onEvent('SignedMessageArrived', async (evt) => {
|
||||
const payload = evt?.payload || {};
|
||||
const fromLogin = payload.fromLogin || 'unknown';
|
||||
const messageId = payload.messageId || '';
|
||||
const eventId = payload.eventId || evt?.requestId || '';
|
||||
let text = payload.text || '';
|
||||
if (!text && payload.blobB64) {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(payload.blobB64), (ch) => ch.charCodeAt(0));
|
||||
const msgLen = (bytes[bytes.length - 66] << 8) | bytes[bytes.length - 65];
|
||||
const msgStart = bytes.length - 64 - msgLen;
|
||||
const msgBytes = bytes.slice(msgStart, msgStart + msgLen);
|
||||
text = new TextDecoder().decode(msgBytes);
|
||||
} catch {
|
||||
text = '[binary message]';
|
||||
}
|
||||
}
|
||||
const added = addIncomingMessage(fromLogin, text, messageId);
|
||||
if (added) {
|
||||
const messageKey = String(payload.messageKey || '').trim();
|
||||
const blobB64 = String(payload.blobB64 || '').trim();
|
||||
if (!messageKey || !blobB64) return;
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = authService.parseSignedMessageBlob(blobB64);
|
||||
} catch (error) {
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'incoming-dm',
|
||||
message: `Входящее сообщение от ${fromLogin}`,
|
||||
details: { messageId, text },
|
||||
level: 'warn',
|
||||
source: 'signed-dm',
|
||||
message: 'Не удалось распарсить входящий signed message',
|
||||
details: { messageKey, error: error?.message || 'unknown' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (added && Notification.permission === 'granted') {
|
||||
try {
|
||||
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
|
||||
} catch {}
|
||||
}
|
||||
if (eventId) {
|
||||
try {
|
||||
await authService.ackIncomingMessage(eventId, messageId);
|
||||
} catch (error) {
|
||||
|
||||
const myLogin = String(state.session.login || '').trim().toLowerCase();
|
||||
const fromLogin = parsed.fromLogin || '';
|
||||
const toLogin = parsed.toLogin || '';
|
||||
const chatId = String(fromLogin || '').toLowerCase() === myLogin ? toLogin : fromLogin;
|
||||
const messageType = Number(parsed.messageType || 0);
|
||||
const text = (messageType === 1 || messageType === 2)
|
||||
? new TextDecoder().decode(parsed.payloadBytes || new Uint8Array(0))
|
||||
: '';
|
||||
|
||||
if (messageType === 1 || messageType === 2) {
|
||||
const isIncomingForCurrent = messageType === 1;
|
||||
const added = addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey,
|
||||
baseKey: parsed.baseKey,
|
||||
from: isIncomingForCurrent ? 'in' : 'out',
|
||||
text,
|
||||
messageType,
|
||||
unread: isIncomingForCurrent,
|
||||
rawBlobB64: blobB64,
|
||||
});
|
||||
if (added) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'incoming-dm',
|
||||
message: 'Не удалось отправить ACK на входящее сообщение',
|
||||
details: { eventId, messageId, error: error?.message || 'unknown' },
|
||||
level: 'info',
|
||||
source: 'signed-dm',
|
||||
message: isIncomingForCurrent
|
||||
? `Новое входящее сообщение от ${fromLogin}`
|
||||
: `Синхронизирована исходящая копия в чате ${chatId}`,
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType },
|
||||
});
|
||||
}
|
||||
if (added && isIncomingForCurrent && Notification.permission === 'granted' && !payload.backlog) {
|
||||
try {
|
||||
new Notification(`Сообщение от ${fromLogin}`, { body: text || '' });
|
||||
} catch {}
|
||||
}
|
||||
} else if (messageType === 3 || messageType === 4) {
|
||||
const refBaseKey = String(payload.receiptRefBaseKey || '').trim();
|
||||
if (refBaseKey) {
|
||||
markOutgoingReadByBaseKey(refBaseKey);
|
||||
} else {
|
||||
try {
|
||||
const ref = authService.parseReadReceiptPayload(parsed.payloadBytes);
|
||||
const fallbackRefBase = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`;
|
||||
markOutgoingReadByBaseKey(fallbackRefBase);
|
||||
} catch {}
|
||||
}
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'signed-dm',
|
||||
message: 'Получено подтверждение прочтения',
|
||||
details: { messageKey, baseKey: parsed.baseKey, messageType },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.ackSessionDelivery(messageKey);
|
||||
} catch (error) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'signed-dm',
|
||||
message: 'Не удалось отправить ACK доставки по сессии',
|
||||
details: { messageKey, error: error?.message || 'unknown' },
|
||||
});
|
||||
}
|
||||
|
||||
const pageId = getRoute().pageId || '';
|
||||
if (pageId === 'chat-view' || pageId === 'messages-list') {
|
||||
renderApp();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -392,6 +440,7 @@ async function init() {
|
||||
});
|
||||
|
||||
await tryAutoLogin();
|
||||
await hydrateMessagesFromStore();
|
||||
await ensureSessionRuntimeStarted();
|
||||
|
||||
if (!window.location.hash) {
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import { addAppLogEntry, addChatMessage, getChatMessages, authService, state } from '../state.js';
|
||||
import {
|
||||
addAppLogEntry,
|
||||
addChatMessage,
|
||||
addOutgoingPendingMessage,
|
||||
getChatMessages,
|
||||
markChatRead,
|
||||
markOutgoingSent,
|
||||
authService,
|
||||
state,
|
||||
} from '../state.js';
|
||||
import { startOutgoingCall, hangupActiveCall } from '../services/call-service.js';
|
||||
|
||||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||
|
||||
function parseBaseKey(baseKey) {
|
||||
const raw = String(baseKey || '').trim();
|
||||
const parts = raw.split('|');
|
||||
if (parts.length < 4) return null;
|
||||
const fromLogin = parts[0] || '';
|
||||
const toLogin = parts[1] || '';
|
||||
const timeMs = Number(parts[2] || 0);
|
||||
const nonce = Number(parts[3] || 0);
|
||||
if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || !Number.isFinite(nonce)) return null;
|
||||
return { fromLogin, toLogin, timeMs, nonce };
|
||||
}
|
||||
|
||||
function renderLog(list, chatId) {
|
||||
list.innerHTML = '';
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
messages.forEach((msg) => {
|
||||
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'meta-muted';
|
||||
sep.style.textAlign = 'center';
|
||||
sep.style.margin = '8px 0';
|
||||
sep.textContent = 'Новые сообщения';
|
||||
list.append(sep);
|
||||
unreadSeparatorInserted = true;
|
||||
}
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = `bubble ${msg.from}`;
|
||||
bubble.textContent = msg.text;
|
||||
let text = msg.text || '';
|
||||
if (msg.from === 'out') {
|
||||
if (msg.secondTick) text += ' ✓✓';
|
||||
else if (msg.firstTick) text += ' ✓';
|
||||
else text += ' …';
|
||||
}
|
||||
bubble.textContent = text;
|
||||
list.append(bubble);
|
||||
});
|
||||
list.scrollTop = list.scrollHeight;
|
||||
markChatRead(chatId);
|
||||
}
|
||||
|
||||
export function render({ navigate, route }) {
|
||||
@@ -27,6 +66,7 @@ export function render({ navigate, route }) {
|
||||
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack';
|
||||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
@@ -60,6 +100,37 @@ export function render({ navigate, route }) {
|
||||
})
|
||||
);
|
||||
|
||||
if (!isKnownContact) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'secondary-btn';
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Добавить в контакты';
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await authService.addCloseFriend(chatId);
|
||||
state.contacts = [...new Set([...(state.contacts || []), chatId])];
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'contacts',
|
||||
message: `Пользователь ${chatId} добавлен в контакты`,
|
||||
});
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Добавлено';
|
||||
} catch (e) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'contacts',
|
||||
message: 'Не удалось добавить пользователя в контакты',
|
||||
details: { login: chatId, error: e?.message || 'unknown' },
|
||||
});
|
||||
}
|
||||
});
|
||||
card.append(btn);
|
||||
screen.append(card);
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chat-wrap';
|
||||
|
||||
@@ -79,7 +150,7 @@ export function render({ navigate, route }) {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
addChatMessage(chatId, text);
|
||||
const tempId = addOutgoingPendingMessage(chatId, text);
|
||||
input.value = '';
|
||||
renderLog(log, chatId);
|
||||
|
||||
@@ -90,16 +161,20 @@ export function render({ navigate, route }) {
|
||||
text,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
});
|
||||
markOutgoingSent(tempId, {
|
||||
messageKey: result?.outgoingKey || '',
|
||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
});
|
||||
renderLog(log, chatId);
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'outgoing-dm',
|
||||
message: `Сообщение отправлено для ${chatId}`,
|
||||
details: {
|
||||
toLogin: chatId,
|
||||
messageId: result?.messageId || '',
|
||||
messageId: result?.outgoingKey || '',
|
||||
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
|
||||
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
|
||||
sessionNotFound: Boolean(result?.sessionNotFound),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -118,7 +193,37 @@ export function render({ navigate, route }) {
|
||||
});
|
||||
|
||||
renderLog(log, chatId);
|
||||
void sendReadReceiptsForVisible();
|
||||
wrap.append(log, form);
|
||||
screen.append(wrap);
|
||||
return screen;
|
||||
}
|
||||
async function sendReadReceiptsForVisible() {
|
||||
const pending = getChatMessages(chatId)
|
||||
.filter((row) => row?.from === 'in' && Number(row?.messageType) === 1 && !row?.readReceiptSent)
|
||||
.slice(0, 50);
|
||||
for (const row of pending) {
|
||||
const ref = parseBaseKey(row.baseKey);
|
||||
if (!ref) continue;
|
||||
try {
|
||||
await authService.sendReadReceipt({
|
||||
login: state.session.login,
|
||||
toLogin: ref.fromLogin,
|
||||
storagePwd: state.session.storagePwdInMemory,
|
||||
refToLogin: ref.toLogin,
|
||||
refFromLogin: ref.fromLogin,
|
||||
refTimeMs: ref.timeMs,
|
||||
refNonce: ref.nonce,
|
||||
refType: 1,
|
||||
});
|
||||
row.readReceiptSent = true;
|
||||
} catch (e) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
source: 'read-receipt',
|
||||
message: 'Не удалось отправить подтверждение прочтения',
|
||||
details: { chatId, messageKey: row.messageKey || '', error: e?.message || 'unknown' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { directMessages } from '../mock-data.js';
|
||||
import { getChatMessages } from '../state.js';
|
||||
import { getChatMessages, setContacts, state } from '../state.js';
|
||||
import { loadCurrentRelations } from '../services/user-connections.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
@@ -30,6 +30,7 @@ export function render({ navigate }) {
|
||||
<div>
|
||||
<div class="row" style="justify-content:flex-start; gap:8px;">
|
||||
<strong>${item.name}</strong>
|
||||
${item.notInContacts ? '<span class="meta-muted">не в контактах</span>' : ''}
|
||||
</div>
|
||||
<p class="meta-muted" style="margin-top:4px;">${item.lastMessage}</p>
|
||||
</div>
|
||||
@@ -46,32 +47,57 @@ export function render({ navigate }) {
|
||||
try {
|
||||
const relations = await loadCurrentRelations();
|
||||
const contacts = relations.outContacts || [];
|
||||
setContacts(contacts);
|
||||
list.innerHTML = '';
|
||||
|
||||
if (!contacts.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Ваш список контактов пока пуст';
|
||||
list.append(empty);
|
||||
status.className = 'status-line is-available';
|
||||
status.textContent = 'Нет контактов.';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = contacts.map((login) => {
|
||||
const contactRows = contacts.map((login) => {
|
||||
const preview = directMessages.find((item) => item.id.toLowerCase() === login.toLowerCase());
|
||||
const chat = getChatMessages(login);
|
||||
const lastChat = chat[chat.length - 1];
|
||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
||||
return {
|
||||
id: login,
|
||||
initials: (login[0] || '?').toUpperCase(),
|
||||
name: preview?.name || login,
|
||||
lastMessage: lastChat?.text || preview?.lastMessage || 'Диалог пока пуст.',
|
||||
time: preview?.time || '—',
|
||||
unread: Number(preview?.unread || 0),
|
||||
unread,
|
||||
notInContacts: false,
|
||||
};
|
||||
});
|
||||
|
||||
const allChatIds = Object.keys(state.chats || {})
|
||||
.filter((id) => id && id.toLowerCase() !== String(state.session.login || '').toLowerCase())
|
||||
.filter((id) => (getChatMessages(id) || []).length > 0);
|
||||
|
||||
const contactKeys = new Set(contacts.map((x) => String(x || '').toLowerCase()));
|
||||
const extraRows = allChatIds
|
||||
.filter((login) => !contactKeys.has(String(login || '').toLowerCase()))
|
||||
.map((login) => {
|
||||
const chat = getChatMessages(login);
|
||||
const lastChat = chat[chat.length - 1];
|
||||
const unread = chat.filter((m) => m?.from === 'in' && m?.unread).length;
|
||||
return {
|
||||
id: login,
|
||||
initials: (login[0] || '?').toUpperCase(),
|
||||
name: login,
|
||||
lastMessage: lastChat?.text || 'Диалог пока пуст.',
|
||||
time: 'сейчас',
|
||||
unread,
|
||||
notInContacts: true,
|
||||
};
|
||||
});
|
||||
|
||||
const rows = [...contactRows, ...extraRows];
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Пока нет ни контактов, ни сообщений';
|
||||
list.append(empty);
|
||||
status.className = 'status-line is-available';
|
||||
status.textContent = 'Нет диалогов.';
|
||||
return;
|
||||
}
|
||||
|
||||
rows.forEach((item) => list.append(renderRow(item)));
|
||||
status.className = 'status-line is-available';
|
||||
status.textContent = `Загружено диалогов: ${rows.length}`;
|
||||
|
||||
@@ -200,6 +200,114 @@ function uint8Bytes(value) {
|
||||
return new Uint8Array([Number(value) & 0xff]);
|
||||
}
|
||||
|
||||
const DM2_PREFIX = utf8Bytes('SHiNE_dm2');
|
||||
const DM2_TYPE_INCOMING = 1;
|
||||
const DM2_TYPE_OUTGOING_COPY = 2;
|
||||
const DM2_TYPE_READ_INCOMING = 3;
|
||||
const DM2_TYPE_READ_OUTGOING_COPY = 4;
|
||||
|
||||
function ensureAsciiBytes(value, field, min = 1, max = 60) {
|
||||
const text = String(value || '').trim();
|
||||
const bytes = utf8Bytes(text);
|
||||
if (bytes.length < min || bytes.length > max) {
|
||||
throw new Error(`${field} должен быть ${min}..${max} ASCII-символов`);
|
||||
}
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
const code = bytes[i];
|
||||
if (code < 0x20 || code > 0x7e) throw new Error(`${field} должен быть ASCII`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function dm2BaseKey({ toLogin, fromLogin, timeMs, nonce }) {
|
||||
return `${fromLogin}|${toLogin}|${Number(timeMs)}|${Number(nonce)}`;
|
||||
}
|
||||
|
||||
function dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType }) {
|
||||
return `${dm2BaseKey({ toLogin, fromLogin, timeMs, nonce })}|${Number(messageType)}`;
|
||||
}
|
||||
|
||||
function buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType }) {
|
||||
const toBytes = ensureAsciiBytes(refToLogin, 'receipt.refToLogin');
|
||||
const fromBytes = ensureAsciiBytes(refFromLogin, 'receipt.refFromLogin');
|
||||
return concatBytes(
|
||||
uint8Bytes(toBytes.length), toBytes,
|
||||
uint8Bytes(fromBytes.length), fromBytes,
|
||||
uint64Bytes(refTimeMs),
|
||||
uint32Bytes(refNonce),
|
||||
uint16Bytes(refType),
|
||||
);
|
||||
}
|
||||
|
||||
function parseSignedMessageBlockBytes(bytes) {
|
||||
if (!(bytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
||||
let o = 0;
|
||||
const read = (n) => {
|
||||
if (o + n > bytes.length) throw new Error('BAD_LEN');
|
||||
const out = bytes.slice(o, o + n);
|
||||
o += n;
|
||||
return out;
|
||||
};
|
||||
const readU8 = () => read(1)[0];
|
||||
const readU16 = () => {
|
||||
const part = read(2);
|
||||
const view = new DataView(part.buffer, part.byteOffset, 2);
|
||||
return view.getUint16(0, false);
|
||||
};
|
||||
const readU32 = () => {
|
||||
const part = read(4);
|
||||
const view = new DataView(part.buffer, part.byteOffset, 4);
|
||||
return view.getUint32(0, false);
|
||||
};
|
||||
const readU64 = () => {
|
||||
const part = read(8);
|
||||
const view = new DataView(part.buffer, part.byteOffset, 8);
|
||||
return Number(view.getBigUint64(0, false));
|
||||
};
|
||||
const readAscii = () => {
|
||||
const len = readU8();
|
||||
const part = read(len);
|
||||
const text = new TextDecoder().decode(part);
|
||||
for (let i = 0; i < part.length; i += 1) {
|
||||
const c = part[i];
|
||||
if (c < 0x20 || c > 0x7e) throw new Error('BAD_ASCII');
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const prefix = read(DM2_PREFIX.length);
|
||||
for (let i = 0; i < DM2_PREFIX.length; i += 1) {
|
||||
if (prefix[i] !== DM2_PREFIX[i]) throw new Error('BAD_PREFIX');
|
||||
}
|
||||
|
||||
const toLogin = readAscii();
|
||||
const fromLogin = readAscii();
|
||||
const timeMs = readU64();
|
||||
const nonce = readU32();
|
||||
const messageType = readU16();
|
||||
const payloadLen = readU16();
|
||||
const payloadBytes = read(payloadLen);
|
||||
const signatureBytes = read(64);
|
||||
if (o !== bytes.length) throw new Error('BAD_LEN');
|
||||
|
||||
const signedBody = bytes.slice(0, bytes.length - 64);
|
||||
const baseKey = dm2BaseKey({ toLogin, fromLogin, timeMs, nonce });
|
||||
const messageKey = dm2MessageKey({ toLogin, fromLogin, timeMs, nonce, messageType });
|
||||
return {
|
||||
toLogin,
|
||||
fromLogin,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
payloadBytes,
|
||||
signatureBytes,
|
||||
signedBody,
|
||||
rawBytes: bytes,
|
||||
baseKey,
|
||||
messageKey,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUserParamBodyBytes({ lineCode, prevLineNumber, prevLineHashHex, thisLineNumber, key, value }) {
|
||||
const keyBytes = utf8Bytes(String(key || ''));
|
||||
const valueBytes = utf8Bytes(String(value || ''));
|
||||
@@ -1118,11 +1226,18 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async sendDirectMessage({ login, toLogin, text, storagePwd, targetSessionId = null, messageType = 1 }) {
|
||||
async buildSignedDm2Block({
|
||||
login,
|
||||
toLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType,
|
||||
payloadBytes,
|
||||
}) {
|
||||
const cleanFromLogin = String(login || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
const cleanText = String(text || '');
|
||||
if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text');
|
||||
if (!cleanFromLogin || !cleanToLogin) throw new Error('Не передан login/toLogin');
|
||||
if (!storagePwd) throw new Error('Не передан storagePwd для подписи');
|
||||
|
||||
const secrets = await loadEncryptedUserSecrets(cleanFromLogin, storagePwd);
|
||||
@@ -1130,46 +1245,147 @@ export class AuthService {
|
||||
if (!devicePriv) throw new Error('Не найден приватный deviceKey');
|
||||
const privateKey = await importPkcs8Ed25519(devicePriv);
|
||||
|
||||
const prefix = utf8Bytes('SHiNE_msg');
|
||||
const version = uint8Bytes(1);
|
||||
const toBytes = utf8Bytes(cleanToLogin);
|
||||
const fromBytes = utf8Bytes(cleanFromLogin);
|
||||
if (toBytes.length < 1 || toBytes.length > 30) throw new Error('toLogin должен быть 1..30 ASCII-символов');
|
||||
if (fromBytes.length < 1 || fromBytes.length > 30) throw new Error('fromLogin должен быть 1..30 ASCII-символов');
|
||||
if (cleanText.length > 3000) throw new Error('Слишком длинное сообщение');
|
||||
|
||||
const mode = targetSessionId ? 1 : 0;
|
||||
const targetBytes = targetSessionId ? utf8Bytes(String(targetSessionId)) : new Uint8Array(0);
|
||||
if (mode === 1 && (targetBytes.length < 1 || targetBytes.length > 255)) {
|
||||
throw new Error('targetSessionId должен быть 1..255 символов');
|
||||
const toBytes = ensureAsciiBytes(cleanToLogin, 'toLogin');
|
||||
const fromBytes = ensureAsciiBytes(cleanFromLogin, 'fromLogin');
|
||||
if (!(payloadBytes instanceof Uint8Array) || payloadBytes.length < 1 || payloadBytes.length > 4096) {
|
||||
throw new Error('payload должен быть 1..4096 байт');
|
||||
}
|
||||
const bodyBytes = utf8Bytes(cleanText);
|
||||
|
||||
const preimage = concatBytes(
|
||||
prefix,
|
||||
version,
|
||||
DM2_PREFIX,
|
||||
uint8Bytes(toBytes.length), toBytes,
|
||||
uint8Bytes(fromBytes.length), fromBytes,
|
||||
uint64Bytes(Date.now()),
|
||||
uint32Bytes(Math.floor(Math.random() * 0x100000000)),
|
||||
uint64Bytes(timeMs),
|
||||
uint32Bytes(nonce),
|
||||
uint16Bytes(messageType),
|
||||
uint8Bytes(mode),
|
||||
mode === 1 ? concatBytes(uint8Bytes(targetBytes.length), targetBytes) : new Uint8Array(0),
|
||||
uint16Bytes(bodyBytes.length),
|
||||
bodyBytes,
|
||||
uint16Bytes(payloadBytes.length),
|
||||
payloadBytes,
|
||||
);
|
||||
const signature = await signBytes(privateKey, preimage);
|
||||
const packet = concatBytes(preimage, signature);
|
||||
const blobB64 = bytesToBase64(packet);
|
||||
return concatBytes(preimage, signature);
|
||||
}
|
||||
|
||||
const response = await this.ws.request('SendDirectMessage', { blobB64 });
|
||||
if (response.status !== 200) throw opError('SendDirectMessage', response);
|
||||
parseSignedMessageBlob(blobB64) {
|
||||
const bytes = base64ToBytes(String(blobB64 || '').trim());
|
||||
return parseSignedMessageBlockBytes(bytes);
|
||||
}
|
||||
|
||||
parseReadReceiptPayload(payloadBytes) {
|
||||
if (!(payloadBytes instanceof Uint8Array)) throw new Error('Expected Uint8Array');
|
||||
let o = 0;
|
||||
const read = (n) => {
|
||||
if (o + n > payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
|
||||
const out = payloadBytes.slice(o, o + n);
|
||||
o += n;
|
||||
return out;
|
||||
};
|
||||
const readU8 = () => read(1)[0];
|
||||
const readU16 = () => {
|
||||
const part = read(2);
|
||||
return new DataView(part.buffer, part.byteOffset, 2).getUint16(0, false);
|
||||
};
|
||||
const readU32 = () => {
|
||||
const part = read(4);
|
||||
return new DataView(part.buffer, part.byteOffset, 4).getUint32(0, false);
|
||||
};
|
||||
const readU64 = () => {
|
||||
const part = read(8);
|
||||
return Number(new DataView(part.buffer, part.byteOffset, 8).getBigUint64(0, false));
|
||||
};
|
||||
const readAscii = () => {
|
||||
const len = readU8();
|
||||
const part = read(len);
|
||||
return new TextDecoder().decode(part);
|
||||
};
|
||||
const refToLogin = readAscii();
|
||||
const refFromLogin = readAscii();
|
||||
const refTimeMs = readU64();
|
||||
const refNonce = readU32();
|
||||
const refType = readU16();
|
||||
if (o !== payloadBytes.length) throw new Error('BAD_RECEIPT_LEN');
|
||||
return { refToLogin, refFromLogin, refTimeMs, refNonce, refType };
|
||||
}
|
||||
|
||||
async sendMessagePair({ incomingBlobB64, outgoingBlobB64 }) {
|
||||
const response = await this.ws.request('SendMessagePair', { incomingBlobB64, outgoingBlobB64 });
|
||||
if (response.status !== 200) throw opError('SendMessagePair', 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);
|
||||
async sendDirectMessage({ login, toLogin, text, storagePwd }) {
|
||||
const cleanFromLogin = String(login || '').trim();
|
||||
const cleanToLogin = String(toLogin || '').trim();
|
||||
const cleanText = String(text || '');
|
||||
if (!cleanFromLogin || !cleanToLogin || !cleanText) throw new Error('Не передан login/toLogin/text');
|
||||
|
||||
const timeMs = Date.now();
|
||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||
const incomingPayload = utf8Bytes(cleanText);
|
||||
const outgoingPayload = utf8Bytes(cleanText);
|
||||
|
||||
const incomingBlock = await this.buildSignedDm2Block({
|
||||
login: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType: DM2_TYPE_INCOMING,
|
||||
payloadBytes: incomingPayload,
|
||||
});
|
||||
const outgoingBlock = await this.buildSignedDm2Block({
|
||||
login: cleanFromLogin,
|
||||
toLogin: cleanToLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType: DM2_TYPE_OUTGOING_COPY,
|
||||
payloadBytes: outgoingPayload,
|
||||
});
|
||||
|
||||
const payload = await this.sendMessagePair({
|
||||
incomingBlobB64: bytesToBase64(incomingBlock),
|
||||
outgoingBlobB64: bytesToBase64(outgoingBlock),
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
localIncomingBlobB64: bytesToBase64(incomingBlock),
|
||||
localOutgoingBlobB64: bytesToBase64(outgoingBlock),
|
||||
localBaseKey: dm2BaseKey({ toLogin: cleanToLogin, fromLogin: cleanFromLogin, timeMs, nonce }),
|
||||
};
|
||||
}
|
||||
|
||||
async sendReadReceipt({ login, toLogin, storagePwd, refToLogin, refFromLogin, refTimeMs, refNonce, refType = DM2_TYPE_INCOMING }) {
|
||||
const timeMs = Date.now();
|
||||
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||
const payload = buildReadReceiptPayloadBytes({ refToLogin, refFromLogin, refTimeMs, refNonce, refType });
|
||||
|
||||
const type3 = await this.buildSignedDm2Block({
|
||||
login,
|
||||
toLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType: DM2_TYPE_READ_INCOMING,
|
||||
payloadBytes: payload,
|
||||
});
|
||||
const type4 = await this.buildSignedDm2Block({
|
||||
login,
|
||||
toLogin,
|
||||
storagePwd,
|
||||
timeMs,
|
||||
nonce,
|
||||
messageType: DM2_TYPE_READ_OUTGOING_COPY,
|
||||
payloadBytes: payload,
|
||||
});
|
||||
return this.sendMessagePair({
|
||||
incomingBlobB64: bytesToBase64(type3),
|
||||
outgoingBlobB64: bytesToBase64(type4),
|
||||
});
|
||||
}
|
||||
|
||||
async ackSessionDelivery(messageKey) {
|
||||
const response = await this.ws.request('AckSessionDelivery', { messageKey });
|
||||
if (response.status !== 200) throw opError('AckSessionDelivery', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
const DB_NAME = 'shine-ui-messages-v1';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_MESSAGES = 'messages';
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_MESSAGES)) {
|
||||
const store = db.createObjectStore(STORE_MESSAGES, { keyPath: 'messageKey' });
|
||||
store.createIndex('by_chat', 'chatId', { unique: false });
|
||||
store.createIndex('by_ts', 'ts', { unique: false });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB open failed'));
|
||||
});
|
||||
}
|
||||
|
||||
async function withStore(mode, callback) {
|
||||
const db = await openDb();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_MESSAGES, mode);
|
||||
const store = tx.objectStore(STORE_MESSAGES);
|
||||
const result = callback(store, tx);
|
||||
tx.oncomplete = () => resolve(result);
|
||||
tx.onerror = () => reject(tx.error || new Error('IndexedDB transaction failed'));
|
||||
tx.onabort = () => reject(tx.error || new Error('IndexedDB transaction aborted'));
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function putStoredMessage(record) {
|
||||
if (!record || !record.messageKey) return;
|
||||
await withStore('readwrite', (store) => {
|
||||
store.put(record);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listStoredMessages() {
|
||||
return withStore('readonly', (store) => new Promise((resolve, reject) => {
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
|
||||
req.onerror = () => reject(req.error || new Error('IndexedDB getAll failed'));
|
||||
}));
|
||||
}
|
||||
+144
-2
@@ -1,6 +1,7 @@
|
||||
import { chatMessages, wallet } from './mock-data.js';
|
||||
import { AuthService } from './services/auth-service.js';
|
||||
import { clearClientAuthData } from './services/key-vault.js';
|
||||
import { listStoredMessages, putStoredMessage } from './services/message-store.js';
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const SESSION_STORAGE_KEY = 'shine-ui-current-session-v1';
|
||||
@@ -130,6 +131,8 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
contacts: [],
|
||||
appLog: [],
|
||||
incomingDedup: {},
|
||||
knownMessageKeys: {},
|
||||
outgoingTempSeq: 1,
|
||||
notificationsTab: 'replies',
|
||||
pageLabelCollapsed: false,
|
||||
session: {
|
||||
@@ -199,6 +202,55 @@ export const authService = new AuthService(state.entrySettings.shineServer);
|
||||
let onSessionReset = null;
|
||||
let onSessionAuthorized = null;
|
||||
|
||||
function persistMessageRecord(chatId, row) {
|
||||
if (!chatId || !row?.messageKey) return;
|
||||
void putStoredMessage({
|
||||
messageKey: row.messageKey,
|
||||
chatId,
|
||||
from: row.from || 'in',
|
||||
text: String(row.text || ''),
|
||||
baseKey: String(row.baseKey || ''),
|
||||
messageType: Number(row.messageType || 0),
|
||||
rawBlobB64: String(row.rawBlobB64 || ''),
|
||||
unread: Boolean(row.unread),
|
||||
firstTick: Boolean(row.firstTick),
|
||||
secondTick: Boolean(row.secondTick),
|
||||
readReceiptSent: Boolean(row.readReceiptSent),
|
||||
refBaseKey: String(row.refBaseKey || ''),
|
||||
ts: Date.now(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
export async function hydrateMessagesFromStore() {
|
||||
try {
|
||||
const rows = await listStoredMessages();
|
||||
rows
|
||||
.sort((a, b) => Number(a?.ts || 0) - Number(b?.ts || 0))
|
||||
.forEach((row) => {
|
||||
const chatId = String(row?.chatId || '').trim();
|
||||
const messageKey = String(row?.messageKey || '').trim();
|
||||
if (!chatId || !messageKey) return;
|
||||
if (state.knownMessageKeys[messageKey]) return;
|
||||
state.knownMessageKeys[messageKey] = true;
|
||||
getChatMessages(chatId).push({
|
||||
from: row.from === 'out' ? 'out' : 'in',
|
||||
text: String(row.text || ''),
|
||||
messageKey,
|
||||
baseKey: String(row.baseKey || ''),
|
||||
messageType: Number(row.messageType || 0),
|
||||
rawBlobB64: String(row.rawBlobB64 || ''),
|
||||
unread: Boolean(row.unread),
|
||||
firstTick: Boolean(row.firstTick),
|
||||
secondTick: Boolean(row.secondTick),
|
||||
readReceiptSent: Boolean(row.readReceiptSent),
|
||||
refBaseKey: String(row.refBaseKey || ''),
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// ignore broken storage
|
||||
}
|
||||
}
|
||||
|
||||
export function getChatMessages(chatId) {
|
||||
if (!state.chats[chatId]) {
|
||||
state.chats[chatId] = [];
|
||||
@@ -209,7 +261,7 @@ export function getChatMessages(chatId) {
|
||||
export function addChatMessage(chatId, text) {
|
||||
const message = text.trim();
|
||||
if (!message) return;
|
||||
getChatMessages(chatId).push({ from: 'out', text: message });
|
||||
getChatMessages(chatId).push({ from: 'out', text: message, firstTick: false, secondTick: false, unread: false });
|
||||
}
|
||||
|
||||
|
||||
@@ -218,10 +270,100 @@ export function addIncomingMessage(chatId, text, messageId = '') {
|
||||
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 });
|
||||
getChatMessages(chatId).push({ from: 'in', text: msg, messageId, unread: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function addOutgoingPendingMessage(chatId, text) {
|
||||
const msg = String(text || '').trim();
|
||||
if (!msg) return null;
|
||||
const tempId = `tmp-${Date.now()}-${state.outgoingTempSeq++}`;
|
||||
getChatMessages(chatId).push({
|
||||
from: 'out',
|
||||
text: msg,
|
||||
tempId,
|
||||
firstTick: false,
|
||||
secondTick: false,
|
||||
unread: false,
|
||||
});
|
||||
return tempId;
|
||||
}
|
||||
|
||||
export function markOutgoingSent(tempId, { messageKey = '', baseKey = '' } = {}) {
|
||||
if (!tempId) return;
|
||||
const keys = Object.keys(state.chats || {});
|
||||
keys.forEach((chatId) => {
|
||||
const list = getChatMessages(chatId);
|
||||
const row = list.find((item) => item?.tempId === tempId);
|
||||
if (!row) return;
|
||||
row.firstTick = true;
|
||||
row.messageKey = messageKey || row.messageKey || '';
|
||||
row.baseKey = baseKey || row.baseKey || '';
|
||||
if (messageKey) {
|
||||
state.knownMessageKeys[messageKey] = true;
|
||||
persistMessageRecord(chatId, row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function markOutgoingReadByBaseKey(baseKey) {
|
||||
if (!baseKey) return;
|
||||
const keys = Object.keys(state.chats || {});
|
||||
keys.forEach((chatId) => {
|
||||
const list = getChatMessages(chatId);
|
||||
list.forEach((row) => {
|
||||
if (row?.from !== 'out') return;
|
||||
if (row.baseKey === baseKey) {
|
||||
row.secondTick = true;
|
||||
persistMessageRecord(chatId, row);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function addSignedMessageToChat({
|
||||
chatId,
|
||||
messageKey,
|
||||
baseKey = '',
|
||||
from = 'in',
|
||||
text = '',
|
||||
messageType = 1,
|
||||
unread = false,
|
||||
rawBlobB64 = '',
|
||||
refBaseKey = '',
|
||||
} = {}) {
|
||||
const id = String(messageKey || '').trim();
|
||||
if (!chatId || !id) return false;
|
||||
if (state.knownMessageKeys[id]) return false;
|
||||
state.knownMessageKeys[id] = true;
|
||||
|
||||
const row = {
|
||||
from: from === 'out' ? 'out' : 'in',
|
||||
text: String(text || ''),
|
||||
messageKey: id,
|
||||
baseKey: String(baseKey || ''),
|
||||
messageType: Number(messageType || 0),
|
||||
rawBlobB64: String(rawBlobB64 || ''),
|
||||
unread: Boolean(unread),
|
||||
refBaseKey: String(refBaseKey || ''),
|
||||
firstTick: from === 'out',
|
||||
secondTick: false,
|
||||
};
|
||||
getChatMessages(chatId).push(row);
|
||||
persistMessageRecord(chatId, row);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function markChatRead(chatId) {
|
||||
const list = getChatMessages(chatId);
|
||||
list.forEach((row) => {
|
||||
if (row?.from === 'in') {
|
||||
row.unread = false;
|
||||
persistMessageRecord(chatId, row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setContacts(list) {
|
||||
state.contacts = Array.isArray(list) ? [...list] : [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user