salgram / static /app.js
ChessVania's picture
Deploy SalGram (Docker)
484ec23 verified
Raw
History Blame Contribute Delete
154 kB
/* SalGram — фронтенд: авторизация, чаты (WebSocket), поиск, профили, настройки. */
const $ = sel => document.querySelector(sel);
let me = null; // профиль текущего пользователя (из /api/me)
let ws = null; // WebSocket-соединение
let currentPeer = null; // собеседник открытого личного чата: {id, ...профиль}
let currentGroup = null; // открытая группа: {id, title, members, is_owner, ...}
let statusTimer = null; // периодическое обновление статуса в шапке чата
let pinnedMsgs = []; // закреплённые сообщения открытого чата (по возрастанию id)
let replyTarget = null; // сообщение, на которое отвечаем в открытом чате (или null)
let editTarget = null; // сообщение, которое редактируем прямо в поле ввода (или null)
let peerReadUpto = 0; // докуда собеседник прочитал мои сообщения (id) — для галочек
let typingTimer = null; // троттлинг отправки статуса «печатает»
/* ===== Настройки устройства в cookie: тема и часовой пояс ===== */
const getCookie = name =>
document.cookie.split("; ").find(r => r.startsWith(name + "="))?.slice(name.length + 1);
const setCookie = (name, value) =>
(document.cookie = `${name}=${value}; max-age=31536000; path=/; samesite=lax`);
/* Тема применяется сразу при загрузке, ещё до входа в аккаунт */
function applyTheme() {
document.documentElement.dataset.theme = getCookie("theme") || "dark";
}
applyTheme();
/* Часовой пояс: "auto" — определяется браузером, иначе выбранное число */
const autoTz = () => -new Date().getTimezoneOffset() / 60;
function resolveTz() {
const c = getCookie("tz");
return c === undefined || c === "auto" ? autoTz() : +c;
}
const TRASH_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
const BADGE_SVG = `<svg class="badge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`;
/** Имя с галочкой администратора. */
function setName(el, name, isAdmin) {
el.textContent = name;
if (isAdmin) el.insertAdjacentHTML("beforeend", BADGE_SVG);
}
/** Всплывающее уведомление внизу экрана. */
function toast(text) {
const t = document.createElement("div");
t.className = "toast";
t.textContent = text;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3500);
}
/** Дописывает в элемент текст, делая @юзернеймы кликабельными (без очистки). */
function appendMentions(el, text) {
const re = /@([A-Za-z0-9_]{3,32})/g;
let last = 0, m;
while ((m = re.exec(text))) {
el.append(text.slice(last, m.index));
const a = document.createElement("span");
a.className = "mention";
a.textContent = m[0];
const username = m[1];
a.onclick = async e => {
e.stopPropagation();
const { ok, data } = await api("/api/users/" + encodeURIComponent(username));
if (ok) openUserProfile(data.id);
else toast(`Пользователя @${username} не существует`);
};
el.appendChild(a);
last = m.index + m[0].length;
}
el.append(text.slice(last));
}
/** Вставляет текст: ссылки кликабельны (новая вкладка), @юзернеймы открывают
* профиль, а пригласительные ссылки в группы этого сайта — группу в приложении. */
function renderMentions(el, text) {
el.textContent = "";
const re = /https?:\/\/[^\s<>"'«»]+/g;
let last = 0, m;
while ((m = re.exec(text))) {
appendMentions(el, text.slice(last, m.index));
let url = m[0];
const trail = url.match(/[.,!?;:))\]»]+$/); // пунктуация в конце — не часть ссылки
if (trail) url = url.slice(0, -trail[0].length);
const a = document.createElement("a");
a.className = "msg-link";
a.textContent = url;
a.href = url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.onclick = e => e.stopPropagation(); // не открывать профиль/меню по клику
// Ссылка-приглашение в группу на этом же сайте открывается на этой странице.
const join = url.match(/#join=([\w-]{8,64})$/);
if (join && url.startsWith(location.origin + "/")) {
a.onclick = e => {
e.preventDefault();
e.stopPropagation();
openJoinLink(join[1]);
};
}
el.appendChild(a);
if (trail) el.append(trail[0]);
last = m.index + m[0].length;
}
appendMentions(el, text.slice(last));
}
/** Запрос к API (JSON). Возвращает {ok, data}. */
async function api(path, body, method) {
const res = await fetch(path, body !== undefined ? {
method: method || "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
} : { method: method || "GET" });
return { ok: res.ok, data: await res.json().catch(() => ({})) };
}
/* ===== Время: хранится в UTC (ЧП +0), показывается в поясе из настроек ===== */
const two = n => String(n).padStart(2, "0");
const parseUTC = s => new Date(s.replace(" ", "T") + "Z");
const shifted = s => new Date(parseUTC(s).getTime() + me.tz_offset * 3600e3);
function fmtTime(s) { const d = shifted(s); return `${two(d.getUTCHours())}:${two(d.getUTCMinutes())}`; }
function fmtDate(s) { const d = shifted(s); return `${two(d.getUTCDate())}.${two(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`; }
/** Время, если сообщение сегодняшнее, иначе дата + время. */
function fmtSmart(s) {
const today = new Date(Date.now() + me.tz_offset * 3600e3).toISOString().slice(0, 10);
return shifted(s).toISOString().slice(0, 10) === today ? fmtTime(s) : `${fmtDate(s)} ${fmtTime(s)}`;
}
/** Статус: в сети / был(а) N назад / дата / аккаунт удалён / заблокирован. */
function fmtStatus(u) {
if (u.deleted) return "аккаунт удалён";
if (u.is_system) return "автоматизированный аккаунт";
if (u.banned) return "аккаунт заблокирован";
if (u.online) return "в сети";
if (!u.last_seen) return ""; // скрыто (например, для заблокированных)
const diff = (Date.now() - parseUTC(u.last_seen).getTime()) / 1000;
if (diff < 60) return "был(а) только что";
if (diff < 3600) return `был(а) ${Math.floor(diff / 60)} мин назад`;
if (diff < 86400) return `был(а) ${Math.floor(diff / 3600)} ч назад`;
return `был(а) ${fmtDate(u.last_seen)} в ${fmtTime(u.last_seen)}`;
}
/* ===== Экраны и виды ===== */
function show(screen) {
$("#auth").hidden = screen !== "auth";
$("#app").hidden = screen !== "app";
}
/** Открывает вид справа: "empty" | "chat" | "user" | "group" | "settings" | "profile". */
function openView(name) {
for (const v of ["empty", "chat", "user", "group", "settings", "profile"])
$("#view-" + v).hidden = v !== name;
// На телефоне видна одна панель: список чатов (empty) либо открытый вид.
$("#app").classList.toggle("show-main", name !== "empty");
$("#nav-settings").classList.toggle("active", name === "settings");
$("#nav-profile").classList.toggle("active", name === "profile");
if (name !== "chat") { clearInterval(statusTimer); currentPeer = null; currentGroup = null; resetTyping(); }
if (name === "settings") openSettingsPage("page-root");
if (name === "profile") collapseContacts(); // список контактов по умолчанию скрыт
}
/** Загружает профиль, подключает WebSocket и входит в приложение. */
async function enterApp() {
const { ok, data } = await api("/api/me");
if (!ok) return show("auth");
me = data;
// Часовой пояс — из cookie устройства (по умолчанию автоопределение).
me.tz_offset = resolveTz();
if (getCookie("tz") === undefined) setCookie("tz", "auto");
renderProfile();
$("#privacy-avatar").value = me.privacy.avatar;
$("#privacy-contacts").value = me.privacy.contacts;
$("#privacy-group-invite").value = me.privacy.group_invite;
$("#slow-seconds").value = me.slow_seconds;
$("#slow-scope").value = me.slow_scope;
$("#tz-offset").value = getCookie("tz") || "auto";
$("#theme-select").value = getCookie("theme") || "dark";
// Забаненный аккаунт: только чтение — вместо поиска красная надпись,
// настройки и профиль недоступны.
$("#search-input").hidden = !!me.banned;
$("#banned-banner").hidden = !me.banned;
$("#nav-settings").hidden = !!me.banned;
$("#nav-profile").hidden = !!me.banned;
$("#group-create").hidden = !!me.banned;
show("app");
openView("empty");
renderWallpaper();
connectWS();
initNotifications();
loadChats();
handleJoinHash(); // вступление по пригласительной ссылке /#join=<hash>
}
/* ===== WebSocket ===== */
function connectWS() {
ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws");
ws.onmessage = e => handleWS(JSON.parse(e.data));
// Пинг каждые 25 с: прокси хостингов закрывают неактивные WS-соединения.
const ping = setInterval(() => {
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" }));
}, 25000);
ws.onclose = () => {
clearInterval(ping);
if (me) setTimeout(connectWS, 2000); // авто-переподключение
};
}
/* ===== Уведомления о сообщениях =====
Открытое приложение уведомляет само (по WebSocket); когда оно закрыто —
уведомления приходят через Web Push (service worker). Сервер шлёт push только
офлайн-получателям, поэтому дублей нет. В EXE используется нативный мост. */
let swReg = null; // регистрация service worker (PWA + push)
let unreadCount = 0; // непрочитанные — для счётчика в заголовке вкладки
const notifyOn = () => localStorage.getItem("notify") !== "0";
const hasBridge = () => !!window.pywebview?.api?.notify; // десктоп-приложение (EXE)
/* ===== ПК-приложение vs веб-версия =====
В ПК-приложении страница открыта внутри pywebview, поэтому существует объект
window.pywebview (в браузере и PWA его нет). Только там показываем раздел
«Приложение», куда переносим оформление, кэш и уведомления, а «Оформление»
как отдельную кнопку прячем. window.pywebview может появиться чуть позже
загрузки страницы — поэтому applyDesktopUI() вызывается и по событию
pywebviewready. */
const isDesktopApp = () => !!window.pywebview;
function applyDesktopUI() {
if (!isDesktopApp()) return;
if (document.body.classList.contains("is-desktop")) return; // переносим один раз
document.body.classList.add("is-desktop");
const page = $("#page-app");
const heading = text => {
const h = document.createElement("h3");
h.className = "settings-sub";
h.textContent = text;
page.appendChild(h);
};
// append() перемещает существующие узлы — обработчики и id сохраняются.
heading("Оформление");
page.append($("#row-theme"), $("#row-wallpaper"), $("#hint-wallpaper"), $("#wallpaper-input"));
heading("Кэш");
page.append($("#row-cache"), $("#hint-cache"));
heading("Уведомления");
page.append($("#row-notify"), $("#notify-hint"));
// Автозапуск (статичные узлы page-app) переносим в конец, после остальных секций.
page.append($("#sub-autostart"), $("#row-autostart"), $("#hint-autostart"));
// Кнопку «Оформление» прячем — её содержимое теперь в «Приложении».
$("#root-appearance").hidden = true;
$("#root-app").hidden = false;
syncAutostartToggle();
}
/** Подтягивает состояние автозапуска из нативного моста в чекбокс. */
async function syncAutostartToggle() {
const cb = $("#autostart-toggle");
if (!cb) return;
if (!window.pywebview?.api?.get_autostart) { cb.disabled = true; return; }
cb.disabled = false; // мост готов — переключатель активен
try { cb.checked = !!(await window.pywebview.api.get_autostart()); }
catch { /* мост недоступен — оставляем как есть */ }
}
window.addEventListener("pywebviewready", () => {
applyDesktopUI(); // идемпотентно: перенос секций выполнится один раз
syncAutostartToggle(); // мост уже готов — подтягиваем актуальное состояние
});
document.addEventListener("DOMContentLoaded", () => {
applyDesktopUI(); // на случай, если мост готов ещё до загрузки DOM
const cb = $("#autostart-toggle");
if (cb) cb.onchange = async e => {
const on = e.target.checked;
try {
const ok = await window.pywebview?.api?.set_autostart(on);
if (ok === false) throw new Error();
toast(on ? "Автозапуск включён" : "Автозапуск выключен");
} catch {
e.target.checked = !on; // откатываем переключатель, если не вышло
toast("Не удалось изменить автозапуск");
}
};
});
function updateTitle() {
document.title = unreadCount > 0 ? `(${unreadCount}) SalGram` : "SalGram";
}
function clearUnread() { if (unreadCount) { unreadCount = 0; updateTitle(); } }
window.addEventListener("focus", () => { clearUnread(); sendRead(); });
document.addEventListener("visibilitychange", () => {
if (!document.hidden) { clearUnread(); sendRead(); }
});
/** Показывает уведомление: нативный тост (EXE) либо Web Notification (браузер). */
function showNotification(title, body, m) {
if (hasBridge()) { try { window.pywebview.api.notify(title, body); } catch { /* ignore */ } return; }
if (typeof Notification === "undefined" || Notification.permission !== "granted") return;
try {
const n = new Notification(title, {
body, icon: "/icon-192.png", badge: "/icon-192.png",
tag: m.group ? "grp-" + m.group : "dm-" + m.from,
});
n.onclick = () => {
window.focus();
if (m.group) openGroup(m.group);
else openChat(m.from === me.id ? m.to : m.from);
n.close();
};
} catch { /* ignore */ }
}
/** Решает, нужно ли уведомить о пришедшем сообщении, и обновляет счётчик. */
function maybeNotify(m) {
if (!me || m.from === me.id) return;
const visibleHere = !document.hidden && (m.group
? currentGroup?.id === m.group
: currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id));
if (visibleHere) return; // пользователь и так видит это сообщение
unreadCount++;
updateTitle();
if (!notifyOn()) return;
const title = m.group ? `${m.from_name || "Сообщение"} • группа` : (m.from_name || "Новое сообщение");
showNotification(title, commentShort(m), m);
}
function urlBase64ToUint8Array(b64) {
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + pad).replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
}
/** Подписывает браузер на Web Push (для уведомлений при закрытом приложении). */
async function subscribePush() {
if (!swReg || typeof Notification === "undefined" || Notification.permission !== "granted") return;
try {
const { ok, data } = await api("/api/push/key");
if (!ok || !data.enabled || !data.key) return;
let sub = await swReg.pushManager.getSubscription();
if (!sub) sub = await swReg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(data.key),
});
await api("/api/push/subscribe", sub.toJSON());
} catch { /* push недоступен — остаются уведомления при открытом приложении */ }
}
async function unsubscribePush() {
try {
const sub = await swReg?.pushManager.getSubscription();
if (sub) { await api("/api/push/unsubscribe", { endpoint: sub.endpoint }); await sub.unsubscribe(); }
} catch { /* ignore */ }
}
/** Приводит чекбокс настроек в соответствие текущему состоянию разрешений. */
function syncNotifyToggle() {
const cb = $("#notify-toggle");
if (!cb) return;
const supported = hasBridge() || typeof Notification !== "undefined";
cb.disabled = !supported;
cb.checked = supported && notifyOn() &&
(hasBridge() || Notification.permission === "granted");
}
async function initNotifications() {
if ("serviceWorker" in navigator) {
try { swReg = await navigator.serviceWorker.register("/sw.js"); } catch { /* ignore */ }
navigator.serviceWorker.addEventListener("message", e => {
const d = e.data; // клик по фоновому push-уведомлению
if (d?.type === "notification-click" && me) {
if (d.data?.group) openGroup(d.data.group);
else if (d.data?.peer) openChat(d.data.peer);
}
});
}
// Уведомления включены по умолчанию: при первом заходе сами спрашиваем разрешение
// браузера/PWA/TWA. Без этого showNotification молча ничего не показывает
// (permission остаётся "default"), и кажется, что уведомления не приходят.
// Десктоп-обёртку (pywebview) пропускаем — там нативный мост, а не Web Notification
// (window.pywebview может появиться чуть позже моста, поэтому проверяем и его).
if (notifyOn() && !hasBridge() && !window.pywebview
&& typeof Notification !== "undefined"
&& Notification.permission === "default") {
try { await Notification.requestPermission(); } catch { /* ignore */ }
}
syncNotifyToggle();
if (notifyOn() && !hasBridge()
&& typeof Notification !== "undefined" && Notification.permission === "granted")
subscribePush();
}
document.addEventListener("DOMContentLoaded", () => {
const cb = $("#notify-toggle");
if (!cb) return;
cb.onchange = async e => {
if (e.target.checked) {
let perm = "granted";
if (!hasBridge() && typeof Notification !== "undefined")
perm = await Notification.requestPermission();
if (perm !== "granted") {
e.target.checked = false;
return toast("Уведомления запрещены в настройках браузера");
}
localStorage.setItem("notify", "1");
subscribePush();
toast("Уведомления включены");
} else {
localStorage.setItem("notify", "0");
unsubscribePush();
toast("Уведомления выключены");
}
};
});
function handleWS(msg) {
switch (msg.type) {
case "message": {
const m = msg.message;
cacheAppend(m);
maybeNotify(m);
const inOpen = m.group
? currentGroup?.id === m.group
: currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id);
if (inOpen) {
appendMessage(m);
// Входящее в открытой личной переписке — сразу отмечаем прочитанным.
if (!m.group && m.from !== me.id) { delete typingState[m.from]; renderTypingStatus(); sendRead(); }
}
if (m.thread_root) { // комментарий: дописать в открытый тред и обновить счётчик
onCommentMessage(m);
bumpCommentCount(m.thread_root);
}
loadChats(); // обновляем превью в списке чатов
break;
}
case "typing": {
const open = msg.group ? currentGroup?.id === msg.group
: currentPeer && currentPeer.id === msg.from;
if (open && msg.from !== me.id) noteTyping(msg.from, msg.from_name);
break;
}
case "read":
if (!currentGroup && currentPeer && msg.with === currentPeer.id) {
peerReadUpto = Math.max(peerReadUpto, msg.upto || 0);
updateTicks();
}
break;
case "history":
if (msg.thread) { renderCommentHistory(msg); break; } // тред комментариев
// Свежая история с сервера — источник истины, перезаписываем кэш.
cacheSave(msg.group ? "g" + msg.group : msg.with,
{ messages: msg.messages, pinned: msg.pinned || [] });
if (msg.group ? currentGroup?.id === msg.group
: currentPeer && msg.with === currentPeer.id) {
if (!msg.group) peerReadUpto = msg.peer_read || 0; // докуда собеседник прочитал
$("#messages").innerHTML = "";
msg.messages.forEach(appendMessage);
scrollMessages();
pinnedMsgs = msg.pinned || [];
renderPinnedBar();
if (!msg.group) sendRead(); // отметить прочитанным то, что пришло
}
break;
case "message_edited": {
cacheUpdate(msg.message);
const div = $(`#messages [data-id="${msg.message.id}"]`);
if (div) fillMessage(div, msg.message);
pinnedMsgs = pinnedMsgs.map(p => (p.id === msg.message.id ? msg.message : p));
renderPinnedBar();
loadChats();
break;
}
case "message_deleted":
cacheDelete(cachePeer(msg), msg.id);
$(`#messages [data-id="${msg.id}"]`)?.remove();
pinnedMsgs = pinnedMsgs.filter(p => p.id !== msg.id);
renderPinnedBar();
loadChats();
break;
case "message_pinned": {
const m = msg.message;
cacheUpdate(m);
const div = $(`#messages [data-id="${m.id}"]`);
if (div) fillMessage(div, m); // обновить значок 📌 в пузыре
const inOpen = m.group
? currentGroup?.id === m.group
: currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id);
if (inOpen) {
pinnedMsgs = pinnedMsgs.filter(p => p.id !== m.id);
if (m.extra?.pinned) {
pinnedMsgs.push(m);
pinnedMsgs.sort((a, b) => a.id - b.id);
}
renderPinnedBar();
}
break;
}
case "reaction": {
cacheReaction(cachePeer(msg), msg.id, msg.reactions);
const div = $(`#messages [data-id="${msg.id}"]`);
if (div) {
div._m.reactions = msg.reactions;
renderReactions(div, div._m);
}
break;
}
case "chat_deleted":
cacheDrop(msg.with); // переписка удалена — чистим её кэш
if (currentPeer?.id === msg.with) openView("empty");
loadChats();
break;
case "group_removed": // вышли из группы либо её удалили
cacheDrop("g" + msg.group);
if (currentGroup?.id === msg.group) openView("empty");
loadChats();
break;
case "group_added": // нас добавили в группу — она появляется в списке чатов
loadChats();
break;
case "moderation": // мут/кик/бан/назначение — уведомление и обновление состояния
toast(msg.text);
if (currentGroup?.id === msg.group) refreshGroupHead();
loadChats();
break;
case "error":
showChatError(msg.error);
break;
}
}
function showChatError(text) {
const el = $("#chat-error");
el.textContent = text;
el.hidden = false;
clearTimeout(el._t);
el._t = setTimeout(() => (el.hidden = true), 4000);
}
/* ===== Список чатов ===== */
/** Кружок-аватарка: картинка/гиф (img), видео (video) либо первая буква имени. */
function avaNode(peerId, username, name, kind) {
const div = document.createElement("div");
div.className = "ava";
div.textContent = (name || "?")[0].toUpperCase();
if (username) {
const url = "/api/avatar/" + encodeURIComponent(username);
if (kind === "video") {
const v = document.createElement("video");
v.src = url;
v.autoplay = v.muted = v.loop = true;
v.playsInline = true;
v.onerror = () => v.remove(); // нет аватарки или скрыта приватностью
div.appendChild(v);
} else { // image/gif — гиф анимируется в <img> сам
const img = document.createElement("img");
img.src = url;
img.onerror = () => img.remove();
div.appendChild(img);
}
}
return div;
}
async function loadChats() {
const { ok, data } = await api("/api/chats");
if (!ok) return;
const list = $("#chat-list");
list.innerHTML = "";
if (!data.length) {
list.innerHTML = `<p class="empty">Чатов пока нет.<br>Найдите собеседника через поиск.</p>`;
return;
}
for (const c of data) {
const row = document.createElement("button");
const info = document.createElement("div");
info.className = "chat-row-info";
const name = document.createElement("span");
const preview = document.createElement("small");
if (c.group_id) { // групповой чат
row.className = "chat-row" + (currentGroup?.id === c.group_id ? " active" : "");
row.appendChild(groupAvaNode(c.group_id, c.group_title));
name.textContent = c.group_title;
preview.textContent = !c.kind ? "Группа создана"
: (c.from_me ? "Вы: " : c.sender_name ? c.sender_name + ": " : "")
+ (c.kind === "photo" ? "Фото" : c.kind === "gif" ? "GIF"
: c.kind === "call" ? "Видеозвонок"
: c.kind === "audio" ? (c.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио")
: c.kind === "file" ? (c.extra?.name || "Файл") : c.content);
row.onclick = () => openGroup(c.group_id);
row.oncontextmenu = e => openGroupRowMenu(e, c.group_id);
} else {
row.className = "chat-row" + (currentPeer?.id === c.peer_id ? " active" : "");
row.appendChild(avaNode(c.peer_id, c.peer_deleted ? null : c.peer_username,
c.peer_name, c.peer_avatar_kind));
name.textContent = c.peer_deleted ? "Удалённый аккаунт" : c.peer_name;
preview.textContent = (c.from_me ? "Вы: " : "")
+ (c.kind === "photo" ? "Фото" : c.kind === "gif" ? "GIF"
: c.kind === "call" ? "Видеозвонок"
: c.kind === "audio" ? (c.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио")
: c.kind === "file" ? (c.extra?.name || "Файл") : c.content);
row.onclick = () => openChat(c.peer_id);
row.oncontextmenu = e => openChatMenu(e, c.peer_id); // ПКМ — удалить переписку
}
info.append(name, preview);
const time = document.createElement("time");
time.textContent = fmtSmart(c.created_at);
row.append(info, time);
list.appendChild(row);
}
}
/* ===== Кэш сообщений (localStorage) =====
Открытый ранее чат показывается мгновенно из кэша, затем содержимое
заменяется свежей историей с сервера. Кэш синхронизируется с событиями
WS: правки, удаления, реакции и закрепления применяются и к кэшу,
даже если чат сейчас не открыт. Размер и очистка — в настройках «Чаты». */
const CACHE_LIMIT = 300; // храним не больше N последних сообщений на чат
const cacheKey = peerId => `msgCache:${me.id}:${peerId}`;
const cacheKeys = () => Object.keys(localStorage).filter(k => k.startsWith(`msgCache:${me.id}:`));
// Ключ чата для сообщения: группа — "g<id>", личный — id собеседника.
const cachePeer = m => (m.group ? "g" + m.group : m.from === me.id ? m.to : m.from);
function cacheLoad(peerId) {
try { return JSON.parse(localStorage.getItem(cacheKey(peerId))); }
catch { return null; }
}
function cacheSave(peerId, data) {
data.messages = data.messages.slice(-CACHE_LIMIT);
try { localStorage.setItem(cacheKey(peerId), JSON.stringify(data)); }
catch { localStorage.removeItem(cacheKey(peerId)); } // хранилище переполнено
}
/** Новое сообщение: дописываем в существующий кэш (новый создаст история). */
function cacheAppend(m) {
const peerId = cachePeer(m);
const data = cacheLoad(peerId);
if (!data) return;
if (!data.messages.some(x => x.id === m.id)) data.messages.push(m);
cacheSave(peerId, data);
}
/** Правка или закрепление: заменяем сообщение по id, обновляем закреплённые. */
function cacheUpdate(m) {
const peerId = cachePeer(m);
const data = cacheLoad(peerId);
if (!data) return;
data.messages = data.messages.map(x => (x.id === m.id ? m : x));
data.pinned = (data.pinned || []).filter(p => p.id !== m.id);
if (m.extra?.pinned) {
data.pinned.push(m);
data.pinned.sort((a, b) => a.id - b.id);
}
cacheSave(peerId, data);
}
/** Удалённое сообщение пропадает и из кэша. */
function cacheDelete(peerId, id) {
const data = cacheLoad(peerId);
if (!data) return;
data.messages = data.messages.filter(x => x.id !== id);
data.pinned = (data.pinned || []).filter(p => p.id !== id);
cacheSave(peerId, data);
}
function cacheReaction(peerId, id, reactions) {
const data = cacheLoad(peerId);
if (!data) return;
for (const list of [data.messages, data.pinned || []])
for (const m of list) if (m.id === id) m.reactions = reactions;
cacheSave(peerId, data);
}
function cacheDrop(peerId) { localStorage.removeItem(cacheKey(peerId)); }
function clearMsgCache() { cacheKeys().forEach(k => localStorage.removeItem(k)); }
/** Вес кэша в байтах (строки localStorage — UTF-16, 2 байта на символ). */
function msgCacheSize() {
let chars = 0;
for (const k of cacheKeys()) chars += k.length + (localStorage.getItem(k) || "").length;
return chars * 2;
}
function renderCacheSize() {
const n = msgCacheSize();
$("#cache-size").textContent = n ? fmtSize(n) : "пусто";
$("#cache-clear").hidden = !n;
}
/* ===== Чат ===== */
async function openChat(peerId) {
openView("chat");
currentPeer = { id: peerId };
currentGroup = null;
peerReadUpto = 0;
resetTyping();
clearReply();
clearEdit();
$("#messages").innerHTML = "";
// Чат открывается мгновенно из кэша; ответ "history" заменит его свежим.
const cached = cacheLoad(peerId);
if (cached) {
cached.messages.forEach(appendMessage);
scrollMessages();
}
pinnedMsgs = cached?.pinned || [];
renderPinnedBar();
await refreshChatHead();
ws?.send(JSON.stringify({ type: "history", with: peerId }));
clearInterval(statusTimer);
statusTimer = setInterval(refreshChatHead, 30000); // статус обновляется каждые 30 с
loadChats();
}
/** Обновляет шапку чата (имя, статус) и доступность поля ввода. */
async function refreshChatHead() {
if (!currentPeer) return;
const { ok, data } = await api("/api/peer/" + currentPeer.id);
if (!ok) return;
currentPeer = data;
setName($("#chat-name"), data.deleted ? "Удалённый аккаунт" : data.display_name, data.is_admin);
if (!Object.keys(typingState).length) $("#chat-status").textContent = fmtStatus(data);
$("#chat-ava").replaceWith(Object.assign(
avaNode(data.id, data.username, data.display_name, data.avatar_kind), { id: "chat-ava" }));
// Поле ввода заменяется на причину, по которой писать нельзя.
const reason = me.banned ? "Ваш аккаунт заблокирован"
: data.deleted ? "Аккаунт удалён"
: data.banned ? "Аккаунт заблокирован"
: data.blocked_me ? "Пользователь заблокировал вас"
: data.i_blocked ? "Вы заблокировали пользователя"
: (data.is_system && data.username === "reports") ? "Автоматизированный аккаунт"
// В @salgram пишет только админ; остальным он шлёт рассылки и уведомления.
: (data.is_system && data.username === "salgram" && !me.is_admin)
? "Официальный аккаунт SalGram" : "";
// В чате с ботом @addgif вместо поля ввода — кнопка добавления GIF в библиотеку.
const isAddgif = data.is_system && data.username === "addgif";
$("#addgif-bar").hidden = !isAddgif || !!me.banned;
$("#channel-bar").hidden = true; // панель подписчика канала — только в канале
$("#msg-form").hidden = !!reason || isAddgif;
$("#chat-blocked").hidden = !reason;
$("#chat-blocked").textContent = reason;
$("#chat-delete").hidden = !!me.banned; // в режиме «только чтение» удалять нельзя
}
function scrollMessages() {
const box = $("#messages");
box.scrollTop = box.scrollHeight;
}
/* ===== Группы ===== */
/** Кружок-аватарка группы: картинка либо первая буква названия. */
function groupAvaNode(gid, title) {
const div = document.createElement("div");
div.className = "ava";
div.textContent = (title || "?")[0].toUpperCase();
const img = document.createElement("img");
img.src = "/api/group_avatar/" + gid;
img.onerror = () => img.remove(); // аватарки нет — остаётся буква
div.appendChild(img);
return div;
}
/** «5 участников» с правильным склонением. */
function fmtMembers(n) {
const d = n % 10, h = n % 100;
const word = d === 1 && h !== 11 ? "участник"
: d >= 2 && d <= 4 && (h < 12 || h > 14) ? "участника" : "участников";
return `${n} ${word}`;
}
/** «5 подписчиков» с правильным склонением (для каналов). */
function fmtSubscribers(n) {
const d = n % 10, h = n % 100;
const word = d === 1 && h !== 11 ? "подписчик"
: d >= 2 && d <= 4 && (h < 12 || h > 14) ? "подписчика" : "подписчиков";
return `${n} ${word}`;
}
/** Сводка участников/подписчиков по типу (группа или канал). */
const fmtAudience = g => g.is_channel ? fmtSubscribers(g.members) : fmtMembers(g.members);
async function openGroup(gid) {
openView("chat");
currentPeer = null;
currentGroup = { id: gid };
peerReadUpto = 0;
resetTyping();
clearReply();
clearEdit();
$("#messages").innerHTML = "";
// Как и личные чаты, группа открывается мгновенно из кэша.
const cached = cacheLoad("g" + gid);
if (cached) {
cached.messages.forEach(appendMessage);
scrollMessages();
}
pinnedMsgs = cached?.pinned || [];
renderPinnedBar();
await refreshGroupHead();
ws?.send(JSON.stringify({ type: "history", group: gid }));
clearInterval(statusTimer);
statusTimer = setInterval(refreshGroupHead, 30000); // число участников в шапке
loadChats();
}
/** Шапка группового чата: название, число участников, доступность ввода. */
async function refreshGroupHead() {
if (!currentGroup) return;
const { ok, data } = await api("/api/groups/" + currentGroup.id);
if (!ok) return;
currentGroup = data;
$("#chat-name").textContent = data.title;
if (!Object.keys(typingState).length) $("#chat-status").textContent = fmtAudience(data);
$("#chat-ava").replaceWith(Object.assign(
groupAvaNode(data.id, data.title), { id: "chat-ava" }));
// В канале публикует только владелец/админ; подписчики читают и комментируют.
const canPost = !data.is_channel || data.is_owner || me.is_admin;
$("#addgif-bar").hidden = true; // панель бота — только в личном чате с @addgif
// Подписчику канала вместо поля ввода — кнопки «Открыть чат» и «Отписаться».
const subscriberBar = data.is_channel && data.is_member && !canPost && !me.banned;
$("#channel-bar").hidden = !subscriberBar;
$("#channel-open-chat").hidden = !data.linked_group_id; // нет обсуждения — нет кнопки
$("#channel-open-chat").onclick = () => openComments(data.id);
$("#channel-unsub").onclick = () => leaveGroup(data.id, true);
$("#msg-form").hidden = !!me.banned || !data.is_member || !canPost || !!data.my_muted;
// Текст «недоступно» — только для мута/бана; у подписчика канала вместо него кнопки.
const reason = me.banned ? "Ваш аккаунт заблокирован"
: data.my_muted ? "Вы не можете писать в этой группе (мут)" : "";
$("#chat-blocked").hidden = !reason;
$("#chat-blocked").textContent = reason;
const del = $("#chat-delete");
del.hidden = !!me.banned;
del.title = data.is_owner ? (data.is_channel ? "Удалить канал" : "Удалить группу")
: (data.is_channel ? "Отписаться" : "Выйти из группы");
}
async function openGroupProfile(gid) {
const { ok, data } = await api("/api/groups/" + gid);
if (!ok) return toast(data.error || "Группа не найдена");
showGroupProfile(data);
}
/** invite — хэш ссылки, по которой пришёл не-участник (вступление идёт через неё). */
function showGroupProfile(g, invite) {
openView("group");
const ava = $("#group-ava");
ava.textContent = (g.title || "?")[0].toUpperCase(); // сбрасывает и старую картинку
if (g.has_avatar) {
const img = document.createElement("img");
img.src = `/api/group_avatar/${g.id}?v=${Date.now()}`;
img.onerror = () => img.remove();
ava.appendChild(img);
}
// Владелец меняет аватарку кликом по кружку.
const canEditAva = g.is_owner && !me.banned;
ava.classList.toggle("editable", canEditAva);
ava.title = canEditAva ? "Изменить аватарку" : "";
ava.onclick = canEditAva ? () => $("#group-avatar-input").click() : null;
$("#group-avatar-input").onchange = async e => {
const file = e.target.files[0];
e.target.value = "";
if (!file) return;
const fd = new FormData();
fd.append("avatar", file);
const res = await fetch(`/api/groups/${g.id}/avatar`, { method: "POST", body: fd });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return toast(data.error || "Не удалось загрузить аватарку");
}
openGroupProfile(g.id); // перерисовать профиль с новой аватаркой
loadChats(); // и список чатов
};
$("#group-title").textContent = g.title;
const kindWord = g.is_channel ? "канал" : "группа";
const pubWord = g.public ? (g.is_channel ? "Публичный" : "Публичная")
: (g.is_channel ? "Приватный" : "Приватная");
$("#group-meta").textContent = `${pubWord} ${kindWord}`
+ (g.username ? ` · @${g.username}` : "") + ` · ${fmtAudience(g)}`;
$("#group-open").hidden = !g.is_member;
$("#group-open").onclick = () => openGroup(g.id);
$("#group-join").hidden = g.is_member || !!me.banned;
$("#group-join").textContent = g.is_channel ? "Подписаться" : "Вступить";
$("#group-join").onclick = async () => {
const { ok, data } = await api(
invite ? "/api/groups/join/" + invite : `/api/groups/${g.id}/join`, {});
if (!ok) return toast(data.error || "Не удалось вступить");
toast(g.is_channel ? `Вы подписались на «${g.title}»` : `Вы вступили в группу «${g.title}»`);
openGroup(g.id);
loadChats();
};
// Пригласительная ссылка — только участникам (сервер не отдаёт хэш чужим).
const inviteRow = $("#group-invite-row");
inviteRow.hidden = !g.invite_hash;
if (g.invite_hash) {
const link = location.origin + "/#join=" + g.invite_hash;
$("#group-invite").textContent = link;
$("#group-invite-copy").onclick = async () => {
try { await navigator.clipboard.writeText(link); toast("Ссылка скопирована"); }
catch { toast(link); } // буфер недоступен — показываем ссылку
};
}
const sec = $("#group-members-sec");
sec.hidden = !g.member_list;
$("#group-members-title").textContent = g.is_channel ? "Подписчики" : "Участники";
// В группе добавить участника может любой участник; в канал подписчиков добавляет
// только владелец/админ. В режиме «только чтение» (бан) — нельзя.
const addBtn = $("#group-add-member");
addBtn.hidden = !g.is_member || !!me.banned
|| (g.is_channel && !g.is_owner && !me.is_admin);
addBtn.title = g.is_channel ? "Добавить подписчика" : "Добавить участника";
addBtn.onclick = () => openAddMemberDialog(g.id);
if (g.member_list) {
const ul = $("#group-member-list");
ul.innerHTML = "";
for (const u of g.member_list) {
const li = document.createElement("li");
const name = document.createElement("span");
name.textContent = u.display_name;
const role = u.is_owner ? " · владелец" : u.is_admin ? " · админ" : "";
const uname = document.createElement("small");
uname.textContent = "@" + u.username + role + (u.muted ? " · мут" : "");
li.append(name, uname);
// Кнопка действий модерации: для модераторов, кроме владельца и себя;
// админа может модерировать только владелец/глобальный админ.
const canActHere = g.can_moderate && !u.is_owner && u.id !== me.id
&& (!u.is_admin || g.is_owner || me.is_admin) && !me.banned;
if (canActHere) {
const act = document.createElement("button");
act.className = "icon-btn member-actions";
act.textContent = "⋮";
act.title = "Действия модерации";
act.onclick = e => { e.stopPropagation(); openMemberMenu(e, g, u); };
li.appendChild(act);
}
li.style.cursor = "pointer";
li.onclick = () => openUserProfile(u.id);
ul.appendChild(li);
}
}
renderChannelExtra(g); // комментарии канала: открыть / привязать / отвязать группу
renderModeration(g); // настройки модерации (для владельца/админов)
$("#group-leave").hidden = !g.is_member || g.is_owner || !!me.banned;
$("#group-leave").textContent = g.is_channel ? "Отписаться" : "Выйти из группы";
$("#group-leave").onclick = () => leaveGroup(g.id, g.is_channel);
$("#group-delete").hidden = !(g.is_owner || me.is_admin) || !!me.banned;
$("#group-delete").textContent = g.is_channel ? "Удалить канал" : "Удалить группу";
$("#group-delete").onclick = () => deleteGroup(g.id);
}
/** Блок комментариев канала в его профиле: переход к комментариям и (для владельца)
* привязка/отвязка группы для комментариев. */
function renderChannelExtra(g) {
const box = $("#group-channel-extra");
box.innerHTML = "";
if (!g.is_channel) return;
if (g.linked_group_id && g.is_member) {
const open = document.createElement("button");
open.className = "primary write-btn";
open.textContent = "Комментарии";
open.onclick = () => openComments(g.id);
box.appendChild(open);
}
if ((g.is_owner || me.is_admin) && !me.banned) {
const ctl = document.createElement("button");
ctl.className = "link";
if (g.linked_group_id) {
ctl.textContent = `Отвязать комментарии (${g.linked_group_title || "группа"})`;
ctl.onclick = () => confirmDialog("Отвязать группу комментариев?", async () => {
const { ok, data } = await api(`/api/channels/${g.id}/unlink`, {});
if (!ok) return toast(data.error || "Ошибка");
openGroupProfile(g.id);
});
} else {
ctl.textContent = "Привязать группу для комментариев";
ctl.onclick = () => linkCommentsDialog(g.id);
}
box.appendChild(ctl);
}
}
/** Открытие комментариев канала: вступаем в привязанную группу и открываем её чат. */
async function openComments(cid) {
const { ok, data } = await api(`/api/channels/${cid}/comments`, {});
if (!ok) return toast(data.error || "Комментарии недоступны");
openGroup(data.id);
loadChats();
}
/* ===== Окно комментариев (тред под постом канала, как в Telegram) ===== */
let commentThread = null; // { cid, discGid, rootId, replyTo, byId, listEl, replyBar, input }
const commentShort = m => m.kind === "text" ? m.content
: m.kind === "photo" ? "Фото" : m.kind === "gif" ? "GIF"
: m.kind === "audio" ? (m.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио")
: m.kind === "file" ? (m.extra?.name || "Файл") : "сообщение";
/** Тело комментария: текст/фото/гиф/файл (как в обычном сообщении). */
function renderCommentBody(div, m) {
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");
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 === "file") {
renderFile(div, m);
} else {
const p = document.createElement("p");
renderMentions(p, m.content);
div.appendChild(p);
}
}
/** Узел комментария в треде. isRoot — корневой пост сверху. */
function renderCommentNode(m, isRoot) {
const div = document.createElement("div");
div.className = "comment" + (isRoot ? " root" : (m.from === me.id ? " mine" : ""));
div.dataset.id = m.id;
div._m = m;
if (m.from_name && !isRoot) {
const a = document.createElement("span");
a.className = "msg-author";
a.textContent = m.from_name;
a.onclick = e => { e.stopPropagation(); openUserProfile(m.from); };
div.appendChild(a);
}
// Цитата родителя (ответ на другой комментарий, а не на сам пост).
if (!isRoot && m.reply_to && m.reply_to !== commentThread?.rootId) {
const parent = commentThread?.byId.get(m.reply_to);
const q = document.createElement("div");
q.className = "reply-quote";
q.textContent = parent ? commentShort(parent).slice(0, 80) : "комментарий";
div.appendChild(q);
}
renderCommentBody(div, m);
const time = document.createElement("time");
time.textContent = fmtSmart(m.created_at);
div.appendChild(time);
if (!isRoot && !me.banned) {
const r = document.createElement("button");
r.className = "link comment-reply";
r.textContent = "Ответить";
r.onclick = e => { e.stopPropagation(); setCommentReply(m); };
div.appendChild(r);
}
return div;
}
function setCommentReply(m) {
if (!commentThread) return;
commentThread.replyTo = m.id;
commentThread.replyBar.hidden = false;
commentThread.replyBar.querySelector("span").textContent = "В ответ: " + commentShort(m).slice(0, 60);
commentThread.input.focus();
}
function clearCommentReply() {
if (!commentThread) return;
commentThread.replyTo = commentThread.rootId;
commentThread.replyBar.hidden = true;
}
/** Открыть окно комментариев под постом. cid — канал, rootId — id поста-зеркала. */
async function openCommentsThread(cid, rootId) {
// Вступаем в привязанную группу-обсуждение (чтобы читать/писать) и получаем её id.
const { ok, data } = await api(`/api/channels/${cid}/comments`, {});
if (!ok) return toast(data.error || "Комментарии недоступны");
closeCommentsThread();
const wrap = document.createElement("div");
wrap.className = "thread-modal";
wrap.innerHTML =
`<div class="thread-head">
<span class="thread-title">Комментарии</span>
<button type="button" class="icon-btn thread-close" title="Закрыть">✕</button>
</div>
<div class="thread-root"></div>
<div class="thread-list"></div>
<div class="thread-reply" hidden><span></span>
<button type="button" class="icon-btn" title="Отменить">✕</button></div>
<form class="thread-form">
<input class="thread-input" placeholder="Комментарий" autocomplete="off" maxlength="4096">
<button class="icon-btn big" title="Отправить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</form>`;
document.body.appendChild(wrap);
commentThread = {
cid, discGid: data.id, rootId, replyTo: rootId, byId: new Map(),
el: wrap, rootEl: wrap.querySelector(".thread-root"),
listEl: wrap.querySelector(".thread-list"),
replyBar: wrap.querySelector(".thread-reply"),
input: wrap.querySelector(".thread-input"),
};
wrap.querySelector(".thread-close").onclick = closeCommentsThread;
commentThread.replyBar.querySelector("button").onclick = clearCommentReply;
wrap.querySelector(".thread-form").addEventListener("submit", e => {
e.preventDefault();
const text = commentThread.input.value.trim();
if (!text || ws?.readyState !== 1) return;
ws.send(JSON.stringify({ type: "send", group: commentThread.discGid, text,
reply_to: commentThread.replyTo }));
commentThread.input.value = "";
clearCommentReply();
});
document.addEventListener("keydown", commentThreadKey);
// Запрос истории треда (корень + комментарии).
ws?.send(JSON.stringify({ type: "history", group: data.id, thread: rootId }));
}
function commentThreadKey(e) { if (e.key === "Escape") closeCommentsThread(); }
function closeCommentsThread() {
if (!commentThread) return;
document.removeEventListener("keydown", commentThreadKey);
commentThread.el.remove();
commentThread = null;
}
/** История треда с сервера: рендер корневого поста и комментариев. */
function renderCommentHistory(msg) {
if (!commentThread || commentThread.rootId !== msg.thread) return;
commentThread.byId.clear();
commentThread.rootEl.innerHTML = "";
commentThread.rootEl.appendChild(renderCommentNode(msg.root, true));
commentThread.listEl.innerHTML = "";
for (const m of msg.messages) {
commentThread.byId.set(m.id, m);
commentThread.listEl.appendChild(renderCommentNode(m, false));
}
commentThread.listEl.scrollTop = commentThread.listEl.scrollHeight;
}
/** Новый комментарий пришёл по WS — добавить в открытый тред. */
function onCommentMessage(m) {
if (!commentThread || m.thread_root !== commentThread.rootId) return;
if (commentThread.listEl.querySelector(`[data-id="${m.id}"]`)) return;
commentThread.byId.set(m.id, m);
const atBottom = commentThread.listEl.scrollHeight - commentThread.listEl.scrollTop
- commentThread.listEl.clientHeight < 60;
commentThread.listEl.appendChild(renderCommentNode(m, false));
if (atBottom || m.from === me.id) commentThread.listEl.scrollTop = commentThread.listEl.scrollHeight;
}
/** Обновить счётчик «Комментарии N» под постом/дублем в открытом чате. */
function bumpCommentCount(rootId) {
for (const div of $("#messages").querySelectorAll(".msg")) {
const mm = div._m;
const root = mm?.extra?.disc || (mm?.extra?.channel_post ? mm.id : null);
if (root === rootId) {
mm.comments = (mm.comments || 0) + 1;
fillMessage(div, mm);
}
}
}
/** Диалог привязки группы комментариев: выбор из своих групп (не каналов). */
async function linkCommentsDialog(cid) {
const { ok, data } = await api("/api/me/groups");
if (!ok) return toast("Не удалось загрузить группы");
if (!data.length)
return toast("Нет подходящих групп. Создайте обычную группу для комментариев.");
dialog("Привязать группу для комментариев", [
{ name: "group_id", type: "select",
options: data.map(grp => ({ value: String(grp.id), label: grp.title })) },
], "Привязать", async v => {
const r = await api(`/api/channels/${cid}/link`, { group_id: +v.group_id });
if (!r.ok) return r.data.error;
openGroupProfile(cid);
});
}
/* ===== Модерация группы/канала ===== */
/** Кнопка «Настройки модерации» в профиле (для владельца/админов). */
function renderModeration(g) {
const box = $("#group-mod-row");
box.innerHTML = "";
if (!g.can_moderate || me.banned) return;
const b = document.createElement("button");
b.className = "link";
b.textContent = "Настройки модерации" + (g.moderation?.enabled ? " (включена)" : "");
b.onclick = () => openModerationDialog(g);
box.appendChild(b);
}
const splitLines = s => s.split("\n").map(x => x.trim()).filter(Boolean);
/** Диалог настроек модерации: бан-лист, белый список и режим, медленный режим. */
function openModerationDialog(g) {
const m = g.moderation || {};
const form = dialog("Модерация", [
{ name: "enabled", type: "select", options: [
{ value: "1", label: "Модерация включена" },
{ value: "0", label: "Модерация выключена" } ] },
{ name: "banned_words", type: "textarea", maxlength: 4000, optional: true,
placeholder: "Запрещённые слова или фразы — по одной на строку" },
{ name: "whitelist_words", type: "textarea", maxlength: 4000, optional: true,
placeholder: "Разрешённые слова или фразы — по одной на строку" },
{ name: "whitelist_mode", type: "select", options: [
{ value: "any", label: "Белый список: хватает одного слова" },
{ value: "all", label: "Белый список: нужны все слова" } ] },
{ name: "slow_seconds", type: "number", optional: true,
placeholder: "Медленный режим (секунды)" },
], "Сохранить", async v => {
const r = await api(`/api/groups/${g.id}/moderation`, {
enabled: v.enabled === "1",
banned_words: splitLines(v.banned_words),
whitelist_words: splitLines(v.whitelist_words),
whitelist_mode: v.whitelist_mode,
slow_seconds: Math.max(0, +v.slow_seconds || 0),
});
if (!r.ok) return r.data.error;
toast("Настройки модерации сохранены");
openGroupProfile(g.id);
});
// Заполняем текущими значениями.
form.elements.enabled.value = m.enabled ? "1" : "0";
form.elements.banned_words.value = (m.banned_words || []).join("\n");
form.elements.whitelist_words.value = (m.whitelist_words || []).join("\n");
form.elements.whitelist_mode.value = m.whitelist_mode || "any";
form.elements.slow_seconds.value = m.slow_seconds || 0;
}
/** Меню действий над участником: мут, кик, бан, (владелец) назначить админом. */
function openMemberMenu(e, g, u) {
const menu = contextMenu(e);
menuItem(menu, u.muted ? "Размутить" : "Замутить", false,
() => modAction(g.id, u.id, "mute", { muted: !u.muted }));
menuItem(menu, "Исключить", false, () => confirmDialog(
`Исключить ${u.display_name}?`, () => modAction(g.id, u.id, "kick")));
menuItem(menu, "Заблокировать", true, () => confirmDialog(
`Заблокировать ${u.display_name} в этой группе?`, () => modAction(g.id, u.id, "ban")));
if (g.is_owner || me.is_admin)
menuItem(menu, u.is_admin ? "Снять администратора" : "Назначить администратором", false,
() => modAction(g.id, u.id, "admin", { admin: !u.is_admin }));
document.body.appendChild(menu);
menu._place();
}
async function modAction(gid, uid, action, body) {
const { ok, data } = await api(`/api/groups/${gid}/members/${uid}/${action}`, body || {});
if (!ok) return toast(data.error || "Ошибка");
openGroupProfile(gid); // обновить список участников и роли
loadChats();
}
function leaveGroup(gid, isChannel) {
confirmDialog(isChannel ? "Отписаться от канала?" : "Выйти из группы?", async () => {
const { ok, data } = await api(`/api/groups/${gid}/leave`, {});
if (!ok) return toast(data.error || "Ошибка");
cacheDrop("g" + gid);
if (currentGroup?.id === gid || !$("#view-group").hidden) openView("empty");
loadChats();
});
}
function deleteGroup(gid) {
confirmDialog("Удалить группу? Все сообщения удалятся у всех участников.", async () => {
const { ok, data } = await api(`/api/groups/${gid}/delete`, {});
if (!ok) return toast(data.error || "Ошибка");
cacheDrop("g" + gid);
openView("empty");
loadChats();
});
}
/** Диалог добавления участника: ввод юзернейма + быстрый выбор из контактов. */
async function openAddMemberDialog(gid) {
const wrap = document.createElement("div");
wrap.className = "modal";
const card = document.createElement("div");
card.className = "modal-card dialog";
card.innerHTML = `<h2>Добавить участника</h2>
<input id="add-member-input" placeholder="Юзернейм пользователя" maxlength="32" autocomplete="off">
<p class="error" hidden></p>
<ul class="member-picker" id="add-member-contacts"></ul>
<div class="dialog-buttons">
<button type="button" class="link" data-cancel>Закрыть</button>
<button type="button" class="primary" data-add>Добавить</button>
</div>`;
wrap.appendChild(card);
const errEl = card.querySelector(".error");
const input = card.querySelector("#add-member-input");
const close = () => wrap.remove();
card.querySelector("[data-cancel]").onclick = close;
wrap.addEventListener("click", e => { if (e.target === wrap) close(); });
const add = async username => {
username = username.trim().replace(/^@/, "");
errEl.hidden = true;
if (!username) return;
const { ok, data } = await api(`/api/groups/${gid}/add`, { username });
if (!ok) { errEl.textContent = data.error || "Ошибка"; errEl.hidden = false; return; }
toast(`@${username} добавлен(а) в группу`);
close();
openGroupProfile(gid); // обновить список участников
loadChats();
};
card.querySelector("[data-add]").onclick = () => add(input.value);
input.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); add(input.value); } });
// Быстрый выбор из списка контактов.
const ul = card.querySelector("#add-member-contacts");
const { ok, data } = await api("/api/me/contacts");
if (ok && data.length) {
for (const c of data) {
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);
li.style.cursor = "pointer";
li.onclick = () => add(c.username);
ul.appendChild(li);
}
} else {
ul.innerHTML = `<li class="empty">Контактов нет — введите юзернейм выше</li>`;
}
document.body.appendChild(wrap);
input.focus();
}
// Ссылка-приглашение может прийти и в уже открытое приложение (меняется только hash).
window.addEventListener("hashchange", () => { if (me) handleJoinHash(); });
/** Открывает группу по хэшу приглашения: участнику — чат,
* остальным — экран группы с кнопкой «Вступить». */
async function openJoinLink(hash) {
const { ok, data } = await api("/api/groups/hash/" + hash);
if (!ok) return toast(data.error || "Ссылка недействительна");
if (data.is_member) return openGroup(data.id);
showGroupProfile(data, hash);
}
/** Вступление по пригласительной ссылке /#join=<hash> (работает и для приватных). */
function handleJoinHash() {
const m = location.hash.match(/^#join=([\w-]{8,64})$/);
if (!m) return;
history.replaceState(null, "", location.pathname); // хэш обработан — убираем из URL
openJoinLink(m[1]);
}
/* --- Панель закреплённого сообщения (показывает последнее закреплённое) --- */
function renderPinnedBar() {
const bar = $("#pinned-bar");
const last = pinnedMsgs[pinnedMsgs.length - 1];
bar.hidden = !last;
if (!last) return;
$("#pinned-text").textContent = last.kind === "photo" ? "Фото" : last.kind === "gif" ? "GIF"
: last.kind === "call" ? "Видеозвонок"
: last.kind === "audio" ? (last.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио")
: last.kind === "file" ? (last.extra?.name || "Файл") : last.content;
bar.onclick = () => { // клик — прокрутка к сообщению с подсветкой
const div = $(`#messages [data-id="${last.id}"]`);
if (!div) return;
div.scrollIntoView({ behavior: "smooth", block: "center" });
div.classList.add("flash");
setTimeout(() => div.classList.remove("flash"), 1200);
};
$("#pinned-unpin").hidden = !!me.banned;
$("#pinned-unpin").onclick = e => {
e.stopPropagation();
ws?.send(JSON.stringify({ type: "pin", id: last.id, pinned: false }));
};
}
/* ===== Форматирование текста (жирный, курсив, размер, цвет и т.д.) =====
Сообщение хранится как обычный текст + список пометок extra.fmt:
{s, e, t, v?} — диапазон [s,e) символов, тип t, значение v (для size/color).
И композер, и отрисовка строят одинаковые <span> с этими классами. */
const FMT_TYPES = ["b", "i", "u", "s", "code", "quote", "size", "color"];
const FMT_CLASS = { b: "f-b", i: "f-i", u: "f-u", s: "f-s", code: "f-code", quote: "f-quote" };
/** Создаёт <span> для одного типа форматирования. */
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, " "); // &nbsp; -> 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("<b>Ж</b>", "Жирный", apply("b"));
btn("<i>К</i>", "Курсив", apply("i"));
btn("<u>Ч</u>", "Подчёркнутый", apply("u"));
btn("<s>З</s>", "Зачёркнутый", apply("s"));
btn("&laquo;&raquo;", "Цитата", apply("quote"));
btn("&lt;/&gt;", "Моноширинный (код)", apply("code"));
btn("A<small>±</small>", "Размер", anchor => openSizeMenu(anchor));
btn("<span class='fmt-bar-swatch'></span>", "Цвет", () => pickColor());
// Самая правая кнопка — снять всё форматирование с выделения.
const clearBtn = btn(
"<svg viewBox='0 0 24 24' width='15' height='15' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 7V4h16v3'/><path d='M5 20h6'/><path d='M13 4 8 20'/><line x1='18' y1='14' x2='22' y2='18'/><line x1='22' y1='14' x2='18' y2='18'/></svg>",
"Убрать форматирование", () => { 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() {
// Нативная команда вставляет <br> и сама корректно двигает каретку и ставит
// «филлер» — ручная вставка в 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> сам
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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
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 = `<h2>GIF</h2>
<input id="gif-search" placeholder="Поиск по ключевым словам" autocomplete="off">
<div class="gif-grid" id="gif-grid"></div>`;
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 = `<p class="empty">Ничего не найдено.<br>Добавьте GIF через @addgif.</p>`;
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 = `<h2>Добавить GIF</h2>
<p class="hint">GIF попадёт в общую библиотеку и станет доступен всем по ключевым
словам. Можно добавить до 5 GIF в сутки.</p>
<label class="gif-file-label"><span id="gif-file-name">Выбрать GIF-файл</span>
<input type="file" id="gif-file" accept="image/gif" hidden>
</label>
<input id="gif-keywords" placeholder="Ключевые слова (через пробел)" maxlength="200" autocomplete="off">
<p class="error" hidden></p>
<div class="dialog-buttons">
<button type="button" class="link" data-cancel>Отмена</button>
<button type="button" class="primary" data-add>Добавить</button>
</div>`;
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 = `<p class="empty">Ничего не найдено</p>`;
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 = `<li class="empty">Контактов нет</li>`;
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 = `
<form class="modal-card dialog">
<h2>${title}</h2>
${fields.map(f => f.type === "textarea"
? `<textarea name="${f.name}" placeholder="${f.placeholder}" maxlength="${f.maxlength || 512}"></textarea>`
: f.type === "select"
? `<select name="${f.name}">${(f.options || []).map(o =>
`<option value="${o.value}">${o.label}</option>`).join("")}</select>`
: `<input name="${f.name}" type="${f.type || "text"}" placeholder="${f.placeholder}"
${f.maxlength ? `maxlength="${f.maxlength}"` : ""} ${f.optional ? "" : "required"}>`
).join("")}
<p class="error" hidden></p>
<div class="dialog-buttons">
<button type="button" class="link" data-cancel>Отмена</button>
<button class="primary">${submitLabel}</button>
</div>
</form>`;
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 = `
<div class="modal-card dialog">
<h2></h2>
<div class="dialog-buttons">
<button type="button" class="link" data-cancel>Отмена</button>
<button type="button" class="primary" data-yes>Да</button>
</div>
</div>`;
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 = `<li class="empty">Контактов пока нет</li>`;
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();