для одного типа форматирования. */
+function fmtSpan(t, v) {
+ const span = document.createElement("span");
+ if (t === "size") { span.className = "f-size"; span.dataset.size = v; }
+ else if (t === "color") { span.className = "f-color"; span.dataset.color = v; span.style.color = v; }
+ else span.className = FMT_CLASS[t];
+ return span;
+}
+
+const EMPTY_STYLE = () => ({ b: 0, i: 0, u: 0, s: 0, code: 0, quote: 0, size: null, color: null });
+
+/** Эффективный стиль символа i (для size/color побеждает последняя пометка в списке). */
+function styleAt(marks, i) {
+ const st = EMPTY_STYLE();
+ for (const m of marks) {
+ if (i < m.s || i >= m.e) continue;
+ if (m.t === "size") st.size = m.v;
+ else if (m.t === "color") st.color = m.v;
+ else st[m.t] = 1;
+ }
+ return st;
+}
+
+function sameStyle(a, b) {
+ return a.b === b.b && a.i === b.i && a.u === b.u && a.s === b.s && a.code === b.code
+ && a.quote === b.quote && a.size === b.size && a.color === b.color;
+}
+
+/** Оборачивает строку в нужные span'ы (текст — через textNode, без XSS). */
+function styledNode(text, st) {
+ let node = document.createTextNode(text);
+ const wrap = (t, v) => { const sp = fmtSpan(t, v); sp.appendChild(node); node = sp; };
+ if (st.code) wrap("code");
+ if (st.quote) wrap("quote");
+ if (st.b) wrap("b");
+ if (st.i) wrap("i");
+ if (st.u) wrap("u");
+ if (st.s) wrap("s");
+ if (st.size && st.size !== "normal") wrap("size", st.size);
+ if (st.color) wrap("color", st.color);
+ return node;
+}
+
+/** Отрисовывает текст с форматированием в контейнер. Без пометок — обычный
+ * путь с кликабельными ссылками и @упоминаниями. */
+function renderRich(container, text, marks) {
+ if (!marks || !marks.length) return renderMentions(container, text);
+ let i = 0;
+ while (i < text.length) {
+ const st = styleAt(marks, i);
+ let j = i + 1;
+ while (j < text.length && sameStyle(styleAt(marks, j), st)) j++;
+ container.appendChild(styledNode(text.slice(i, j), st));
+ i = j;
+ }
+}
+
+/* --- Композер: contenteditable #msg-input --- */
+
+const composer = () => $("#msg-input");
+
+/** Стиль текстового узла по цепочке предков-обёрток до корня композера. */
+function nodeStyle(node, root) {
+ const st = EMPTY_STYLE();
+ let el = node.parentElement;
+ while (el && el !== root) {
+ const c = el.classList;
+ if (c) {
+ if (c.contains("f-b")) st.b = 1;
+ if (c.contains("f-i")) st.i = 1;
+ if (c.contains("f-u")) st.u = 1;
+ if (c.contains("f-s")) st.s = 1;
+ if (c.contains("f-code")) st.code = 1;
+ if (c.contains("f-quote")) st.quote = 1;
+ if (st.size === null && el.dataset.size) st.size = el.dataset.size; // innermost-wins
+ if (st.color === null && el.dataset.color) st.color = el.dataset.color;
+ }
+ el = el.parentElement;
+ }
+ return st;
+}
+
+/** Сырая сериализация композера в {text, marks} БЕЗ обрезки краёв.
+ * Индексы символов совпадают с boundaryAtChar (BR = 1 символ). */
+function rawSerialize() {
+ const root = composer();
+ let text = "";
+ const active = {}; // type -> {s, v}
+ const marks = [];
+ const close = (t, cur) => {
+ const mk = { s: cur.s, e: text.length, t };
+ if (cur.v !== true) mk.v = cur.v;
+ marks.push(mk);
+ };
+ const applyStyle = st => {
+ for (const t of FMT_TYPES) {
+ let dv;
+ if (t === "size") dv = (st.size && st.size !== "normal") ? st.size : null;
+ else if (t === "color") dv = st.color || null;
+ else dv = st[t] ? true : null;
+ const cur = active[t];
+ if (dv) {
+ if (!cur || cur.v !== dv) { if (cur) close(t, cur); active[t] = { s: text.length, v: dv }; }
+ } else if (cur) { close(t, cur); delete active[t]; }
+ }
+ };
+ const walk = node => {
+ for (const child of node.childNodes) {
+ if (child.nodeType === 3) { applyStyle(nodeStyle(child, root)); text += child.nodeValue; }
+ else if (child.nodeType === 1) {
+ if (child.tagName === "BR") { applyStyle(EMPTY_STYLE()); text += "\n"; }
+ else walk(child);
+ }
+ }
+ };
+ walk(root);
+ for (const t of FMT_TYPES) if (active[t]) close(t, active[t]);
+ text = text.replace(/ /g, " "); // -> normal space (length unchanged)
+ return { text, marks };
+}
+
+/** Сериализует содержимое композера в {text, marks} для отправки (с обрезкой краёв). */
+function serializeComposer() {
+ const { text: raw, marks } = rawSerialize();
+ // сервер делает strip(); обрезаем края и сдвигаем пометки, чтобы смещения совпали
+ const lead = raw.match(/^\s+/);
+ const shift = lead ? lead[0].length : 0;
+ const text = raw.trim();
+ const shifted = marks.map(m => ({ ...m, s: m.s - shift, e: m.e - shift }));
+ return { text, marks: clampMarks(shifted, text.length) };
+}
+
+/** Отбрасывает/подрезает пометки по границам текста. */
+function clampMarks(marks, len) {
+ const out = [];
+ for (const m of marks) {
+ const s = Math.max(0, m.s), e = Math.min(m.e, len);
+ if (s < e) out.push({ ...m, s, e });
+ }
+ return out;
+}
+
+/** Длина содержимого узла в «символах» (текст + BR как 1), как в rawSerialize. */
+function measureLen(node) {
+ let n = 0;
+ for (const c of node.childNodes) {
+ if (c.nodeType === 3) n += c.nodeValue.length;
+ else if (c.nodeType === 1) n += (c.tagName === "BR") ? 1 : measureLen(c);
+ }
+ return n;
+}
+
+/** Индекс символа для границы выделения (container, offset) в координатах rawSerialize. */
+function charIndexOf(root, container, offset) {
+ const r = document.createRange();
+ r.setStart(root, 0);
+ r.setEnd(container, offset);
+ return measureLen(r.cloneContents());
+}
+
+/** Находит позицию (node, offset) по индексу символа — для восстановления выделения. */
+function boundaryAtChar(root, target) {
+ let idx = 0, found = null;
+ (function walk(node) {
+ for (const child of node.childNodes) {
+ if (found) return;
+ if (child.nodeType === 3) {
+ const len = child.nodeValue.length;
+ if (target <= idx + len) { found = { node: child, offset: target - idx }; return; }
+ idx += len;
+ } else if (child.nodeType === 1) {
+ if (child.tagName === "BR") {
+ if (target <= idx) { const p = child.parentNode;
+ found = { node: p, offset: [...p.childNodes].indexOf(child) }; return; }
+ idx += 1;
+ } else walk(child);
+ }
+ }
+ })(root);
+ return found || { node: root, offset: root.childNodes.length };
+}
+
+/** Есть ли формат t у каждого символа диапазона [s,e). */
+function rangeHasFormat(marks, s, e, t) {
+ if (e <= s) return false;
+ for (let i = s; i < e; i++) {
+ const st = styleAt(marks, i);
+ const has = t === "size" ? (st.size && st.size !== "normal")
+ : t === "color" ? !!st.color : !!st[t];
+ if (!has) return false;
+ }
+ return true;
+}
+
+/** Убирает формат t из диапазона [s,e), разрезая пометки по краям. */
+function removeFormat(marks, s, e, t) {
+ const out = [];
+ for (const m of marks) {
+ if (m.t !== t) { out.push(m); continue; }
+ if (m.s < s) out.push({ ...m, e: Math.min(m.e, s) }); // кусок слева
+ if (m.e > e) out.push({ ...m, s: Math.max(m.s, e) }); // кусок справа
+ }
+ return out.filter(m => m.s < m.e);
+}
+
+/** Переключает/задаёт формат на текущем выделении (с перерисовкой композера). */
+function applyFormat(t, v) {
+ editFormat((marks, s, e) => {
+ if (t === "size" || t === "color") {
+ const out = removeFormat(marks, s, e, t);
+ const clear = (t === "size" && (!v || v === "normal")) || (t === "color" && !v);
+ if (!clear) out.push({ s, e, t, v });
+ return out;
+ }
+ return rangeHasFormat(marks, s, e, t)
+ ? removeFormat(marks, s, e, t)
+ : [...marks, { s, e, t }];
+ });
+}
+
+/** Полностью снимает форматирование с выделения. */
+function clearFormatting() {
+ editFormat((marks, s, e) => {
+ let out = marks;
+ for (const t of FMT_TYPES) out = removeFormat(out, s, e, t);
+ return out;
+ });
+}
+
+/** Общий механизм: берёт выделение, меняет пометки, перерисовывает и возвращает выделение. */
+function editFormat(transform) {
+ const root = composer();
+ const sel = window.getSelection();
+ if (!sel.rangeCount) return;
+ const range = sel.getRangeAt(0);
+ if (range.collapsed || !root.contains(range.commonAncestorContainer)) return;
+ const a = charIndexOf(root, range.startContainer, range.startOffset);
+ const b = charIndexOf(root, range.endContainer, range.endOffset);
+ const s = Math.min(a, b), e = Math.max(a, b);
+ if (s >= e) return;
+ const { text, marks } = rawSerialize();
+ fillComposer(text, transform(marks, s, e));
+ // Восстанавливаем выделение по символьным индексам.
+ const bs = boundaryAtChar(root, s), be = boundaryAtChar(root, e);
+ const r = document.createRange();
+ r.setStart(bs.node, bs.offset);
+ r.setEnd(be.node, be.offset);
+ sel.removeAllRanges();
+ sel.addRange(r);
+ root.focus();
+}
+
+/** Контекстное меню форматирования (ПКМ по выделенному тексту в поле ввода). */
+function openFormatMenu(e) {
+ const root = composer();
+ const sel = window.getSelection();
+ if (!sel.rangeCount || sel.isCollapsed
+ || !root.contains(sel.getRangeAt(0).commonAncestorContainer))
+ return; // нет выделения — оставляем обычное меню браузера
+ if (me?.banned) return;
+ const saved = sel.getRangeAt(0).cloneRange();
+ const menu = contextMenu(e);
+ const restore = () => { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); };
+ const item = (label, fn) => menuItem(menu, label, false, ev => {
+ ev?.stopPropagation(); restore(); fn(); closeMsgMenu();
+ });
+
+ item("Жирный", () => applyFormat("b"));
+ item("Курсив", () => applyFormat("i"));
+ item("Подчёркнутый", () => applyFormat("u"));
+ item("Зачёркнутый", () => applyFormat("s"));
+ item("Цитата", () => applyFormat("quote"));
+ item("Моноширинный (код)", () => applyFormat("code"));
+
+ const div = document.createElement("div");
+ div.className = "msg-menu-sep";
+ div.textContent = "Размер";
+ menu.appendChild(div);
+ for (const [label, val] of [["Огромный", "huge"], ["Большой", "big"],
+ ["Обычный", "normal"], ["Маленький", "small"], ["Очень маленький", "tiny"]])
+ item(label, () => applyFormat("size", val));
+
+ const sep = document.createElement("div");
+ sep.className = "msg-menu-sep";
+ menu.appendChild(sep);
+ item("Цвет…", () => {
+ const input = $("#fmt-color");
+ input.onchange = () => { restore(); applyFormat("color", input.value); };
+ input.click();
+ });
+
+ const sep2 = document.createElement("div");
+ sep2.className = "msg-menu-sep";
+ menu.appendChild(sep2);
+ item("Убрать форматирование", () => clearFormatting());
+
+ document.body.appendChild(menu);
+ menu._place();
+}
+
+/* --- Плавающая панель форматирования над выделенным текстом в композере.
+ Появляется при выделении текста, чтобы не лезть в контекстное меню. --- */
+
+let fmtBar = null;
+
+/** Создаёт панель один раз и возвращает её (ленивая инициализация). */
+function ensureFmtBar() {
+ if (fmtBar) return fmtBar;
+ const bar = document.createElement("div");
+ bar.className = "fmt-bar";
+ bar.hidden = true;
+ // Клик по кнопке не должен снимать выделение в поле ввода.
+ bar.addEventListener("mousedown", e => e.preventDefault());
+
+ const btn = (html, title, fn) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "fmt-bar-btn";
+ b.innerHTML = html;
+ b.title = title;
+ b.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); fn(b); });
+ bar.appendChild(b);
+ return b;
+ };
+
+ // Простые переключатели — применяются к текущему выделению.
+ const apply = t => () => { applyFormat(t); positionFmtBar(); };
+ btn("Ж", "Жирный", apply("b"));
+ btn("К", "Курсив", apply("i"));
+ btn("Ч", "Подчёркнутый", apply("u"));
+ btn("З", "Зачёркнутый", apply("s"));
+ btn("«»", "Цитата", apply("quote"));
+ btn("</>", "Моноширинный (код)", apply("code"));
+ btn("A±", "Размер", anchor => openSizeMenu(anchor));
+ btn("", "Цвет", () => pickColor());
+ // Самая правая кнопка — снять всё форматирование с выделения.
+ const clearBtn = btn(
+ "",
+ "Убрать форматирование", () => { clearFormatting(); positionFmtBar(); });
+ clearBtn.classList.add("fmt-bar-clear");
+ document.body.appendChild(bar);
+ fmtBar = bar;
+ return bar;
+}
+
+/** Сохраняет выделение, открывает системный выбор цвета и применяет его. */
+function pickColor() {
+ const sel = window.getSelection();
+ const saved = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
+ const input = $("#fmt-color");
+ input.onchange = () => {
+ if (saved) { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); }
+ applyFormat("color", input.value);
+ positionFmtBar();
+ };
+ input.click();
+}
+
+/** Маленькое меню выбора размера, привязанное к кнопке панели. */
+function openSizeMenu(anchor) {
+ closeMsgMenu();
+ const sel = window.getSelection();
+ const saved = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null;
+ const menu = document.createElement("div");
+ menu.className = "msg-menu";
+ for (const [label, val] of [["Огромный", "huge"], ["Большой", "big"],
+ ["Обычный", "normal"], ["Маленький", "small"], ["Очень маленький", "tiny"]]) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "msg-menu-item";
+ b.textContent = label;
+ b.addEventListener("mousedown", e => e.preventDefault());
+ b.onclick = e => {
+ e.stopPropagation();
+ if (saved) { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); }
+ applyFormat("size", val);
+ closeMsgMenu();
+ positionFmtBar();
+ };
+ menu.appendChild(b);
+ }
+ document.body.appendChild(menu);
+ const r = anchor.getBoundingClientRect();
+ const mr = menu.getBoundingClientRect();
+ menu.style.left = Math.max(8, Math.min(r.left, innerWidth - mr.width - 8)) + "px";
+ menu.style.top = Math.min(r.bottom + 4, innerHeight - mr.height - 8) + "px";
+}
+
+/** Прячет панель форматирования. */
+function hideFmtBar() { if (fmtBar) fmtBar.hidden = true; }
+
+/** Ставит панель по центру над выделением (или под ним, если сверху не влезает). */
+function positionFmtBar(range) {
+ const bar = ensureFmtBar();
+ const sel = window.getSelection();
+ range = range || (sel.rangeCount ? sel.getRangeAt(0) : null);
+ if (!range) return;
+ const rect = range.getBoundingClientRect();
+ bar.hidden = false;
+ const br = bar.getBoundingClientRect();
+ let left = rect.left + rect.width / 2 - br.width / 2;
+ left = Math.max(8, Math.min(left, innerWidth - br.width - 8));
+ let top = rect.top - br.height - 8;
+ if (top < 8) top = rect.bottom + 8; // не помещается сверху — показываем снизу
+ bar.style.left = left + "px";
+ bar.style.top = top + "px";
+}
+
+/** Решает, показывать ли панель: есть выделение внутри композера и не забанен. */
+function updateFmtBar() {
+ const root = composer();
+ if (!root) return;
+ const sel = window.getSelection();
+ if (me?.banned || !sel.rangeCount || sel.isCollapsed) return hideFmtBar();
+ const range = sel.getRangeAt(0);
+ if (!root.contains(range.commonAncestorContainer)) return hideFmtBar();
+ positionFmtBar(range);
+}
+
+document.addEventListener("selectionchange", updateFmtBar);
+window.addEventListener("resize", () => { if (fmtBar && !fmtBar.hidden) updateFmtBar(); });
+
+/* --- Отправка сообщения из композера --- */
+
+function clearComposer() { composer().innerHTML = ""; updatePlaceholder(); hideFmtBar(); }
+
+function updatePlaceholder() {
+ const ed = composer();
+ ed.classList.toggle("is-empty", !ed.textContent.replace(/ /g, " ").trim());
+}
+
+/** Вставляет перенос строки в позицию каретки (Shift/Ctrl+Enter). */
+function insertBreak() {
+ // Нативная команда вставляет
и сама корректно двигает каретку и ставит
+ // «филлер» — ручная вставка в Chrome требовала двойного нажатия.
+ if (document.execCommand("insertLineBreak")) { updatePlaceholder(); return; }
+ // Запасной путь, если execCommand недоступен.
+ const sel = window.getSelection();
+ if (!sel.rangeCount) return;
+ const range = sel.getRangeAt(0);
+ range.deleteContents();
+ const br = document.createElement("br");
+ range.insertNode(br);
+ if (!br.nextSibling) br.after(document.createElement("br")); // чтобы каретка перешла вниз
+ range.setStartAfter(br);
+ range.collapse(true);
+ sel.removeAllRanges();
+ sel.addRange(range);
+ updatePlaceholder();
+}
+
+function sendComposer() {
+ if (ws?.readyState !== 1 || (!currentPeer && !currentGroup)) return;
+ const { text, marks } = serializeComposer();
+ if (!text) return;
+ if (text.length > 4096) return showChatError("Сообщение: 1–4096 символов");
+ // Режим правки: Enter изменяет сообщение, а не отправляет новое.
+ if (editTarget) {
+ const out = { type: "edit", id: editTarget.id, text };
+ if (marks.length) out.fmt = marks;
+ ws.send(JSON.stringify(out));
+ clearEdit();
+ stopTyping();
+ return;
+ }
+ const out = currentGroup ? { type: "send", group: currentGroup.id, text }
+ : { type: "send", to: currentPeer.id, text };
+ if (marks.length) out.fmt = marks;
+ if (replyTarget) out.reply_to = replyTarget.id;
+ ws.send(JSON.stringify(out));
+ clearComposer();
+ clearReply();
+ stopTyping();
+}
+
+/* --- Статус «печатает» --- */
+
+let typingState = {}; // from_id -> {name, until}
+let typingTick = null;
+
+function sendTyping() {
+ if (ws?.readyState !== 1 || me?.banned || (!currentPeer && !currentGroup)) return;
+ if (typingTimer) return; // не чаще раза в 2.5 с
+ ws.send(JSON.stringify(currentGroup ? { type: "typing", group: currentGroup.id }
+ : { type: "typing", to: currentPeer.id }));
+ typingTimer = setTimeout(() => { typingTimer = null; }, 2500);
+}
+function stopTyping() { clearTimeout(typingTimer); typingTimer = null; }
+
+function resetTyping() {
+ typingState = {};
+ clearInterval(typingTick);
+ typingTick = null;
+ $("#chat-status").classList.remove("typing");
+}
+
+function noteTyping(fromId, name) {
+ typingState[fromId] = { name: name || "", until: Date.now() + 4000 };
+ renderTypingStatus();
+ clearInterval(typingTick);
+ typingTick = setInterval(renderTypingStatus, 1000);
+}
+
+function renderTypingStatus() {
+ const now = Date.now();
+ for (const k in typingState) if (typingState[k].until < now) delete typingState[k];
+ const names = Object.values(typingState).map(t => t.name);
+ const el = $("#chat-status");
+ if (!names.length) {
+ clearInterval(typingTick); typingTick = null;
+ el.classList.remove("typing");
+ if (currentGroup) el.textContent = fmtAudience(currentGroup);
+ else if (currentPeer) el.textContent = fmtStatus(currentPeer);
+ return;
+ }
+ el.classList.add("typing");
+ el.textContent = currentGroup
+ ? (names.length === 1 ? `${names[0]} печатает…` : `${names.length} печатают…`)
+ : "печатает…";
+}
+
+/* --- Прочтение: отметка и галочки --- */
+
+function sendRead() {
+ if (currentPeer && !currentGroup && ws?.readyState === 1 && !document.hidden)
+ ws.send(JSON.stringify({ type: "read", with: currentPeer.id }));
+}
+
+/** Обновляет галочки ✓/✓✓ у моих сообщений в открытой личной переписке. */
+function updateTicks() {
+ if (currentGroup) return;
+ document.querySelectorAll("#messages .msg.mine").forEach(div => {
+ const t = div.querySelector(".ticks");
+ if (t && div._m) t.classList.toggle("read", div._m.id <= peerReadUpto);
+ });
+}
+
+/* --- Ответ на сообщение --- */
+
+/** Короткое текстовое представление сообщения (для панели ответа). */
+function msgShort(m) {
+ if (m.kind === "text") return m.content;
+ return ({ photo: "📷 Фото", gif: "GIF", file: "📎 " + (m.extra?.name || "Файл"),
+ audio: m.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио", call: "📞 Звонок" })[m.kind] || "Сообщение";
+}
+
+function setReply(m) {
+ if (me?.banned || $("#msg-form").hidden) return; // отвечать некуда — поле скрыто
+ clearEdit();
+ replyTarget = m;
+ $("#reply-bar").hidden = false;
+ $("#reply-bar-name").textContent = m.from === me.id ? "Вы" : (m.from_name || "Сообщение");
+ $("#reply-bar-text").textContent = msgShort(m).slice(0, 90);
+ composer().focus();
+}
+
+function clearReply() {
+ replyTarget = null;
+ $("#reply-bar").hidden = true;
+}
+
+/* --- Редактирование сообщения прямо в поле ввода --- */
+
+/** Заполняет композер текстом с форматированием (для редактирования). */
+function fillComposer(text, marks) {
+ const root = composer();
+ root.innerHTML = "";
+ if (!marks || !marks.length) {
+ root.appendChild(document.createTextNode(text));
+ } else {
+ // Та же сегментация, что и при отрисовке пузыря (renderRich), но без ссылок.
+ let i = 0;
+ while (i < text.length) {
+ const st = styleAt(marks, i);
+ let j = i + 1;
+ while (j < text.length && sameStyle(styleAt(marks, j), st)) j++;
+ root.appendChild(styledNode(text.slice(i, j), st));
+ i = j;
+ }
+ }
+ updatePlaceholder();
+}
+
+/** Ставит каретку в конец композера. */
+function caretToEnd() {
+ const root = composer();
+ root.focus();
+ const sel = window.getSelection();
+ const r = document.createRange();
+ r.selectNodeContents(root);
+ r.collapse(false);
+ sel.removeAllRanges();
+ sel.addRange(r);
+}
+
+/** Входит в режим правки: текст сообщения (с форматированием) попадает в поле ввода. */
+function startEdit(m) {
+ if (me?.banned || $("#msg-form").hidden) return;
+ clearReply();
+ editTarget = m;
+ $("#edit-bar").hidden = false;
+ fillComposer(m.content, m.extra?.fmt);
+ caretToEnd();
+}
+
+/** Выходит из режима правки (и очищает поле). */
+function clearEdit() {
+ if (!editTarget) return;
+ editTarget = null;
+ $("#edit-bar").hidden = true;
+ clearComposer();
+}
+
+/** Заполняет пузырь сообщения содержимым (используется и при редактировании). */
+function fillMessage(div, m) {
+ div._m = m; // данные сообщения — для меню действий и реакций
+ div.innerHTML = "";
+
+ // В группе у чужих сообщений показываем аватарку автора слева от пузыря.
+ const groupIncoming = m.group && m.from !== me.id;
+ div.classList.toggle("has-ava", !!groupIncoming);
+ if (groupIncoming) {
+ const av = avaNode(m.from, m.from_username, m.from_name, m.from_avatar_kind);
+ av.classList.add("msg-ava");
+ av.onclick = e => { e.stopPropagation(); openUserProfile(m.from); };
+ div.appendChild(av);
+ }
+
+ // В группе чужие сообщения подписываются автором (клик — его профиль).
+ if (m.group && m.from !== me.id && m.from_name) {
+ const author = document.createElement("span");
+ author.className = "msg-author";
+ author.textContent = m.from_name;
+ author.onclick = e => { e.stopPropagation(); openUserProfile(m.from); };
+ div.appendChild(author);
+ }
+ // Дубль поста канала в группе-обсуждении — помечаем источником.
+ if (m.extra?.channel_post) {
+ const tag = document.createElement("span");
+ tag.className = "channel-post-tag";
+ tag.textContent = "📢 Пост из «" + m.extra.channel_post.title + "»";
+ div.appendChild(tag);
+ }
+ // Цитата сообщения, на которое отвечает это сообщение (клик — переход к нему).
+ if (m.reply_to_preview) {
+ const rp = m.reply_to_preview;
+ const q = document.createElement("div");
+ q.className = "reply-quote clickable";
+ const who = document.createElement("small");
+ who.textContent = rp.from === me.id ? "Вы" : rp.from_name;
+ const tx = document.createElement("span");
+ tx.textContent = rp.text || "сообщение";
+ q.append(who, tx);
+ q.onclick = e => {
+ e.stopPropagation();
+ const target = $(`#messages [data-id="${rp.id}"]`);
+ if (!target) return toast("Сообщение не загружено");
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
+ target.classList.add("flash");
+ setTimeout(() => target.classList.remove("flash"), 1200);
+ };
+ div.appendChild(q);
+ }
+
+ if (m.kind === "photo") {
+ const img = document.createElement("img");
+ img.src = "/api/photos/" + m.content;
+ img.loading = "lazy";
+ img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; // просмотр внутри
+ div.appendChild(img);
+ } else if (m.kind === "gif") {
+ const img = document.createElement("img"); // GIF анимируется в
сам
+ img.className = "msg-gif";
+ img.src = "/api/gifs/" + m.content;
+ img.loading = "lazy";
+ img.onclick = e => { e.stopPropagation(); openPhoto(img.src); };
+ div.appendChild(img);
+ } else if (m.kind === "call") {
+ renderCall(div, m);
+ } else if (m.kind === "audio") {
+ renderAudio(div, m);
+ } else if (m.kind === "file") {
+ renderFile(div, m);
+ } else {
+ const p = document.createElement("p");
+ renderRich(p, m.content, m.extra?.fmt); // форматирование + @упоминания, без XSS
+ div.appendChild(p);
+ }
+ const time = document.createElement("time");
+ time.textContent = (m.extra?.pinned ? "📌 " : "") + (m.extra?.edited ? "изм. " : "")
+ + fmtSmart(m.created_at);
+ // Галочки «доставлено/прочитано» — у моих сообщений в личной переписке.
+ if (!m.group && m.from === me.id && me && !me.banned) {
+ const ticks = document.createElement("span");
+ ticks.className = "ticks" + (m.id <= peerReadUpto ? " read" : "");
+ time.appendChild(ticks);
+ }
+ div.appendChild(time);
+ renderReactions(div, m);
+
+ // Кнопка комментариев со счётчиком. Под постом канала (extra.disc → зеркало в
+ // обсуждении) и под дублем поста в самой группе-обсуждении (extra.channel_post).
+ const disc = m.extra?.disc; // пост в канале
+ const cpost = m.extra?.channel_post; // дубль поста в обсуждении
+ if (m.group && (disc || cpost)) {
+ const cid = disc ? m.group : cpost.cid; // id канала
+ const rootId = disc || m.id; // id поста-зеркала (корень треда)
+ const c = document.createElement("button");
+ c.className = "link comments-link";
+ c.textContent = "Комментарии" + (m.comments ? " " + m.comments : "");
+ c.onclick = e => { e.stopPropagation(); openCommentsThread(cid, rootId); };
+ div.appendChild(c);
+ }
+
+ // Кнопки модерации в сообщении-жалобе (видны только админу, пока жалоба открыта).
+ if (m.extra?.report && me.is_admin && !m.extra.closed) {
+ const actions = document.createElement("div");
+ actions.className = "report-actions";
+ for (const [label, act] of [["Забанить", "ban"], ["Отклонить", "dismiss"]]) {
+ const b = document.createElement("button");
+ b.className = "primary small-btn" + (act === "ban" ? " danger-btn" : "");
+ b.textContent = label;
+ b.onclick = async e => {
+ e.stopPropagation();
+ const { ok, data } = await api(`/api/admin/reports/${m.extra.report}/${act}`, {});
+ if (!ok) return toast(data.error || "Ошибка");
+ actions.remove();
+ toast(act === "ban" ? "Пользователь забанен" : "Жалоба отклонена");
+ };
+ actions.appendChild(b);
+ }
+ div.appendChild(actions);
+ }
+}
+
+function appendMessage(m) {
+ const box = $("#messages");
+ if (box.querySelector(`[data-id="${m.id}"]`)) return; // защита от дублей
+ const div = document.createElement("div");
+ div.className = "msg" + (m.from === me.id ? " mine" : "");
+ div.dataset.id = m.id;
+ fillMessage(div, m);
+ div.oncontextmenu = e => openMsgMenu(e, div); // ПКМ по сообщению — меню действий
+
+ const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 60;
+ box.appendChild(div);
+ if (atBottom || m.from === me.id) scrollMessages();
+}
+
+/* --- Файлы: карточка, размер, предпросмотр --- */
+
+const FILE_SVG = ``;
+
+function fmtSize(bytes) {
+ if (bytes < 1024) return bytes + " Б";
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " КБ";
+ return (bytes / 1048576).toFixed(1) + " МБ";
+}
+
+/** Файл-сообщение: видео играет в чате; PDF открывается в окне предпросмотра;
+ * для документов/презентаций/таблиц показывается текстовый сниппет. */
+function renderFile(div, m) {
+ const f = m.extra || {};
+ const src = "/api/files/" + m.content;
+
+ if (f.mime?.startsWith("video/")) { // предпросмотр видео — плеер прямо в чате
+ const v = document.createElement("video");
+ v.src = src;
+ v.controls = true;
+ v.preload = "metadata";
+ v.oncontextmenu = e => e.stopPropagation(); // нативное меню видео
+ div.appendChild(v);
+ }
+
+ const row = document.createElement("a");
+ row.className = "file-row";
+ row.href = src + "?download=1"; // клик по карточке — скачать с исходным именем
+ row.innerHTML = FILE_SVG;
+ const info = document.createElement("div");
+ info.className = "file-info";
+ const name = document.createElement("span");
+ name.textContent = f.name || "Файл";
+ const size = document.createElement("small");
+ size.textContent = fmtSize(f.size || 0);
+ info.append(name, size);
+ row.appendChild(info);
+ div.appendChild(row);
+
+ if (f.mime === "application/pdf") { // PDF рендерит сам браузер — во всплывающем окне
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "link file-preview-btn";
+ b.textContent = "Предпросмотр";
+ b.onclick = e => { e.stopPropagation(); openFilePreview(src, f.name); };
+ div.appendChild(b);
+ }
+ if (f.preview) { // текстовый сниппет документа / презентации / таблицы
+ const p = document.createElement("p");
+ p.className = "file-preview";
+ p.textContent = f.preview;
+ div.appendChild(p);
+ }
+}
+
+/* --- Аудио: голосовые сообщения и аудиофайлы (встроенный плеер) --- */
+
+const MIC_SVG = ``;
+
+function fmtDuration(sec) {
+ sec = Math.max(0, Math.round(sec || 0));
+ return Math.floor(sec / 60) + ":" + String(sec % 60).padStart(2, "0");
+}
+
+/** Аудио-сообщение: голосовое (🎤 + длительность) или аудиофайл (с именем) —
+ * оба проигрываются встроенным плеером прямо в пузыре. */
+function renderAudio(div, m) {
+ const f = m.extra || {};
+ const box = document.createElement("div");
+ box.className = "audio-msg" + (f.voice ? " voice" : "");
+
+ const head = document.createElement("div");
+ head.className = "audio-head";
+ if (f.voice) {
+ head.innerHTML = MIC_SVG;
+ const label = document.createElement("span");
+ label.textContent = "Голосовое сообщение";
+ head.appendChild(label);
+ } else {
+ const name = document.createElement("span");
+ name.textContent = f.name || "Аудио";
+ head.appendChild(name);
+ }
+ if (f.duration) {
+ const dur = document.createElement("small");
+ dur.textContent = fmtDuration(f.duration);
+ head.appendChild(dur);
+ }
+ box.appendChild(head);
+
+ const audio = document.createElement("audio");
+ audio.controls = true;
+ audio.preload = "metadata";
+ audio.src = "/api/audio/" + m.content;
+ audio.oncontextmenu = e => e.stopPropagation(); // нативное меню плеера
+ box.appendChild(audio);
+ div.appendChild(box);
+}
+
+/** Историческая запись о звонке (звонки больше не поддерживаются — только подпись). */
+function renderCall(div, m) {
+ const box = document.createElement("div");
+ box.className = "call-msg";
+ const label = document.createElement("span");
+ label.textContent = "📞 " + (m.from === me.id ? "Исходящий звонок"
+ : `${m.extra?.by || m.from_name || "Собеседник"} звонил(а) вам`);
+ box.append(label);
+ div.appendChild(box);
+}
+
+/** Предпросмотр PDF внутри мессенджера (iframe в модальном окне). */
+function openFilePreview(src, name) {
+ const wrap = document.createElement("div");
+ wrap.className = "modal preview-modal";
+ const card = document.createElement("div");
+ card.className = "preview-card";
+ const head = document.createElement("div");
+ head.className = "preview-head";
+ const title = document.createElement("span");
+ title.textContent = name || "Предпросмотр";
+ const close = document.createElement("button");
+ close.className = "icon-btn";
+ close.innerHTML = ``;
+ head.append(title, close);
+ const frame = document.createElement("iframe");
+ frame.src = src;
+ card.append(head, frame);
+ wrap.appendChild(card);
+
+ const closeAll = () => { wrap.remove(); document.removeEventListener("keydown", onKey); };
+ const onKey = ev => { if (ev.key === "Escape") closeAll(); };
+ close.onclick = closeAll;
+ wrap.onclick = e => { if (e.target === wrap) closeAll(); };
+ document.addEventListener("keydown", onKey);
+ document.body.appendChild(wrap);
+}
+
+/* --- Реакции --- */
+
+const REACTION_SET = [
+ "😁", "🤣", "😍", "😘", "🥰", "🥲", "🤩", "🤔", "🫡", "🤨", "😐", "😑", "😶",
+ "🙄", "😯", "🥱", "😴", "🤤", "🤑", "🙁", "😭", "😨", "🤯", "😬", "😱", "🤬",
+ "🤮", "🥺", "🥹", "🤡", "🤓", "😈", "👿", "💀", "💩", "👀", "🤞", "🫸", "🤝",
+ "👌", "👍", "👎", "🤌", "✊", "👋", "👏", "🎉", "💋", "🏆", "🗿", "📝", "❤️",
+ "❌", "✅",
+];
+
+/** Полоска реакций под сообщением: группировка по эмодзи, своя — подсвечена. */
+function renderReactions(div, m) {
+ div.querySelector(".reactions")?.remove();
+ if (!m.reactions?.length) return;
+ const row = document.createElement("div");
+ row.className = "reactions";
+ const groups = {};
+ for (const r of m.reactions) (groups[r.emoji] ??= []).push(r.user_id);
+ for (const [emoji, users] of Object.entries(groups)) {
+ const chip = document.createElement("button");
+ chip.className = "reaction-chip" + (users.includes(me.id) ? " mine" : "");
+ chip.textContent = users.length > 1 ? `${emoji} ${users.length}` : emoji;
+ chip.onclick = e => { // клик по своей реакции снимает её, по чужой — присоединяется
+ e.stopPropagation();
+ if (!me.banned) ws?.send(JSON.stringify({ type: "react", id: m.id, emoji }));
+ };
+ row.appendChild(chip);
+ }
+ div.insertBefore(row, div.querySelector("time"));
+}
+
+/* --- Контекстное меню (ПКМ): реакции, закрепить, изменить, удалить --- */
+
+function closeMsgMenu() { document.querySelector(".msg-menu")?.remove(); }
+document.addEventListener("click", closeMsgMenu); // клик мимо меню закрывает его
+document.addEventListener("contextmenu", closeMsgMenu); // как и ПКМ мимо
+
+/** Пустое меню, спозиционированное у курсора (общая часть всех контекстных меню). */
+function contextMenu(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ closeMsgMenu();
+ const menu = document.createElement("div");
+ menu.className = "msg-menu";
+ menu.oncontextmenu = ev => ev.preventDefault();
+ menu._place = () => {
+ const r = menu.getBoundingClientRect(); // не выходить за край экрана
+ menu.style.left = Math.max(8, Math.min(e.clientX, innerWidth - r.width - 8)) + "px";
+ menu.style.top = Math.max(8, Math.min(e.clientY, innerHeight - r.height - 8)) + "px";
+ };
+ return menu;
+}
+
+function menuItem(menu, label, danger, onclick) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "msg-menu-item" + (danger ? " danger" : "");
+ b.textContent = label;
+ b.onclick = onclick;
+ menu.appendChild(b);
+}
+
+/** Кнопка-эмодзи: ЛКМ — поставить реакцию, ПКМ — закрепить/открепить эмодзи. */
+function reactionBtn(m, emoji) {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.textContent = emoji;
+ if (me.pinned_reactions.includes(emoji)) b.classList.add("pinned");
+ b.onclick = () => ws?.send(JSON.stringify({ type: "react", id: m.id, emoji }));
+ b.oncontextmenu = ev => { ev.preventDefault(); ev.stopPropagation(); togglePinReaction(emoji); };
+ return b;
+}
+
+/** ПКМ по эмодзи: закрепляет его первым в списке (макс. 5) либо открепляет. */
+async function togglePinReaction(emoji) {
+ let pinned = me.pinned_reactions.slice();
+ if (pinned.includes(emoji)) {
+ pinned = pinned.filter(x => x !== emoji);
+ toast(`Реакция ${emoji} откреплена`);
+ } else {
+ pinned.unshift(emoji);
+ pinned = pinned.slice(0, 5);
+ toast(`Реакция ${emoji} закреплена`);
+ }
+ me.pinned_reactions = pinned;
+ closeMsgMenu();
+ await api("/api/me/reactions", { pinned });
+}
+
+function openMsgMenu(e, div) {
+ if (me.banned) return; // только чтение
+ const m = div._m;
+ const menu = contextMenu(e);
+
+ // Быстрый ряд — закреплённые эмодзи + кнопка «вся палитра».
+ const row = document.createElement("div");
+ row.className = "msg-menu-reactions";
+ for (const emoji of me.pinned_reactions) row.appendChild(reactionBtn(m, emoji));
+ const more = document.createElement("button");
+ more.type = "button";
+ more.className = "more";
+ more.textContent = "⌄";
+ more.title = "Все реакции";
+ more.onclick = ev => { ev.stopPropagation(); grid.hidden = !grid.hidden; menu._place(); };
+ row.appendChild(more);
+ menu.appendChild(row);
+
+ // Полная палитра: закреплённые первыми, затем остальные.
+ const grid = document.createElement("div");
+ grid.className = "msg-menu-grid";
+ grid.hidden = true;
+ const rest = REACTION_SET.filter(x => !me.pinned_reactions.includes(x));
+ for (const emoji of [...me.pinned_reactions, ...rest]) grid.appendChild(reactionBtn(m, emoji));
+ menu.appendChild(grid);
+
+ // Ответить — если в этот чат можно писать (поле ввода не скрыто).
+ if (!$("#msg-form").hidden)
+ menuItem(menu, "Ответить", false, () => setReply(m));
+ menuItem(menu, m.extra?.pinned ? "Открепить" : "Закрепить", false,
+ () => ws?.send(JSON.stringify({ type: "pin", id: m.id, pinned: !m.extra?.pinned })));
+ if (m.from === me.id && m.kind === "text")
+ menuItem(menu, "Изменить", false, () => startEdit(m));
+ // Удалить может автор; в группе — ещё и её владелец (модерация).
+ if (m.from === me.id || (m.group && currentGroup?.is_owner))
+ menuItem(menu, "Удалить", true, () => confirmDialog(
+ "Удалить сообщение у всех участников?",
+ () => ws?.send(JSON.stringify({ type: "delete", id: m.id }))));
+
+ document.body.appendChild(menu);
+ menu._place();
+}
+
+/* --- Контекстное меню переписки (ПКМ по чату в списке) --- */
+
+function deleteChat(peerId) {
+ confirmDialog("Удалить переписку? Сообщения удалятся у обоих участников.", async () => {
+ const { ok, data } = await api(`/api/chats/${peerId}/delete`, {});
+ if (!ok) return toast(data.error || "Ошибка");
+ if (currentPeer?.id === peerId) openView("empty");
+ loadChats();
+ });
+}
+
+function openChatMenu(e, peerId) {
+ if (me.banned) return;
+ const menu = contextMenu(e);
+ menuItem(menu, "Удалить переписку", true, () => deleteChat(peerId));
+ document.body.appendChild(menu);
+ menu._place();
+}
+
+/* --- Контекстное меню группы (ПКМ по группе в списке) --- */
+
+function openGroupRowMenu(e, gid) {
+ if (me.banned) return;
+ const menu = contextMenu(e);
+ menuItem(menu, "Профиль группы", false, () => openGroupProfile(gid));
+ menuItem(menu, "Выйти из группы", true, () => leaveGroup(gid));
+ document.body.appendChild(menu);
+ menu._place();
+}
+
+/* --- Просмотр фото внутри мессенджера (лайтбокс) --- */
+
+function openPhoto(src) {
+ const wrap = document.createElement("div");
+ wrap.className = "modal photo-modal";
+ const img = document.createElement("img");
+ img.src = src;
+ img.alt = "Фото";
+ wrap.appendChild(img);
+ const close = () => { wrap.remove(); document.removeEventListener("keydown", onKey); };
+ const onKey = ev => { if (ev.key === "Escape") close(); };
+ wrap.onclick = close;
+ document.addEventListener("keydown", onKey);
+ document.body.appendChild(wrap);
+}
+
+$("#msg-form").addEventListener("submit", e => {
+ e.preventDefault();
+ sendComposer();
+});
+
+// Контекстное меню форматирования и поведение поля ввода (contenteditable).
+composer().addEventListener("contextmenu", openFormatMenu);
+composer().addEventListener("input", () => { updatePlaceholder(); sendTyping(); });
+composer().addEventListener("keydown", e => {
+ if (e.key === "Escape" && editTarget) { e.preventDefault(); clearEdit(); return; }
+ if (e.key !== "Enter" || e.isComposing) return;
+ e.preventDefault();
+ // Shift+Enter и Ctrl+Enter — перенос строки, обычный Enter — отправка.
+ if (e.shiftKey || e.ctrlKey) insertBreak();
+ else sendComposer();
+});
+$("#reply-cancel").onclick = clearReply;
+$("#edit-cancel").onclick = clearEdit;
+updatePlaceholder();
+
+$("#chat-head").onclick = () => {
+ if (currentGroup) openGroupProfile(currentGroup.id);
+ else if (currentPeer) openUserProfile(currentPeer.id);
+};
+
+/* --- Мобильная навигация: кнопки «Назад» возвращают к списку чатов --- */
+
+$("#chat-back").onclick = e => {
+ e.stopPropagation(); // не открывать профиль из шапки
+ openView("empty");
+};
+document.querySelectorAll("[data-back]").forEach(btn =>
+ btn.addEventListener("click", () => openView("empty")));
+
+/* --- Долгое нажатие (тач) = ПКМ: контекстные меню на телефонах.
+ Android шлёт contextmenu сам, iOS Safari — нет, поэтому синтезируем. --- */
+
+let lpTimer = null, lpFired = false;
+document.addEventListener("touchstart", e => {
+ if (e.touches.length !== 1) return;
+ const { clientX, clientY } = e.touches[0];
+ const target = e.target;
+ lpFired = false;
+ lpTimer = setTimeout(() => {
+ if (document.querySelector(".msg-menu")) return; // Android уже открыл меню сам
+ lpFired = true;
+ target.dispatchEvent(new MouseEvent("contextmenu",
+ { bubbles: true, cancelable: true, clientX, clientY }));
+ }, 500);
+}, { passive: true });
+document.addEventListener("touchmove", () => clearTimeout(lpTimer), { passive: true });
+document.addEventListener("touchend", e => {
+ clearTimeout(lpTimer);
+ if (lpFired) { e.preventDefault(); lpFired = false; } // погасить click после долгого тапа
+}, { passive: false });
+document.addEventListener("touchcancel", () => clearTimeout(lpTimer));
+
+/* --- Удаление переписки из шапки чата (дублирует ПКМ по чату в списке) --- */
+
+$("#chat-delete").onclick = e => {
+ e.stopPropagation(); // не открывать профиль из шапки
+ if (currentGroup) (currentGroup.is_owner ? deleteGroup : leaveGroup)(currentGroup.id);
+ else if (currentPeer) deleteChat(currentPeer.id);
+};
+
+/* --- Вложения: кнопка "+", вставка из буфера, перетаскивание.
+ Картинки сжимаются как фото, остальные файлы (до 25 МБ) — как есть. --- */
+
+async function sendAttachment(file) {
+ if (!file || (!currentPeer && !currentGroup)) return;
+ if (file.size > 25 * 1024 * 1024) return showChatError("Файл больше 25 МБ");
+ if (file.type.startsWith("audio/")) // аудиофайл — отправляем как аудио-сообщение
+ return uploadAudio(file, { name: file.name });
+ const isImage = file.type.startsWith("image/");
+ const fd = new FormData();
+ if (currentGroup) fd.append("group", currentGroup.id);
+ else fd.append("to", currentPeer.id);
+ fd.append(isImage ? "photo" : "file", file);
+ const res = await fetch(isImage ? "/api/messages/photo" : "/api/messages/file",
+ { method: "POST", body: fd });
+ if (!res.ok) showChatError((await res.json().catch(() => ({}))).error
+ || "Не удалось отправить файл");
+}
+
+/** Отправка аудио (голосовое или файл) на /api/messages/audio. */
+async function uploadAudio(blob, { voice = false, duration = 0, name = "" } = {}) {
+ if (!blob || (!currentPeer && !currentGroup)) return;
+ if (blob.size > 25 * 1024 * 1024) return showChatError("Аудио больше 25 МБ");
+ const fd = new FormData();
+ if (currentGroup) fd.append("group", currentGroup.id);
+ else fd.append("to", currentPeer.id);
+ fd.append("audio", blob, name || "voice.webm");
+ if (voice) fd.append("voice", "1");
+ if (duration) fd.append("duration", String(duration));
+ const res = await fetch("/api/messages/audio", { method: "POST", body: fd });
+ if (!res.ok) showChatError((await res.json().catch(() => ({}))).error
+ || "Не удалось отправить аудио");
+}
+
+/* --- Запись голосовых сообщений (MediaRecorder) --- */
+
+let mediaRecorder = null, recChunks = [], recStart = 0, recTimer = null, recCancelled = false;
+
+async function startRecording() {
+ if (!currentPeer && !currentGroup) return;
+ if (mediaRecorder) return; // уже идёт запись
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder)
+ return showChatError("Запись аудио не поддерживается этим браузером");
+ let stream;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ } catch {
+ return showChatError("Нет доступа к микрофону");
+ }
+ // Выбираем формат, который умеет браузер (Chrome — webm/opus, Safari — mp4).
+ const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"]
+ .find(t => MediaRecorder.isTypeSupported(t)) || "";
+ recChunks = [];
+ recCancelled = false;
+ mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
+ mediaRecorder.ondataavailable = e => { if (e.data.size) recChunks.push(e.data); };
+ mediaRecorder.onstop = () => {
+ stream.getTracks().forEach(t => t.stop());
+ clearInterval(recTimer);
+ $("#rec-bar").hidden = true;
+ $("#msg-form").hidden = false;
+ const mr = mediaRecorder;
+ mediaRecorder = null;
+ if (recCancelled || !recChunks.length) return;
+ const duration = (Date.now() - recStart) / 1000;
+ const type = (mr.mimeType || "audio/webm").split(";")[0];
+ const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm";
+ uploadAudio(new Blob(recChunks, { type }),
+ { voice: true, duration, name: "voice." + ext });
+ };
+ mediaRecorder.start();
+ recStart = Date.now();
+ $("#msg-form").hidden = true;
+ const bar = $("#rec-bar");
+ bar.hidden = false;
+ $("#rec-time").textContent = "0:00";
+ recTimer = setInterval(() => {
+ const s = (Date.now() - recStart) / 1000;
+ $("#rec-time").textContent = fmtDuration(s);
+ if (s >= 600) stopRecording(false); // защита: максимум 10 минут
+ }, 200);
+}
+
+function stopRecording(cancel) {
+ if (!mediaRecorder) return;
+ recCancelled = cancel;
+ try { mediaRecorder.stop(); } catch { /* уже остановлен */ }
+}
+
+$("#mic-btn").onclick = () => startRecording();
+$("#rec-send").onclick = () => stopRecording(false);
+$("#rec-cancel").onclick = () => stopRecording(true);
+
+$("#attach-btn").onclick = () => $("#photo-input").click();
+$("#photo-input").onchange = e => { sendAttachment(e.target.files[0]); e.target.value = ""; };
+
+$("#msg-input").addEventListener("paste", e => {
+ const item = [...(e.clipboardData?.items || [])].find(i => i.kind === "file");
+ if (item) { e.preventDefault(); sendAttachment(item.getAsFile()); return; }
+ // Вставляем как простой текст — чтобы не тащить чужое HTML-форматирование.
+ e.preventDefault();
+ const text = e.clipboardData.getData("text/plain");
+ document.execCommand("insertText", false, text);
+ updatePlaceholder();
+});
+
+const msgForm = $("#msg-form");
+msgForm.addEventListener("dragover", e => { e.preventDefault(); msgForm.classList.add("drag"); });
+msgForm.addEventListener("dragleave", () => msgForm.classList.remove("drag"));
+msgForm.addEventListener("drop", e => {
+ e.preventDefault();
+ msgForm.classList.remove("drag");
+ sendAttachment(e.dataTransfer.files[0]);
+});
+
+/* ===== GIF: общая библиотека ===== */
+
+/** Пикер GIF: поиск по ключевым словам, клик по гифке — отправка в открытый чат. */
+function openGifPicker() {
+ if (!currentPeer && !currentGroup) return;
+ const wrap = document.createElement("div");
+ wrap.className = "modal";
+ const card = document.createElement("div");
+ card.className = "modal-card gif-picker";
+ card.innerHTML = `GIF
+
+ `;
+ wrap.appendChild(card);
+ const close = () => wrap.remove();
+ wrap.addEventListener("click", e => { if (e.target === wrap) close(); });
+
+ const grid = card.querySelector("#gif-grid");
+ const input = card.querySelector("#gif-search");
+ const render = async () => {
+ const { ok, data } = await api("/api/gifs?q=" + encodeURIComponent(input.value.trim()));
+ grid.innerHTML = "";
+ if (!ok || !data.length) {
+ grid.innerHTML = `Ничего не найдено.
Добавьте GIF через @addgif.
`;
+ return;
+ }
+ for (const g of data) {
+ const img = document.createElement("img");
+ img.src = "/api/gifs/" + g.file;
+ img.loading = "lazy";
+ img.onclick = async () => {
+ const body = currentGroup ? { group: currentGroup.id, file: g.file }
+ : { to: currentPeer.id, file: g.file };
+ const r = await api("/api/messages/gif", body);
+ if (!r.ok) return toast(r.data.error || "Не удалось отправить");
+ close();
+ };
+ grid.appendChild(img);
+ }
+ };
+ let t = null;
+ input.addEventListener("input", () => { clearTimeout(t); t = setTimeout(render, 300); });
+ document.body.appendChild(wrap);
+ input.focus();
+ render(); // сразу показать недавние
+}
+
+/** Диалог добавления GIF в общую библиотеку (бот @addgif). */
+function openAddGifDialog() {
+ const wrap = document.createElement("div");
+ wrap.className = "modal";
+ const card = document.createElement("div");
+ card.className = "modal-card dialog";
+ card.innerHTML = `Добавить GIF
+ GIF попадёт в общую библиотеку и станет доступен всем по ключевым
+ словам. Можно добав��ть до 5 GIF в сутки.
+
+
+
+
+
+
+
`;
+ wrap.appendChild(card);
+
+ const errEl = card.querySelector(".error");
+ const fileInput = card.querySelector("#gif-file");
+ const nameEl = card.querySelector("#gif-file-name");
+ const kw = card.querySelector("#gif-keywords");
+ const close = () => wrap.remove();
+ card.querySelector("[data-cancel]").onclick = close;
+ wrap.addEventListener("click", e => { if (e.target === wrap) close(); });
+ fileInput.onchange = () =>
+ (nameEl.textContent = fileInput.files[0] ? fileInput.files[0].name : "Выбрать GIF-файл");
+
+ card.querySelector("[data-add]").onclick = async () => {
+ errEl.hidden = true;
+ const fail = msg => { errEl.textContent = msg; errEl.hidden = false; };
+ const file = fileInput.files[0];
+ if (!file) return fail("Выберите GIF-файл");
+ if (file.type !== "image/gif") return fail("Это должен быть GIF-файл");
+ if (file.size > 15 * 1024 * 1024) return fail("GIF больше 15 МБ");
+ if (!kw.value.trim()) return fail("Укажите ключевые слова");
+ const fd = new FormData();
+ fd.append("gif", file);
+ fd.append("keywords", kw.value.trim());
+ const res = await fetch("/api/gifs", { method: "POST", body: fd });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) return fail(data.error || "Ошибка");
+ close();
+ toast("GIF добавлен в библиотеку");
+ // @addgif пришлёт подтверждение по WS — чат и список обновятся сами.
+ };
+ document.body.appendChild(wrap);
+}
+
+$("#gif-btn").onclick = openGifPicker;
+$("#addgif-add").onclick = openAddGifDialog;
+
+/* ===== Поиск ===== */
+
+let searchTimer = null;
+
+async function runSearch() {
+ const q = $("#search-input").value.trim();
+ const box = $("#search-results");
+ $("#chat-list").hidden = !!q;
+ box.hidden = !q;
+ $("#search-opts").hidden = !q;
+ if (!q) return;
+
+ const params = new URLSearchParams({
+ q, type: $("#search-type").value, mode: $("#search-mode").value,
+ });
+ const { ok, data } = await api("/api/search?" + params);
+ box.innerHTML = "";
+ if (!ok || !data.length) {
+ box.innerHTML = `Ничего не найдено
`;
+ return;
+ }
+
+ for (const r of data) {
+ const row = document.createElement("button");
+ row.className = "chat-row";
+ const type = $("#search-type").value;
+ if (type === "groups") { // публичные группы: по названию и юзернейму
+ row.appendChild(groupAvaNode(r.id, r.title));
+ const info = document.createElement("div");
+ info.className = "chat-row-info";
+ const name = document.createElement("span");
+ name.textContent = r.title;
+ const sub = document.createElement("small");
+ sub.textContent = (r.is_channel ? "Канал · " : "")
+ + (r.username ? "@" + r.username + " · " : "")
+ + (r.is_channel ? fmtSubscribers(r.members) : fmtMembers(r.members));
+ info.append(name, sub);
+ row.appendChild(info);
+ row.onclick = () => openGroupProfile(r.id); // клик -> профиль группы/канала
+ } else if (type === "users") {
+ row.appendChild(avaNode(r.id, r.username, r.display_name, r.avatar_kind));
+ const info = document.createElement("div");
+ info.className = "chat-row-info";
+ const name = document.createElement("span");
+ name.textContent = r.display_name;
+ const uname = document.createElement("small");
+ uname.textContent = "@" + r.username;
+ info.append(name, uname);
+ row.appendChild(info);
+ row.onclick = () => openUserProfile(r.id); // клик -> профиль пользователя
+ } else {
+ row.appendChild(avaNode(r.peer_id, r.peer_deleted ? null : r.peer_username,
+ r.peer_name, r.peer_avatar_kind));
+ const info = document.createElement("div");
+ info.className = "chat-row-info";
+ const name = document.createElement("span");
+ name.textContent = r.peer_deleted ? "Удалённый аккаунт" : r.peer_name;
+ const snippet = document.createElement("small");
+ snippet.textContent = r.content;
+ info.append(name, snippet);
+ const time = document.createElement("time");
+ time.textContent = fmtSmart(r.created_at);
+ row.append(info, time);
+ row.onclick = () => openChat(r.peer_id); // клик -> чат с сообщением
+ }
+ box.appendChild(row);
+ }
+}
+
+$("#search-input").addEventListener("input", () => {
+ clearTimeout(searchTimer);
+ searchTimer = setTimeout(runSearch, 300);
+});
+$("#search-type").onchange = runSearch;
+$("#search-mode").onchange = runSearch;
+
+/* ===== Профиль другого пользователя ===== */
+
+async function openUserProfile(id) {
+ if (id === me.id) return openView("profile"); // свой профиль — отдельный вид
+ const { ok, data } = await api("/api/peer/" + id);
+ if (!ok) return;
+ const u = data;
+ openView("user");
+
+ setName($("#user-name"), u.deleted ? "Удалённый аккаунт" : u.display_name, u.is_admin);
+ $("#user-status").textContent = fmtStatus(u);
+ $("#user-username").textContent = u.deleted ? "" : "@" + u.username;
+ renderMentions($("#user-desc"), u.description || "");
+
+ renderProfileAvatar("user-ava", "user-ava-stub", u.username,
+ u.has_avatar && !u.deleted, u.avatar_kind);
+
+ // Кнопки: добавить в контакты (если ещё не добавлен) и блокировка.
+ // В режиме «только чтение» (бан) никакие действия недоступны.
+ $("#user-add-contact").hidden = !!me.banned || !!u.deleted || u.is_contact;
+ $("#user-add-contact").onclick = async () => {
+ await api("/api/me/contacts", { username: u.username });
+ openUserProfile(id);
+ };
+ // Админа и системные аккаунты заблокировать нельзя.
+ $("#user-block").hidden = !!me.banned || !!u.deleted || u.is_admin || u.is_system;
+ $("#user-block").textContent = u.i_blocked ? "Разблокировать" : "Заблокировать";
+ $("#user-block").onclick = async () => {
+ const { ok, data } = await api(`/api/users/${id}/${u.i_blocked ? "unblock" : "block"}`, {});
+ if (!ok) toast(data.error);
+ openUserProfile(id);
+ };
+
+ // Писать нельзя удалённым, забаненным, @reports и в @salgram (если вы не админ).
+ $("#user-write").hidden = !!u.deleted || !!u.banned
+ || (u.is_system && (u.username === "reports"
+ || (u.username === "salgram" && !me.is_admin)));
+ $("#user-write").onclick = () => openChat(id);
+
+ // Жалоба (не на админа и не на системные аккаунты).
+ $("#user-report").hidden = !!me.banned || !!u.deleted || u.is_admin || u.is_system;
+ $("#user-report").onclick = () => confirmDialog(
+ `Пожаловаться на ${u.display_name}? Переписка с ним будет отправлена администратору.`,
+ async () => {
+ const { ok, data } = await api(`/api/users/${id}/report`, {});
+ toast(ok ? "Жалоба отправлена администратору" : data.error);
+ });
+
+ // Удаление аккаунта — только для админа.
+ $("#user-delete").hidden = !me.is_admin || !!u.deleted || u.is_admin || u.is_system;
+ $("#user-delete").onclick = () => confirmDialog(
+ `Удалить аккаунт ${u.display_name}?`,
+ async () => {
+ const { ok, data } = await api(`/api/admin/users/${id}/delete`, {});
+ toast(ok ? "Аккаунт удалён" : data.error);
+ openUserProfile(id);
+ });
+
+ // Список контактов (если виден по приватности и нет блокировки).
+ const sec = $("#user-contacts-sec");
+ sec.hidden = !u.contacts;
+ if (u.contacts) {
+ const ul = $("#user-contact-list");
+ ul.innerHTML = "";
+ if (!u.contacts.length) ul.innerHTML = `Контактов нет`;
+ for (const c of u.contacts) {
+ const li = document.createElement("li");
+ const name = document.createElement("span");
+ name.textContent = c.display_name;
+ const uname = document.createElement("small");
+ uname.textContent = "@" + c.username;
+ li.append(name, uname);
+ ul.appendChild(li);
+ }
+ }
+}
+
+/* ===== Универсальные всплывающие окна ===== */
+
+function dialog(title, fields, submitLabel, onSubmit) {
+ const wrap = document.createElement("div");
+ wrap.className = "modal";
+ wrap.innerHTML = `
+ `;
+
+ const form = wrap.firstElementChild;
+ fields.forEach(f => { if (f.value) form.elements[f.name].value = f.value; }); // без XSS
+
+ wrap.querySelector("[data-cancel]").onclick = () => wrap.remove();
+ wrap.addEventListener("click", e => { if (e.target === wrap) wrap.remove(); });
+ form.onsubmit = async e => {
+ e.preventDefault();
+ const errorEl = form.querySelector(".error");
+ errorEl.hidden = true;
+ const err = await onSubmit(Object.fromEntries(new FormData(form)));
+ if (!err) return wrap.remove();
+ errorEl.textContent = err;
+ errorEl.hidden = false;
+ };
+
+ document.body.appendChild(wrap);
+ form.querySelector("input, textarea")?.focus();
+ return form; // для динамики внутри диалога (скрытие полей и т.п.)
+}
+
+function confirmDialog(text, onYes) {
+ const wrap = document.createElement("div");
+ wrap.className = "modal";
+ wrap.innerHTML = `
+ `;
+ wrap.querySelector("h2").textContent = text; // textContent — защита от XSS
+ wrap.querySelector("[data-cancel]").onclick = () => wrap.remove();
+ wrap.addEventListener("click", e => { if (e.target === wrap) wrap.remove(); });
+ wrap.querySelector("[data-yes]").onclick = () => { wrap.remove(); onYes(); };
+ document.body.appendChild(wrap);
+}
+
+/* ===== Авторизация ===== */
+
+async function submitAuth(form, path, payload) {
+ const errorEl = form.querySelector(".error");
+ errorEl.hidden = true;
+ const { ok, data } = await api(path, payload);
+ if (ok) return enterApp();
+ errorEl.textContent = data.error || "Ошибка сети, попробуйте позже";
+ errorEl.hidden = false;
+}
+
+$("#register-form").addEventListener("submit", e => {
+ e.preventDefault();
+ const f = e.target.elements;
+ submitAuth(e.target, "/api/register", {
+ display_name: f.display_name.value.trim(),
+ username: f.username.value.trim(),
+ password: f.password.value,
+ tos: f.tos.checked,
+ });
+});
+
+$("#login-form").addEventListener("submit", e => {
+ e.preventDefault();
+ const f = e.target.elements;
+ submitAuth(e.target, "/api/login", {
+ username: f.username.value.trim(),
+ password: f.password.value,
+ });
+});
+
+document.querySelectorAll(".switch").forEach(btn =>
+ btn.addEventListener("click", () => {
+ btn.closest("form").hidden = true;
+ const next = document.getElementById(btn.dataset.target);
+ next.hidden = false;
+ next.classList.remove("appear");
+ void next.offsetWidth; // перезапуск CSS-анимации
+ next.classList.add("appear");
+ })
+);
+
+$("#tos-open").addEventListener("click", () => ($("#tos-modal").hidden = false));
+$("#tos-close").addEventListener("click", () => ($("#tos-modal").hidden = true));
+$("#tos-modal").addEventListener("click", e => {
+ if (e.target.id === "tos-modal") e.target.hidden = true;
+});
+
+/* ===== Панель навигации ===== */
+
+$("#nav-settings").onclick = () => openView("settings");
+$("#nav-profile").onclick = () => openView("profile");
+
+/* Создание группы: публичной обязателен юзернейм (по нему её находят поиском),
+ приватная — без юзернейма, вход только по ссылке. */
+$("#group-create").onclick = () => {
+ const form = dialog("Создать", [
+ { name: "type", type: "select", options: [
+ { value: "group", label: "Группа" },
+ { value: "channel", label: "Канал (публикует владелец)" },
+ ] },
+ { name: "title", placeholder: "Название", maxlength: 64 },
+ { name: "public", type: "select", options: [
+ { value: "1", label: "Публичный — виден всем в поиске" },
+ { value: "0", label: "Приватный — вход только по ссылке" },
+ ] },
+ { name: "username", placeholder: "Юзернейм", maxlength: 32, optional: true },
+ ], "Создать", async v => {
+ const isPublic = v.public === "1";
+ const isChannel = v.type === "channel";
+ if (isPublic && !v.username.trim())
+ return isPublic && isChannel ? "Публичному каналу нужен юзернейм"
+ : "Публичной группе нужен юзернейм";
+ const { ok, data } = await api("/api/groups", {
+ title: v.title.trim(), public: isPublic, is_channel: isChannel,
+ username: isPublic ? v.username.trim() : "" });
+ if (!ok) return data.error;
+ loadChats();
+ showGroupProfile(data); // сразу видна пригласительная ссылка
+ });
+ // Юзернейм есть только у публичных групп/каналов — для приватных поле скрывается.
+ const syncUsername = () =>
+ (form.elements.username.hidden = form.elements.public.value !== "1");
+ form.elements.public.onchange = syncUsername;
+ syncUsername();
+};
+$("#nav-logout").onclick = () => confirmDialog("Выйти из аккаунта?", async () => {
+ await unsubscribePush(); // снять push-подписку с этого устройства
+ await api("/api/logout", {});
+ clearMsgCache(); // не оставляем переписки на общем устройстве
+ me = null;
+ ws?.close();
+ unreadCount = 0; updateTitle();
+ show("auth");
+});
+
+/* ===== Настройки ===== */
+
+function openSettingsPage(id) {
+ document.querySelectorAll(".settings-page").forEach(p => (p.hidden = p.id !== id));
+ if (id === "page-chats") renderCacheSize(); // актуальный вес кэша сообщений
+ if (id === "page-appearance") renderWallpaperRow();
+ if (id === "page-app") { // ПК-версия: кэш, обои и автозапуск собраны здесь
+ renderCacheSize();
+ renderWallpaperRow();
+ syncAutostartToggle();
+ }
+ const page = document.getElementById(id);
+ page.classList.remove("appear");
+ void page.offsetWidth;
+ page.classList.add("appear");
+}
+
+document.querySelectorAll("[data-page]").forEach(btn =>
+ btn.addEventListener("click", () => openSettingsPage(btn.dataset.page))
+);
+
+function editNick() {
+ dialog("Изменить ник", [
+ { name: "display_name", placeholder: "Новый ник", maxlength: 50, value: me.display_name },
+ ], "Сохранить", async v => {
+ const { ok, data } = await api("/api/me/display_name", v);
+ if (!ok) return data.error;
+ me.display_name = v.display_name.trim();
+ renderProfile();
+ });
+}
+
+function editUsername() {
+ dialog("Изменить юзернейм", [
+ { name: "username", placeholder: "Новый юзернейм", maxlength: 32, value: me.username },
+ ], "Сохранить", async v => {
+ const { ok, data } = await api("/api/me/username", v);
+ if (!ok) return data.error;
+ me.username = v.username.trim();
+ renderProfile();
+ });
+}
+
+function editPassword() {
+ dialog("Изменить пароль", [
+ { name: "old_password", placeholder: "Старый пароль", type: "password" },
+ { name: "new_password", placeholder: "Новый пароль (мин. 8 символов)", type: "password" },
+ ], "Сохранить", async v => {
+ const { ok, data } = await api("/api/me/password", v);
+ if (!ok) return data.error;
+ });
+}
+
+function editDescription() {
+ dialog("Изменить описание", [
+ { name: "description", placeholder: "Расскажите о себе (до 512 символов)",
+ type: "textarea", maxlength: 512, value: me.description, optional: true },
+ ], "Сохранить", async v => {
+ const { ok, data } = await api("/api/me/description", v);
+ if (!ok) return data.error;
+ me.description = v.description.trim();
+ renderProfile();
+ });
+}
+
+$("#opt-nick").onclick = editNick;
+$("#opt-username").onclick = editUsername;
+$("#opt-password").onclick = editPassword;
+$("#opt-delete").onclick = () =>
+ dialog("Удалить аккаунт", [
+ { name: "password", placeholder: "Пароль аккаунта", type: "password" },
+ ], "Удалить навсегда", async v => {
+ const { ok, data } = await api("/api/me/delete", v);
+ if (!ok) return data.error;
+ clearMsgCache();
+ me = null;
+ ws?.close();
+ show("auth");
+ });
+
+$("#privacy-avatar").onchange = e => api("/api/me/privacy", { avatar: e.target.value });
+$("#privacy-contacts").onchange = e => api("/api/me/privacy", { contacts: e.target.value });
+$("#privacy-group-invite").onchange = e =>
+ api("/api/me/privacy", { group_invite: e.target.value });
+
+/* Чаты: медленный режим */
+$("#slow-seconds").onchange = e => {
+ const v = Math.max(0, Math.min(3600, +e.target.value || 0));
+ e.target.value = v;
+ me.slow_seconds = v;
+ api("/api/me/settings", { slow_seconds: v });
+};
+$("#slow-scope").onchange = e => {
+ me.slow_scope = e.target.value;
+ api("/api/me/settings", { slow_scope: e.target.value });
+};
+
+/* Чаты: кэш сообщений */
+$("#cache-clear").onclick = () => confirmDialog(
+ "Очистить кэш сообщений на этом устройстве?", () => {
+ clearMsgCache();
+ renderCacheSize();
+ toast("Кэш сообщений очищен");
+ });
+
+/* Оформление: цв��товая тема (хранится в cookie устройства) */
+$("#theme-select").onchange = e => {
+ setCookie("theme", e.target.value);
+ applyTheme();
+};
+
+/* Оформление: обои чата — фото или видео фоном в переписке */
+function renderWallpaper() {
+ const bg = $("#chat-bg");
+ bg.innerHTML = "";
+ bg.style.backgroundImage = "";
+ bg.classList.toggle("has-wp", !!me.wallpaper);
+ if (!me.wallpaper) return;
+ const url = "/api/me/wallpaper?v=" + (me.wallpaper.v || 0);
+ if (me.wallpaper.kind === "video") {
+ const v = document.createElement("video");
+ v.src = url;
+ v.autoplay = v.muted = v.loop = true;
+ v.playsInline = true;
+ bg.appendChild(v);
+ } else {
+ bg.style.backgroundImage = `url("${url}")`;
+ }
+}
+
+function renderWallpaperRow() { $("#wallpaper-remove").hidden = !me.wallpaper; }
+
+$("#wallpaper-upload").onclick = () => $("#wallpaper-input").click();
+$("#wallpaper-input").onchange = async e => {
+ const file = e.target.files[0];
+ e.target.value = "";
+ if (!file) return;
+ if (file.size > 25 * 1024 * 1024) return toast("Файл больше 25 МБ");
+ const fd = new FormData();
+ fd.append("wallpaper", file);
+ const res = await fetch("/api/me/wallpaper", { method: "POST", body: fd });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) return toast(data.error || "Не удалось загрузить обои");
+ me.wallpaper = { ...data.wallpaper, v: Date.now() }; // v — сброс кэша браузера
+ renderWallpaper();
+ renderWallpaperRow();
+ toast("Обои установлены");
+};
+$("#wallpaper-remove").onclick = () => confirmDialog("Удалить обои?", async () => {
+ await api("/api/me/wallpaper", undefined, "DELETE");
+ me.wallpaper = null;
+ renderWallpaper();
+ renderWallpaperRow();
+});
+
+/* Прочее: часовой пояс — «Авто» (определяется браузером) либо UTC-12..UTC+14.
+ Хранится в cookie устройства и не сбрасывается между сессиями. */
+{
+ const auto = document.createElement("option");
+ auto.value = "auto";
+ const a = autoTz();
+ auto.textContent = `Авто (UTC${a >= 0 ? "+" : ""}${a})`;
+ $("#tz-offset").appendChild(auto);
+}
+for (let tz = -12; tz <= 14; tz++) {
+ const opt = document.createElement("option");
+ opt.value = tz;
+ opt.textContent = "UTC" + (tz >= 0 ? "+" : "") + tz;
+ $("#tz-offset").appendChild(opt);
+}
+$("#tz-offset").onchange = e => {
+ setCookie("tz", e.target.value);
+ me.tz_offset = resolveTz();
+ loadChats(); // перерисовываем время в новом поясе
+};
+
+/* ===== Собственный профиль ===== */
+
+/** Большая аватарка в профиле: картинка/гиф (img), видео (video) или заглушка. */
+function renderProfileAvatar(imgId, stubId, username, hasAvatar, kind) {
+ const img = $("#" + imgId), stub = $("#" + stubId);
+ const container = img.parentElement;
+ container.querySelector("video.avatar-media")?.remove();
+ if (!hasAvatar) {
+ img.hidden = true;
+ stub.style.display = "";
+ return;
+ }
+ const url = `/api/avatar/${encodeURIComponent(username)}?v=${Date.now()}`;
+ if (kind === "video") {
+ img.hidden = true;
+ stub.style.display = "none";
+ const v = document.createElement("video");
+ v.className = "avatar-media";
+ v.src = url;
+ v.autoplay = v.muted = v.loop = true;
+ v.playsInline = true;
+ container.appendChild(v);
+ } else { // image/gif
+ img.src = url;
+ img.hidden = false;
+ stub.style.display = "none";
+ }
+}
+
+function renderProfile() {
+ setName($("#profile-name"), me.display_name, me.is_admin);
+ $("#profile-username").textContent = "@" + me.username;
+ $("#profile-desc").textContent = me.description || "Нет описания";
+ $("#profile-desc").classList.toggle("muted", !me.description);
+ renderProfileAvatar("avatar-img", "avatar-stub", me.username, me.has_avatar, me.avatar_kind);
+}
+
+$("#edit-nick").onclick = editNick;
+$("#edit-username").onclick = editUsername;
+$("#edit-desc").onclick = editDescription;
+
+/** Длительность видео в секундах (метаданные читаются браузером). */
+function videoDuration(file) {
+ return new Promise((resolve, reject) => {
+ const v = document.createElement("video");
+ v.preload = "metadata";
+ v.onloadedmetadata = () => { URL.revokeObjectURL(v.src); resolve(v.duration); };
+ v.onerror = () => { URL.revokeObjectURL(v.src); reject(new Error("video")); };
+ v.src = URL.createObjectURL(file);
+ });
+}
+
+$("#avatar-input").onchange = async e => {
+ const file = e.target.files[0];
+ e.target.value = "";
+ if (!file) return;
+ // Видео-аватарка: не длиннее 10 секунд и не больше 10 МБ (п��оверка в браузере).
+ if (file.type.startsWith("video/")) {
+ if (file.size > 10 * 1024 * 1024) return toast("Видео-аватарка больше 10 МБ");
+ const dur = await videoDuration(file).catch(() => null);
+ if (dur === null) return toast("Не удалось прочитать видео");
+ if (dur > 10.5) return toast("Видео-аватарка не длиннее 10 секунд");
+ }
+ const fd = new FormData();
+ fd.append("avatar", file);
+ const res = await fetch("/api/me/avatar", { method: "POST", body: fd });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) return toast(data.error || "Не удалось загрузить аватарку");
+ me.has_avatar = true;
+ me.avatar_kind = data.avatar_kind;
+ renderProfile();
+ loadChats(); // обновить аватарку в списке чатов
+};
+
+/* ===== Контакты (свой профиль) ===== */
+
+async function loadContacts() {
+ const { ok, data } = await api("/api/me/contacts");
+ const ul = $("#contact-list");
+ ul.innerHTML = "";
+ if (!ok || !data.length) {
+ ul.innerHTML = `Контактов пока нет`;
+ return;
+ }
+ for (const c of data) {
+ const li = document.createElement("li");
+ const name = document.createElement("span");
+ name.textContent = c.display_name; // textContent — защита от XSS
+ const uname = document.createElement("small");
+ uname.textContent = "@" + c.username;
+ const del = document.createElement("button");
+ del.className = "icon-btn";
+ del.title = "Удалить из контактов";
+ del.innerHTML = TRASH_SVG;
+ del.onclick = () => confirmDialog(`Удалить ${c.display_name} из контактов?`, async () => {
+ await api("/api/me/contacts/" + encodeURIComponent(c.username), undefined, "DELETE");
+ loadContacts();
+ });
+ li.append(name, uname, del);
+ ul.appendChild(li);
+ }
+}
+
+/** Сворачивает список контактов (по умолчанию скрыт при открытии профиля). */
+function collapseContacts() {
+ const ul = $("#contact-list");
+ if (ul) ul.hidden = true;
+ const btn = $("#contacts-toggle");
+ if (btn) { btn.classList.remove("open"); btn.setAttribute("aria-expanded", "false"); }
+}
+
+$("#contacts-toggle").onclick = () => {
+ const ul = $("#contact-list");
+ const btn = $("#contacts-toggle");
+ const show = ul.hidden;
+ ul.hidden = !show;
+ btn.classList.toggle("open", show);
+ btn.setAttribute("aria-expanded", show ? "true" : "false");
+ if (show) loadContacts(); // подгружаем только когда раскрыли
+};
+
+$("#contact-add").onclick = () =>
+ dialog("Добавить контакт", [
+ { name: "username", placeholder: "Юзернейм пользователя", maxlength: 32 },
+ ], "Добавить", async v => {
+ const { ok, data } = await api("/api/me/contacts", v);
+ if (!ok) return data.error;
+ $("#contact-list").hidden = false; // показать список после добавления
+ $("#contacts-toggle").classList.add("open");
+ $("#contacts-toggle").setAttribute("aria-expanded", "true");
+ loadContacts();
+ });
+
+/* ===== Старт: проверяем сессию ===== */
+enterApp();