montana/Russian/Site/messenger-app/app.js

854 lines
36 KiB
JavaScript
Raw Normal View History

2026-05-18 18:05:32 +03:00
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();