montana/Russian/Site/messenger-app/app.js
2026-05-18 18:05:32 +03:00

854 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from 'https://esm.sh/@scure/bip39@2.2.0';
import { wordlist } from 'https://esm.sh/@scure/bip39@2.2.0/wordlists/english';
import { ed25519, x25519 } from 'https://esm.sh/@noble/curves@2.2.0/ed25519';
import { hkdf } from 'https://esm.sh/@noble/hashes@2.2.0/hkdf';
import { sha256 } from 'https://esm.sh/@noble/hashes@2.2.0/sha2';
import { gcm } from 'https://esm.sh/@noble/ciphers@2.2.0/aes';
import { bytesToHex, hexToBytes, randomBytes } from 'https://esm.sh/@noble/hashes@2.2.0/utils';
const API = '/messenger/api';
const SESSION_KEY = 'montana-session-v1';
const MNEMONIC_WORDS = 24;
const POLL_MS = 3000;
const $ = (sel, root = document) => root.querySelector(sel);
const el = (tag, attrs = {}, ...children) => {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k === 'onClick') node.addEventListener('click', v);
else if (k === 'onInput') node.addEventListener('input', v);
else if (k === 'onKeydown') node.addEventListener('keydown', v);
else if (k === 'onChange') node.addEventListener('change', v);
else if (k.startsWith('on')) node.addEventListener(k.slice(2).toLowerCase(), v);
else if (v !== undefined && v !== null) node.setAttribute(k, v);
}
for (const c of children.flat()) {
if (c == null) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
return node;
};
// ─── Identity (deterministic from mnemonic) ───
function deriveIdentity(mnemonic) {
const seed = mnemonicToSeedSync(mnemonic.trim().toLowerCase(), '');
const signingPriv = hkdf(sha256, seed, undefined, new TextEncoder().encode('montana-ed25519-v1'), 32);
const signingPub = ed25519.getPublicKey(signingPriv);
const encPriv = hkdf(sha256, seed, undefined, new TextEncoder().encode('montana-x25519-v1'), 32);
const encPub = x25519.getPublicKey(encPriv);
return {
mnemonic,
accountId: bytesToHex(signingPub),
encryptionPubkey: bytesToHex(encPub),
signingPriv,
encPriv,
};
}
// ─── E2E ───
function sharedKey(myEncPriv, theirEncPubHex) {
const secret = x25519.getSharedSecret(myEncPriv, hexToBytes(theirEncPubHex));
return hkdf(sha256, secret, undefined, new TextEncoder().encode('montana-e2e-aes256-v1'), 32);
}
function encryptFor(identity, theirEncPubHex, plaintext) {
const key = sharedKey(identity.encPriv, theirEncPubHex);
const nonce = randomBytes(12);
const ct = gcm(key, nonce).encrypt(new TextEncoder().encode(plaintext));
return { ciphertext: bytesToHex(ct), nonce: bytesToHex(nonce) };
}
function decryptFrom(identity, theirEncPubHex, ciphertextHex, nonceHex) {
try {
const key = sharedKey(identity.encPriv, theirEncPubHex);
const pt = gcm(key, hexToBytes(nonceHex)).decrypt(hexToBytes(ciphertextHex));
return new TextDecoder().decode(pt);
} catch {
return null;
}
}
// ─── Session ───
function loadSession() {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null;
const s = JSON.parse(raw);
if (s.expiresAt && s.expiresAt < Date.now()) {
localStorage.removeItem(SESSION_KEY);
return null;
}
return s;
} catch { return null; }
}
function saveSession(s) { localStorage.setItem(SESSION_KEY, JSON.stringify(s)); }
function clearSession() { localStorage.removeItem(SESSION_KEY); }
// ─── API ───
async function api(method, path, body, token) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (token) opts.headers.Authorization = 'Bearer ' + token;
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(API + path, opts);
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || ('http ' + r.status));
return j;
}
async function authenticate(identity) {
const ch = await api('POST', '/auth/challenge', { accountId: identity.accountId });
const sig = ed25519.sign(hexToBytes(ch.challenge), identity.signingPriv);
return api('POST', '/auth/verify', {
accountId: identity.accountId,
challengeId: ch.challengeId,
signature: bytesToHex(sig),
encryptionPubkey: identity.encryptionPubkey,
});
}
// ─── State ───
const state = {
identity: null,
session: null,
contacts: [],
activeContact: null,
contactPubkeys: {}, // accountId → encryptionPubkey
messages: {}, // accountId → [msg]
lastInboxTs: 0,
pollTimer: null,
};
// Module-scope context для функций, которым нужны inner-refs из renderMain
const mctx = { refreshSidebar: null, openChat: null, saveContact: null };
// ─── Render entry ───
function render() {
const app = $('#app');
app.innerHTML = '';
if (state.session && state.identity) app.appendChild(renderMain());
else app.appendChild(renderAuth());
}
// ─── AUTH SCREEN ───
function renderAuth() {
let mode = 'enter';
let mnemonic = '';
let generated = '';
let isWritten = false;
let copied = false;
let err = '';
let isHidden = false;
const root = el('div', { class: 'auth' });
const onBlur = () => { isHidden = true; refresh(); };
const onFocus = () => { isHidden = false; refresh(); };
const onVis = () => { isHidden = document.visibilityState !== 'visible'; refresh(); };
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVis);
function refresh() {
root.innerHTML = '';
if (mode === 'create') renderCreate();
else renderEnter();
}
async function submitEnter() {
const m = mnemonic.trim().toLowerCase().replace(/\s+/g, ' ');
if (m.split(' ').length !== MNEMONIC_WORDS) { err = `Нужно ${MNEMONIC_WORDS} слов. Сейчас: ${m.split(' ').length}.`; refresh(); return; }
if (!validateMnemonic(m, wordlist)) { err = 'Некорректная мнемоника BIP39.'; refresh(); return; }
await signIn(m);
}
async function submitCreate() {
if (!isWritten) { err = 'Подтвердите что записали слова.'; refresh(); return; }
await signIn(generated);
}
async function signIn(mn) {
err = 'Подключаемся…'; refresh();
try {
const identity = deriveIdentity(mn);
const v = await authenticate(identity);
saveSession({
accountId: identity.accountId,
mnemonic: mn,
sessionToken: v.sessionToken,
expiresAt: v.expiresAt,
});
state.identity = identity;
state.session = v;
// Saved Messages (себе)
const myId = identity.accountId;
const stored = JSON.parse(localStorage.getItem('montana-contacts-v1') || '{}');
if (!stored[myId]) {
stored[myId] = { accountId: myId, label: 'Избранное', addedAt: Date.now() };
localStorage.setItem('montana-contacts-v1', JSON.stringify(stored));
}
if (!state.contacts.find((c) => c.accountId === myId)) {
state.contacts.unshift({ accountId: myId, lastMsgAt: Date.now() });
}
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVis);
// Permission на уведомления — спрашиваем сразу после входа
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission().catch(() => {});
}
render();
startPolling();
} catch (e) {
err = 'Ошибка: ' + e.message;
refresh();
}
}
function renderEnter() {
root.append(
el('h1', {}, 'Монтана'),
el('p', { class: 'sub' }, 'Введите 24 слова от вашего Montana ID'),
el('textarea', {
placeholder: 'слово1 слово2 слово3 … слово24',
rows: 5, autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false',
onInput: (e) => { mnemonic = e.target.value; },
}, mnemonic),
el('div', { class: 'row' },
el('button', { class: 'btn small', onClick: async () => {
try { mnemonic = await navigator.clipboard.readText(); refresh(); }
catch { err = 'Не получилось вставить. Используйте Cmd/Ctrl+V в поле.'; refresh(); }
} }, 'Вставить')
),
err ? el('div', { class: 'error' }, err) : null,
el('button', { class: 'btn', onClick: submitEnter }, 'Войти'),
el('button', { class: 'btn ghost', onClick: () => {
generated = generateMnemonic(wordlist, 256); mode = 'create'; err = ''; copied = false; isWritten = false; refresh();
} }, 'Создать новый Montana ID'),
);
}
function renderCreate() {
const words = generated.split(' ');
const grid = el('div', { class: 'wordGrid' + (isHidden ? ' hidden' : '') },
words.map((w, i) => el('div', { class: 'word' },
el('span', { class: 'num' }, String(i + 1)),
el('span', { class: 'w' }, w),
)),
);
const copyBtn = el('button', { class: 'btn small' + (copied ? ' copied' : ''), onClick: async () => {
try { await navigator.clipboard.writeText(generated); copied = true; refresh(); setTimeout(() => { copied = false; refresh(); }, 2500); }
catch { err = 'Не удалось скопировать.'; refresh(); }
} }, copied ? 'Скопировано' : 'Скопировать все 24 слова');
root.append(
el('h1', {}, 'Ваш Montana ID'),
el('p', { class: 'sub' }, 'Запишите 24 слова — это единственный ключ.'),
el('p', { class: 'warn' }, 'Никому не показывайте. Не делайте скриншот.'),
grid,
el('div', { class: 'row' }, copyBtn),
el('label', { class: 'checkbox' },
el('input', { type: 'checkbox', onChange: (e) => { isWritten = e.target.checked; refresh(); }, ...(isWritten ? { checked: '' } : {}) }),
el('span', {}, 'Я записал слова в безопасное место'),
),
err ? el('div', { class: 'error' }, err) : null,
el('button', { class: 'btn', onClick: submitCreate, ...(isWritten ? {} : { disabled: 'true' }) }, 'Войти'),
el('button', { class: 'btn ghost', onClick: () => { mode = 'enter'; err = ''; refresh(); } }, 'Назад'),
);
}
refresh();
return root;
}
function getContactLabel(accountId) {
try {
const stored = JSON.parse(localStorage.getItem('montana-contacts-v1') || '{}');
const c = stored[accountId];
if (c && c.label) return c.label;
} catch {}
if (state.identity && accountId === state.identity.accountId) return 'Избранное';
return accountId.slice(0, 8) + '…' + accountId.slice(-4);
}
function flashCopied() {
const tip = document.createElement('div');
tip.textContent = 'Скопировано';
tip.style.cssText = 'position:fixed;top:1rem;left:50%;transform:translateX(-50%);background:var(--gold);color:var(--bg);padding:0.5rem 1rem;border-radius:0.5rem;font-size:0.875rem;z-index:200;animation:fadeOut 1.8s forwards';
document.body.appendChild(tip);
setTimeout(() => tip.remove(), 1800);
}
async function shareMe() {
const myId = state.identity.accountId;
const url = location.origin + '/messenger/?invite=' + myId;
const text = 'Напишите мне в Montana Messenger: ' + url;
if (navigator.share) {
try { await navigator.share({ title: 'Montana Messenger', text, url }); return; } catch {}
}
try { await navigator.clipboard.writeText(text); flashCopied(); } catch {}
}
function openContactImport() {
const overlay = el('div', { class: 'modal-overlay', onClick: (e) => { if (e.target === overlay) overlay.remove(); } });
const modal = el('div', { class: 'modal' });
overlay.appendChild(modal);
function setView(v) { modal.innerHTML = ''; v(modal); }
function showMenu(m) {
m.append(
el('h2', {}, 'Импорт контактов'),
el('p', { class: 'modal-sub' }, 'Добавьте друзей чтобы видеть их в списке. Контакты хранятся локально.'),
el('button', { class: 'btn', onClick: () => setView(showManual) }, '✍️ Ввести вручную (Montana ID)'),
el('button', { class: 'btn ghost', onClick: () => setView(showVcard) }, '📇 Загрузить vCard (.vcf)'),
('contacts' in navigator && 'select' in navigator.contacts)
? el('button', { class: 'btn ghost', onClick: pickFromPhone }, '📱 Из адресной книги (Android Chrome)')
: null,
el('button', { class: 'btn ghost', onClick: () => overlay.remove() }, 'Закрыть'),
);
}
function showManual(m) {
let label = '', id = '', err = '';
function refresh() {
m.innerHTML = '';
m.append(
el('h2', {}, 'Добавить контакт вручную'),
el('label', { class: 'field' },
el('span', {}, 'Имя (метка)'),
el('input', { type: 'text', value: label, placeholder: 'Алик', onInput: (e) => { label = e.target.value; } }),
),
el('label', { class: 'field' },
el('span', {}, 'Montana ID (64 hex)'),
el('input', { type: 'text', value: id, placeholder: 'a1b2c3…', spellcheck: 'false', autocapitalize: 'off',
onInput: (e) => { id = e.target.value.trim().toLowerCase(); } }),
),
err ? el('div', { class: 'error' }, err) : null,
el('div', { class: 'row' },
el('button', { class: 'btn', onClick: () => {
if (!/^[0-9a-f]{64}$/.test(id)) { err = 'ID должен быть 64 hex'; refresh(); return; }
(mctx.saveContact || (()=>{}))({ accountId: id, label: label || id.slice(0,8) });
overlay.remove();
mctx.refreshSidebar && mctx.refreshSidebar();
(mctx.openChat || (()=>{}))(id);
} }, 'Добавить'),
el('button', { class: 'btn ghost', onClick: () => setView(showMenu) }, 'Назад'),
),
);
}
refresh();
}
function showVcard(m) {
let parsed = [];
let err = '';
function refresh() {
m.innerHTML = '';
m.append(
el('h2', {}, 'Импорт из vCard'),
el('p', { class: 'modal-sub' }, 'iOS: «Контакты» → выбрать контакт → «Поделиться контактом» → «Сохранить в Файлы» → .vcf. Затем загрузите файл сюда.'),
el('input', { type: 'file', accept: '.vcf,text/vcard', onChange: async (e) => {
const f = e.target.files[0]; if (!f) return;
try {
const text = await f.text();
parsed = parseVcard(text);
err = '';
} catch (x) { err = 'Не удалось прочитать файл: ' + x.message; }
refresh();
} }),
err ? el('div', { class: 'error' }, err) : null,
parsed.length === 0 ? null : el('div', { class: 'vcard-list' },
parsed.map((c) => el('div', { class: 'vcard-row' },
el('div', { class: 'vcard-info' },
el('div', { class: 'vcard-name' }, c.name || '(без имени)'),
el('div', { class: 'vcard-meta' }, (c.phones[0] || c.emails[0] || '—')),
),
el('button', { class: 'btn small', onClick: () => {
// У vCard контакта нет Montana ID — открываем форму чтобы вписать
setView((mm) => showLinkContact(mm, c));
} }, 'Привязать ID'),
)),
),
el('div', { class: 'row' },
el('button', { class: 'btn ghost', onClick: () => setView(showMenu) }, 'Назад'),
),
);
}
refresh();
}
function showLinkContact(m, vcard) {
let id = '';
let err = '';
function refresh() {
m.innerHTML = '';
m.append(
el('h2', {}, 'Привязать Montana ID'),
el('p', { class: 'modal-sub' }, 'Контакт: ' + (vcard.name || '—') + (vcard.phones[0] ? ' (' + vcard.phones[0] + ')' : '')),
el('label', { class: 'field' },
el('span', {}, 'Montana ID получателя'),
el('input', { type: 'text', value: id, placeholder: '64 hex', spellcheck: 'false', autocapitalize: 'off',
onInput: (e) => { id = e.target.value.trim().toLowerCase(); } }),
),
err ? el('div', { class: 'error' }, err) : null,
el('div', { class: 'row' },
el('button', { class: 'btn', onClick: () => {
if (!/^[0-9a-f]{64}$/.test(id)) { err = 'ID должен быть 64 hex'; refresh(); return; }
(mctx.saveContact || (()=>{}))({ accountId: id, label: vcard.name || id.slice(0,8), phone: vcard.phones[0], email: vcard.emails[0] });
overlay.remove();
mctx.refreshSidebar && mctx.refreshSidebar();
(mctx.openChat || (()=>{}))(id);
} }, 'Сохранить'),
el('button', { class: 'btn ghost', onClick: () => setView(showVcard) }, 'Назад'),
),
);
}
refresh();
}
async function pickFromPhone() {
try {
const list = await navigator.contacts.select(['name','tel','email'], { multiple: true });
if (!list || list.length === 0) { overlay.remove(); return; }
setView((m) => {
m.append(
el('h2', {}, 'Из адресной книги (' + list.length + ')'),
el('p', { class: 'modal-sub' }, 'Привяжите Montana ID к каждому контакту, чтобы видеть их в списке.'),
el('div', { class: 'vcard-list' },
list.map((c) => el('div', { class: 'vcard-row' },
el('div', { class: 'vcard-info' },
el('div', { class: 'vcard-name' }, (c.name && c.name[0]) || '(без имени)'),
el('div', { class: 'vcard-meta' }, (c.tel && c.tel[0]) || (c.email && c.email[0]) || '—'),
),
el('button', { class: 'btn small', onClick: () => {
setView((mm) => showLinkContact(mm, { name: c.name && c.name[0], phones: c.tel || [], emails: c.email || [] }));
} }, 'Привязать ID'),
)),
),
el('div', { class: 'row' },
el('button', { class: 'btn ghost', onClick: () => setView(showMenu) }, 'Назад'),
),
);
});
} catch (e) {
alert('Отменено: ' + e.message);
}
}
setView(showMenu);
document.body.appendChild(overlay);
}
// ─── MAIN SCREEN (sidebar + chat) ───
function renderMain() {
const root = el('div', { class: 'main' });
const sidebar = el('div', { class: 'sidebar' });
const chatPane = el('div', { class: 'chat-pane' });
root.append(sidebar, chatPane);
function refreshSidebar() {
sidebar.innerHTML = '';
const head = el('div', { class: 'sidebar-head' },
el('div', { class: 'brand' }, 'Монтана'),
el('div', { class: 'actions' },
el('button', { class: 'icon-btn', title: 'Импорт контактов',
onClick: pickContacts }, '👥'),
el('button', { class: 'icon-btn', title: 'Выход', onClick: signOut }, '⏏'),
),
);
const search = el('div', { class: 'search-bar' },
el('input', {
placeholder: 'account_id (64 hex)',
onKeydown: (e) => { if (e.key === 'Enter') startChatFromInput(e.target.value); },
}),
el('button', { onClick: (e) => startChatFromInput(e.target.previousElementSibling.value) }, 'Открыть'),
);
const contactsList = el('div', { class: 'contacts' });
if (state.contacts.length === 0) {
contactsList.append(
el('div', { class: 'contact-empty' }, 'Пока нет переписок. Введите account_id выше или импортируйте контакты.'),
el('div', { class: 'contact-empty' },
'Ваш ID:', el('br'),
el('code', { style: 'color:var(--gold);font-family:monospace;font-size:0.6875rem;word-break:break-all;' }, state.identity.accountId),
el('br'),
el('button', { class: 'btn small', style: 'margin-top:0.5rem;flex:initial;display:inline-block;', onClick: async () => {
try { await navigator.clipboard.writeText(state.identity.accountId); } catch {}
} }, 'Скопировать мой ID'),
),
);
} else {
for (const c of state.contacts) {
const isActive = state.activeContact === c.accountId;
contactsList.appendChild(el('div', {
class: 'contact' + (isActive ? ' active' : ''),
onClick: () => (mctx.openChat || (()=>{}))(c.accountId),
},
el('div', { class: 'avatar' }, c.accountId.slice(0, 2)),
el('div', { class: 'contact-info' },
el('div', { class: 'contact-name' }, getContactLabel(c.accountId)),
el('div', { class: 'contact-last' }, formatTime(c.lastMsgAt)),
),
));
}
}
sidebar.append(head, search, contactsList);
}
function refreshChat() {
chatPane.innerHTML = '';
if (!state.activeContact) {
const myId = state.identity.accountId;
const shortId = myId.slice(0, 16) + '…' + myId.slice(-8);
chatPane.append(el('div', { class: 'chat-empty' },
el('div', { class: 'big' }, 'Ɉ'),
el('div', { class: 'welcome-title' }, 'Montana Messenger'),
el('div', { class: 'sub' }, 'Защищённые сообщения по протоколу Montana'),
el('div', { class: 'welcome-card' },
el('div', { class: 'welcome-label' }, 'Ваш Montana ID'),
el('div', { class: 'welcome-id' }, shortId),
el('div', { class: 'welcome-actions' },
el('button', { class: 'btn small', onClick: async () => {
try { await navigator.clipboard.writeText(myId); flashCopied(); } catch {}
} }, 'Скопировать ID'),
el('button', { class: 'btn small', onClick: shareMe }, 'Поделиться'),
),
),
el('div', { class: 'welcome-actions' },
el('button', { class: 'btn', onClick: () => openContactImport() }, ' Новый чат / контакт'),
),
el('div', { class: 'welcome-hint' },
'Дайте кому-то ваш ID — и он сможет вам написать. Или нажмите «Избранное» слева, чтобы оставить заметку себе.'),
));
return;
}
const c = state.activeContact;
const head = el('div', { class: 'chat-head' },
el('div', { class: 'avatar' }, c.slice(0, 2)),
el('div', { class: 'name' },
c.slice(0, 12) + '…',
el('span', { class: 'full' }, c),
),
el('button', { class: 'icon-btn', title: 'Назад', onClick: () => { state.activeContact = null; mctx.refreshSidebar && mctx.refreshSidebar(); refreshChat(); } }, '←'),
);
const msgs = el('div', { class: 'messages' });
const list = state.messages[c] || [];
for (const m of list) {
const isOut = m.sender === state.identity.accountId;
const cls = m.error ? 'msg error' : ('msg ' + (isOut ? 'out' : 'in'));
msgs.append(el('div', { class: cls },
el('div', {}, m.text),
el('div', { class: 'ts' }, formatTime(m.createdAt)),
));
}
const composer = el('div', { class: 'composer' });
const ta = el('textarea', {
placeholder: 'Сообщение',
rows: 1,
onInput: (e) => { e.target.style.height='auto'; e.target.style.height=Math.min(e.target.scrollHeight, 128)+'px'; },
onKeydown: (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } },
});
const sendBtn = el('button', { onClick: doSend }, '↑');
async function doSend() {
const text = ta.value.trim();
if (!text) return;
ta.value = ''; ta.style.height='auto';
try {
await sendMessage(c, text);
} catch (e) {
appendMessage(c, { sender: state.identity.accountId, text: 'Ошибка отправки: ' + e.message, createdAt: Date.now(), error: true });
refreshChat();
}
}
composer.append(ta, sendBtn);
chatPane.append(head, msgs, composer);
setTimeout(() => { msgs.scrollTop = msgs.scrollHeight; }, 0);
}
async function openChat(accountId) {
state.activeContact = accountId;
if (!state.messages[accountId]) state.messages[accountId] = [];
// Подтянем encryption pubkey партнёра
if (!state.contactPubkeys[accountId]) {
try {
const info = await api('GET', '/account/' + accountId, undefined, state.session.sessionToken);
if (info && info.encryptionPubkey) state.contactPubkeys[accountId] = info.encryptionPubkey;
} catch {}
}
mctx.refreshSidebar && mctx.refreshSidebar();
refreshChat();
}
function startChatFromInput(value) {
const id = (value || '').trim().toLowerCase();
if (!/^[0-9a-f]{64}$/.test(id)) {
alert('account_id должен быть 64 hex символа');
return;
}
(mctx.openChat || (()=>{}))(id);
}
async function sendMessage(recipientId, plaintext) {
let encPub = state.contactPubkeys[recipientId];
if (!encPub) {
const info = await api('GET', '/account/' + recipientId, undefined, state.session.sessionToken).catch(() => null);
if (info && info.encryptionPubkey) { encPub = info.encryptionPubkey; state.contactPubkeys[recipientId] = encPub; }
}
let payload;
if (encPub) {
payload = encryptFor(state.identity, encPub, plaintext);
} else {
// партнёр ещё не зарегистрирован — отправим plaintext в hex, расшифруется когда они войдут
const ct = bytesToHex(new TextEncoder().encode(plaintext));
payload = { ciphertext: ct, nonce: '00'.repeat(12) };
}
const r = await api('POST', '/msg/send', { recipient: recipientId, ciphertext: payload.ciphertext, nonce: payload.nonce }, state.session.sessionToken);
appendMessage(recipientId, { sender: state.identity.accountId, text: plaintext, createdAt: r.createdAt });
if (!state.contacts.find((c) => c.accountId === recipientId)) {
state.contacts.unshift({ accountId: recipientId, lastMsgAt: r.createdAt });
}
mctx.refreshSidebar && mctx.refreshSidebar();
refreshChat();
}
function appendMessage(peerId, m) {
if (!state.messages[peerId]) state.messages[peerId] = [];
state.messages[peerId].push(m);
}
async function pollInbox() {
try {
const r = await api('GET', '/msg/inbox?since=' + state.lastInboxTs, undefined, state.session.sessionToken);
let changed = false;
for (const m of r.messages || []) {
if (m.createdAt > state.lastInboxTs) state.lastInboxTs = m.createdAt;
const peer = m.sender === state.identity.accountId ? m.recipient : m.sender;
// если уже видели — пропустим
const list = state.messages[peer] = state.messages[peer] || [];
if (list.find((x) => x.msgId === m.msgId)) continue;
let text;
if (m.nonce === '00'.repeat(12)) {
try { text = new TextDecoder().decode(hexToBytes(m.ciphertext)); }
catch { text = '[invalid plaintext]'; }
} else {
let encPub = state.contactPubkeys[peer];
if (!encPub) {
try {
const info = await api('GET', '/account/' + peer, undefined, state.session.sessionToken);
if (info && info.encryptionPubkey) { encPub = info.encryptionPubkey; state.contactPubkeys[peer] = encPub; }
} catch {}
}
if (encPub) text = decryptFrom(state.identity, encPub, m.ciphertext, m.nonce) || '[не удалось расшифровать]';
else text = '[ключ собеседника недоступен]';
}
list.push({ msgId: m.msgId, sender: m.sender, text, createdAt: m.createdAt });
if (!state.contacts.find((c) => c.accountId === peer)) {
state.contacts.unshift({ accountId: peer, lastMsgAt: m.createdAt });
} else {
for (const c of state.contacts) if (c.accountId === peer) c.lastMsgAt = m.createdAt;
}
changed = true;
// уведомление если это входящее и окно не в фокусе
if (m.sender !== state.identity.accountId && document.visibilityState !== 'visible' && 'Notification' in window && Notification.permission === 'granted') {
try { new Notification('Montana', { body: text.slice(0, 80), icon: '/messenger/favicon.svg', tag: m.msgId }); } catch {}
}
}
if (changed) { mctx.refreshSidebar && mctx.refreshSidebar(); if (state.activeContact) refreshChat(); }
} catch (e) {
console.warn('poll error:', e.message);
}
}
async function refreshContacts() {
try {
const r = await api('GET', '/msg/contacts', undefined, state.session.sessionToken);
const known = new Set(state.contacts.map((c) => c.accountId));
for (const c of r.contacts || []) {
if (!known.has(c.accountId)) state.contacts.push(c);
}
state.contacts.sort((a, b) => (b.lastMsgAt || 0) - (a.lastMsgAt || 0));
mctx.refreshSidebar && mctx.refreshSidebar();
} catch {}
}
function pickContacts() {
openContactImport();
}
function parseVcard(text) {
// Простой парсер vCard 3.0/4.0
const blocks = text.split(/BEGIN:VCARD/i).slice(1);
const out = [];
for (const b of blocks) {
const lines = b.split(/\r?\n/);
let name = '', phones = [], emails = [];
for (const ln of lines) {
if (/^FN[:;]/i.test(ln)) name = ln.split(':').slice(1).join(':').trim();
else if (/^N[:;]/i.test(ln) && !name) {
const parts = ln.split(':').slice(1).join(':').trim().split(';');
name = (parts[1] || '') + ' ' + (parts[0] || '');
name = name.trim();
}
else if (/^TEL[:;]/i.test(ln)) phones.push(ln.split(':').slice(1).join(':').trim());
else if (/^EMAIL[:;]/i.test(ln)) emails.push(ln.split(':').slice(1).join(':').trim());
}
if (name || phones.length || emails.length) out.push({ name, phones, emails });
}
return out;
}
function saveContact(c) {
const stored = JSON.parse(localStorage.getItem('montana-contacts-v1') || '{}');
stored[c.accountId] = { ...stored[c.accountId], ...c };
localStorage.setItem('montana-contacts-v1', JSON.stringify(stored));
if (!state.contacts.find((x) => x.accountId === c.accountId)) {
state.contacts.unshift({ accountId: c.accountId, lastMsgAt: Date.now() });
}
}
function signOut() {
if (!confirm('Выйти и очистить сессию?')) return;
if (state.pollTimer) clearInterval(state.pollTimer);
clearSession();
state.identity = null; state.session = null; state.contacts = []; state.messages = {}; state.activeContact = null;
render();
}
mctx.refreshSidebar = refreshSidebar;
mctx.openChat = openChat;
mctx.saveContact = saveContact;
refreshSidebar();
refreshChat();
refreshContacts();
return root;
}
function startPolling() {
if (state.pollTimer) clearInterval(state.pollTimer);
state.pollTimer = setInterval(() => {
// pollInbox + refreshContacts оборачиваются внутри MainScreen;
// здесь только вызываем глобально через события
document.dispatchEvent(new Event('montana-poll'));
}, POLL_MS);
}
function formatTime(ts) {
if (!ts) return '';
const d = new Date(Number(ts));
const today = new Date();
if (d.toDateString() === today.toDateString()) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit' }) + ' ' +
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ─── Bootstrap ───
function loadStoredContacts() {
try {
const stored = JSON.parse(localStorage.getItem('montana-contacts-v1') || '{}');
for (const accountId of Object.keys(stored)) {
if (!state.contacts.find((c) => c.accountId === accountId)) {
state.contacts.push({ accountId, lastMsgAt: stored[accountId].addedAt || 0 });
}
}
} catch {}
}
function getInviteId() {
try {
const u = new URL(location.href);
const id = u.searchParams.get('invite');
if (id && /^[0-9a-f]{64}$/.test(id.toLowerCase())) return id.toLowerCase();
} catch {}
return null;
}
async function boot() {
const s = loadSession();
loadStoredContacts();
// Авто-Saved Messages
if (state.identity && !state.contacts.find((c) => c.accountId === state.identity.accountId)) {
state.contacts.unshift({ accountId: state.identity.accountId, lastMsgAt: Date.now() });
}
if (s && s.mnemonic) {
try {
state.identity = deriveIdentity(s.mnemonic);
state.session = { sessionToken: s.sessionToken, expiresAt: s.expiresAt, accountId: s.accountId };
// не перепроверяем токен — relay сам ответит 401 если истёк
} catch {
clearSession();
}
}
const invite = getInviteId();
if (invite && state.identity) {
if (!state.contacts.find((c) => c.accountId === invite)) {
state.contacts.unshift({ accountId: invite, lastMsgAt: Date.now() });
}
state.activeContact = invite;
}
render();
}
boot().catch((e) => {
document.body.innerHTML = '<div style="padding:2rem;color:#e64545;font-family:monospace;">Boot error: ' + e.message + '</div>';
});
// Глобальный poll-listener (после render Main подвязывается)
let pollInProgress = false;
document.addEventListener('montana-poll', async () => {
if (pollInProgress) return;
if (!state.session) return;
pollInProgress = true;
try {
// дёргаем endpoint напрямую и эмитим события
const r = await fetch(API + '/msg/inbox?since=' + state.lastInboxTs, {
headers: { Authorization: 'Bearer ' + state.session.sessionToken },
}).then((x) => x.json()).catch(() => null);
if (!r || !r.messages) return;
let changed = false;
for (const m of r.messages) {
if (m.createdAt > state.lastInboxTs) state.lastInboxTs = m.createdAt;
const peer = m.sender === state.identity.accountId ? m.recipient : m.sender;
const list = state.messages[peer] = state.messages[peer] || [];
if (list.find((x) => x.msgId === m.msgId)) continue;
let text;
if (m.nonce === '00'.repeat(12)) {
try { text = new TextDecoder().decode(hexToBytes(m.ciphertext)); }
catch { text = '[plaintext error]'; }
} else {
let encPub = state.contactPubkeys[peer];
if (!encPub) {
try {
const info = await fetch(API + '/account/' + peer, { headers: { Authorization: 'Bearer ' + state.session.sessionToken } }).then((x) => x.json());
if (info && info.encryptionPubkey) { encPub = info.encryptionPubkey; state.contactPubkeys[peer] = encPub; }
} catch {}
}
if (encPub) text = decryptFrom(state.identity, encPub, m.ciphertext, m.nonce) || '[decrypt failed]';
else text = '[peer pubkey unknown]';
}
list.push({ msgId: m.msgId, sender: m.sender, text, createdAt: m.createdAt });
if (!state.contacts.find((c) => c.accountId === peer)) state.contacts.unshift({ accountId: peer, lastMsgAt: m.createdAt });
else for (const c of state.contacts) if (c.accountId === peer) c.lastMsgAt = m.createdAt;
changed = true;
if (m.sender !== state.identity.accountId && document.visibilityState !== 'visible' && 'Notification' in window && Notification.permission === 'granted') {
try { new Notification('Montana', { body: text.slice(0, 80), icon: '/messenger/favicon.svg', tag: m.msgId }); } catch {}
}
}
if (changed) render();
} finally {
pollInProgress = false;
}
});
if (state.session && state.identity) startPolling();