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 = '
Boot error: ' + e.message + '
'; }); // Глобальный 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();