для одного типа форматирования. */
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();