854 lines
36 KiB
JavaScript
854 lines
36 KiB
JavaScript
|
|
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();
|