diff --git "a/static/app.js" "b/static/app.js" new file mode 100644--- /dev/null +++ "b/static/app.js" @@ -0,0 +1,3383 @@ +/* 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 = ``; +const BADGE_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= +} + +/* ===== 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 — гиф анимируется в сам + 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 = `

Чатов пока нет.
Найдите собеседника через поиск.

`; + 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 собеседника. +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 = + `
+ Комментарии + +
+
+
+ +
+ + +
`; + 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 = `

Добавить участника

+ + + +
+ + +
`; + 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 = `
  • Контактов нет — введите юзернейм выше
  • `; + } + + 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= (работает и для приватных). */ +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). + И композер, и отрисовка строят одинаковые с этими классами. */ + +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" }; + +/** Создаёт для одного типа форматирования. */ +function fmtSpan(t, v) { + const span = document.createElement("span"); + if (t === "size") { span.className = "f-size"; span.dataset.size = v; } + else if (t === "color") { span.className = "f-color"; span.dataset.color = v; span.style.color = v; } + else span.className = FMT_CLASS[t]; + return span; +} + +const EMPTY_STYLE = () => ({ b: 0, i: 0, u: 0, s: 0, code: 0, quote: 0, size: null, color: null }); + +/** Эффективный стиль символа i (для size/color побеждает последняя пометка в списке). */ +function styleAt(marks, i) { + const st = EMPTY_STYLE(); + for (const m of marks) { + if (i < m.s || i >= m.e) continue; + if (m.t === "size") st.size = m.v; + else if (m.t === "color") st.color = m.v; + else st[m.t] = 1; + } + return st; +} + +function sameStyle(a, b) { + return a.b === b.b && a.i === b.i && a.u === b.u && a.s === b.s && a.code === b.code + && a.quote === b.quote && a.size === b.size && a.color === b.color; +} + +/** Оборачивает строку в нужные span'ы (текст — через textNode, без XSS). */ +function styledNode(text, st) { + let node = document.createTextNode(text); + const wrap = (t, v) => { const sp = fmtSpan(t, v); sp.appendChild(node); node = sp; }; + if (st.code) wrap("code"); + if (st.quote) wrap("quote"); + if (st.b) wrap("b"); + if (st.i) wrap("i"); + if (st.u) wrap("u"); + if (st.s) wrap("s"); + if (st.size && st.size !== "normal") wrap("size", st.size); + if (st.color) wrap("color", st.color); + return node; +} + +/** Отрисовывает текст с форматированием в контейнер. Без пометок — обычный + * путь с кликабельными ссылками и @упоминаниями. */ +function renderRich(container, text, marks) { + if (!marks || !marks.length) return renderMentions(container, text); + let i = 0; + while (i < text.length) { + const st = styleAt(marks, i); + let j = i + 1; + while (j < text.length && sameStyle(styleAt(marks, j), st)) j++; + container.appendChild(styledNode(text.slice(i, j), st)); + i = j; + } +} + +/* --- Композер: contenteditable #msg-input --- */ + +const composer = () => $("#msg-input"); + +/** Стиль текстового узла по цепочке предков-обёрток до корня композера. */ +function nodeStyle(node, root) { + const st = EMPTY_STYLE(); + let el = node.parentElement; + while (el && el !== root) { + const c = el.classList; + if (c) { + if (c.contains("f-b")) st.b = 1; + if (c.contains("f-i")) st.i = 1; + if (c.contains("f-u")) st.u = 1; + if (c.contains("f-s")) st.s = 1; + if (c.contains("f-code")) st.code = 1; + if (c.contains("f-quote")) st.quote = 1; + if (st.size === null && el.dataset.size) st.size = el.dataset.size; // innermost-wins + if (st.color === null && el.dataset.color) st.color = el.dataset.color; + } + el = el.parentElement; + } + return st; +} + +/** Сырая сериализация композера в {text, marks} БЕЗ обрезки краёв. + * Индексы символов совпадают с boundaryAtChar (BR = 1 символ). */ +function rawSerialize() { + const root = composer(); + let text = ""; + const active = {}; // type -> {s, v} + const marks = []; + const close = (t, cur) => { + const mk = { s: cur.s, e: text.length, t }; + if (cur.v !== true) mk.v = cur.v; + marks.push(mk); + }; + const applyStyle = st => { + for (const t of FMT_TYPES) { + let dv; + if (t === "size") dv = (st.size && st.size !== "normal") ? st.size : null; + else if (t === "color") dv = st.color || null; + else dv = st[t] ? true : null; + const cur = active[t]; + if (dv) { + if (!cur || cur.v !== dv) { if (cur) close(t, cur); active[t] = { s: text.length, v: dv }; } + } else if (cur) { close(t, cur); delete active[t]; } + } + }; + const walk = node => { + for (const child of node.childNodes) { + if (child.nodeType === 3) { applyStyle(nodeStyle(child, root)); text += child.nodeValue; } + else if (child.nodeType === 1) { + if (child.tagName === "BR") { applyStyle(EMPTY_STYLE()); text += "\n"; } + else walk(child); + } + } + }; + walk(root); + for (const t of FMT_TYPES) if (active[t]) close(t, active[t]); + text = text.replace(/ /g, " "); //   -> normal space (length unchanged) + return { text, marks }; +} + +/** Сериализует содержимое композера в {text, marks} для отправки (с обрезкой краёв). */ +function serializeComposer() { + const { text: raw, marks } = rawSerialize(); + // сервер делает strip(); обрезаем края и сдвигаем пометки, чтобы смещения совпали + const lead = raw.match(/^\s+/); + const shift = lead ? lead[0].length : 0; + const text = raw.trim(); + const shifted = marks.map(m => ({ ...m, s: m.s - shift, e: m.e - shift })); + return { text, marks: clampMarks(shifted, text.length) }; +} + +/** Отбрасывает/подрезает пометки по границам текста. */ +function clampMarks(marks, len) { + const out = []; + for (const m of marks) { + const s = Math.max(0, m.s), e = Math.min(m.e, len); + if (s < e) out.push({ ...m, s, e }); + } + return out; +} + +/** Длина содержимого узла в «символах» (текст + BR как 1), как в rawSerialize. */ +function measureLen(node) { + let n = 0; + for (const c of node.childNodes) { + if (c.nodeType === 3) n += c.nodeValue.length; + else if (c.nodeType === 1) n += (c.tagName === "BR") ? 1 : measureLen(c); + } + return n; +} + +/** Индекс символа для границы выделения (container, offset) в координатах rawSerialize. */ +function charIndexOf(root, container, offset) { + const r = document.createRange(); + r.setStart(root, 0); + r.setEnd(container, offset); + return measureLen(r.cloneContents()); +} + +/** Находит позицию (node, offset) по индексу символа — для восстановления выделения. */ +function boundaryAtChar(root, target) { + let idx = 0, found = null; + (function walk(node) { + for (const child of node.childNodes) { + if (found) return; + if (child.nodeType === 3) { + const len = child.nodeValue.length; + if (target <= idx + len) { found = { node: child, offset: target - idx }; return; } + idx += len; + } else if (child.nodeType === 1) { + if (child.tagName === "BR") { + if (target <= idx) { const p = child.parentNode; + found = { node: p, offset: [...p.childNodes].indexOf(child) }; return; } + idx += 1; + } else walk(child); + } + } + })(root); + return found || { node: root, offset: root.childNodes.length }; +} + +/** Есть ли формат t у каждого символа диапазона [s,e). */ +function rangeHasFormat(marks, s, e, t) { + if (e <= s) return false; + for (let i = s; i < e; i++) { + const st = styleAt(marks, i); + const has = t === "size" ? (st.size && st.size !== "normal") + : t === "color" ? !!st.color : !!st[t]; + if (!has) return false; + } + return true; +} + +/** Убирает формат t из диапазона [s,e), разрезая пометки по краям. */ +function removeFormat(marks, s, e, t) { + const out = []; + for (const m of marks) { + if (m.t !== t) { out.push(m); continue; } + if (m.s < s) out.push({ ...m, e: Math.min(m.e, s) }); // кусок слева + if (m.e > e) out.push({ ...m, s: Math.max(m.s, e) }); // кусок справа + } + return out.filter(m => m.s < m.e); +} + +/** Переключает/задаёт формат на текущем выделении (с перерисовкой композера). */ +function applyFormat(t, v) { + editFormat((marks, s, e) => { + if (t === "size" || t === "color") { + const out = removeFormat(marks, s, e, t); + const clear = (t === "size" && (!v || v === "normal")) || (t === "color" && !v); + if (!clear) out.push({ s, e, t, v }); + return out; + } + return rangeHasFormat(marks, s, e, t) + ? removeFormat(marks, s, e, t) + : [...marks, { s, e, t }]; + }); +} + +/** Полностью снимает форматирование с выделения. */ +function clearFormatting() { + editFormat((marks, s, e) => { + let out = marks; + for (const t of FMT_TYPES) out = removeFormat(out, s, e, t); + return out; + }); +} + +/** Общий механизм: берёт выделение, меняет пометки, перерисовывает и возвращает выделение. */ +function editFormat(transform) { + const root = composer(); + const sel = window.getSelection(); + if (!sel.rangeCount) return; + const range = sel.getRangeAt(0); + if (range.collapsed || !root.contains(range.commonAncestorContainer)) return; + const a = charIndexOf(root, range.startContainer, range.startOffset); + const b = charIndexOf(root, range.endContainer, range.endOffset); + const s = Math.min(a, b), e = Math.max(a, b); + if (s >= e) return; + const { text, marks } = rawSerialize(); + fillComposer(text, transform(marks, s, e)); + // Восстанавливаем выделение по символьным индексам. + const bs = boundaryAtChar(root, s), be = boundaryAtChar(root, e); + const r = document.createRange(); + r.setStart(bs.node, bs.offset); + r.setEnd(be.node, be.offset); + sel.removeAllRanges(); + sel.addRange(r); + root.focus(); +} + +/** Контекстное меню форматирования (ПКМ по выделенному тексту в поле ввода). */ +function openFormatMenu(e) { + const root = composer(); + const sel = window.getSelection(); + if (!sel.rangeCount || sel.isCollapsed + || !root.contains(sel.getRangeAt(0).commonAncestorContainer)) + return; // нет выделения — оставляем обычное меню браузера + if (me?.banned) return; + const saved = sel.getRangeAt(0).cloneRange(); + const menu = contextMenu(e); + const restore = () => { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); }; + const item = (label, fn) => menuItem(menu, label, false, ev => { + ev?.stopPropagation(); restore(); fn(); closeMsgMenu(); + }); + + item("Жирный", () => applyFormat("b")); + item("Курсив", () => applyFormat("i")); + item("Подчёркнутый", () => applyFormat("u")); + item("Зачёркнутый", () => applyFormat("s")); + item("Цитата", () => applyFormat("quote")); + item("Моноширинный (код)", () => applyFormat("code")); + + const div = document.createElement("div"); + div.className = "msg-menu-sep"; + div.textContent = "Размер"; + menu.appendChild(div); + for (const [label, val] of [["Огромный", "huge"], ["Большой", "big"], + ["Обычный", "normal"], ["Маленький", "small"], ["Очень маленький", "tiny"]]) + item(label, () => applyFormat("size", val)); + + const sep = document.createElement("div"); + sep.className = "msg-menu-sep"; + menu.appendChild(sep); + item("Цвет…", () => { + const input = $("#fmt-color"); + input.onchange = () => { restore(); applyFormat("color", input.value); }; + input.click(); + }); + + const sep2 = document.createElement("div"); + sep2.className = "msg-menu-sep"; + menu.appendChild(sep2); + item("Убрать форматирование", () => clearFormatting()); + + document.body.appendChild(menu); + menu._place(); +} + +/* --- Плавающая панель форматирования над выделенным текстом в композере. + Появляется при выделении текста, чтобы не лезть в контекстное меню. --- */ + +let fmtBar = null; + +/** Создаёт панель один раз и возвращает её (ленивая инициализация). */ +function ensureFmtBar() { + if (fmtBar) return fmtBar; + const bar = document.createElement("div"); + bar.className = "fmt-bar"; + bar.hidden = true; + // Клик по кнопке не должен снимать выделение в поле ввода. + bar.addEventListener("mousedown", e => e.preventDefault()); + + const btn = (html, title, fn) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "fmt-bar-btn"; + b.innerHTML = html; + b.title = title; + b.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); fn(b); }); + bar.appendChild(b); + return b; + }; + + // Простые переключатели — применяются к текущему выделению. + const apply = t => () => { applyFormat(t); positionFmtBar(); }; + btn("Ж", "Жирный", apply("b")); + btn("К", "Курсив", apply("i")); + btn("Ч", "Подчёркнутый", apply("u")); + btn("З", "Зачёркнутый", apply("s")); + btn("«»", "Цитата", apply("quote")); + btn("</>", "Моноширинный (код)", apply("code")); + btn("A±", "Размер", anchor => openSizeMenu(anchor)); + btn("", "Цвет", () => pickColor()); + // Самая правая кнопка — снять всё форматирование с выделения. + const clearBtn = btn( + "", + "Убрать форматирование", () => { clearFormatting(); positionFmtBar(); }); + clearBtn.classList.add("fmt-bar-clear"); + document.body.appendChild(bar); + fmtBar = bar; + return bar; +} + +/** Сохраняет выделение, открывает системный выбор цвета и применяет его. */ +function pickColor() { + const sel = window.getSelection(); + const saved = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; + const input = $("#fmt-color"); + input.onchange = () => { + if (saved) { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); } + applyFormat("color", input.value); + positionFmtBar(); + }; + input.click(); +} + +/** Маленькое меню выбора размера, привязанное к кнопке панели. */ +function openSizeMenu(anchor) { + closeMsgMenu(); + const sel = window.getSelection(); + const saved = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; + const menu = document.createElement("div"); + menu.className = "msg-menu"; + for (const [label, val] of [["Огромный", "huge"], ["Большой", "big"], + ["Обычный", "normal"], ["Маленький", "small"], ["Очень маленький", "tiny"]]) { + const b = document.createElement("button"); + b.type = "button"; + b.className = "msg-menu-item"; + b.textContent = label; + b.addEventListener("mousedown", e => e.preventDefault()); + b.onclick = e => { + e.stopPropagation(); + if (saved) { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); } + applyFormat("size", val); + closeMsgMenu(); + positionFmtBar(); + }; + menu.appendChild(b); + } + document.body.appendChild(menu); + const r = anchor.getBoundingClientRect(); + const mr = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, Math.min(r.left, innerWidth - mr.width - 8)) + "px"; + menu.style.top = Math.min(r.bottom + 4, innerHeight - mr.height - 8) + "px"; +} + +/** Прячет панель форматирования. */ +function hideFmtBar() { if (fmtBar) fmtBar.hidden = true; } + +/** Ставит панель по центру над выделением (или под ним, если сверху не влезает). */ +function positionFmtBar(range) { + const bar = ensureFmtBar(); + const sel = window.getSelection(); + range = range || (sel.rangeCount ? sel.getRangeAt(0) : null); + if (!range) return; + const rect = range.getBoundingClientRect(); + bar.hidden = false; + const br = bar.getBoundingClientRect(); + let left = rect.left + rect.width / 2 - br.width / 2; + left = Math.max(8, Math.min(left, innerWidth - br.width - 8)); + let top = rect.top - br.height - 8; + if (top < 8) top = rect.bottom + 8; // не помещается сверху — показываем снизу + bar.style.left = left + "px"; + bar.style.top = top + "px"; +} + +/** Решает, показывать ли панель: есть выделение внутри композера и не забанен. */ +function updateFmtBar() { + const root = composer(); + if (!root) return; + const sel = window.getSelection(); + if (me?.banned || !sel.rangeCount || sel.isCollapsed) return hideFmtBar(); + const range = sel.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) return hideFmtBar(); + positionFmtBar(range); +} + +document.addEventListener("selectionchange", updateFmtBar); +window.addEventListener("resize", () => { if (fmtBar && !fmtBar.hidden) updateFmtBar(); }); + +/* --- Отправка сообщения из композера --- */ + +function clearComposer() { composer().innerHTML = ""; updatePlaceholder(); hideFmtBar(); } + +function updatePlaceholder() { + const ed = composer(); + ed.classList.toggle("is-empty", !ed.textContent.replace(/ /g, " ").trim()); +} + +/** Вставляет перенос строки в позицию каретки (Shift/Ctrl+Enter). */ +function insertBreak() { + // Нативная команда вставляет
    и сама корректно двигает каретку и ставит + // «филлер» — ручная вставка в Chrome требовала двойного нажатия. + if (document.execCommand("insertLineBreak")) { updatePlaceholder(); return; } + // Запасной путь, если execCommand недоступен. + const sel = window.getSelection(); + if (!sel.rangeCount) return; + const range = sel.getRangeAt(0); + range.deleteContents(); + const br = document.createElement("br"); + range.insertNode(br); + if (!br.nextSibling) br.after(document.createElement("br")); // чтобы каретка перешла вниз + range.setStartAfter(br); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + updatePlaceholder(); +} + +function sendComposer() { + if (ws?.readyState !== 1 || (!currentPeer && !currentGroup)) return; + const { text, marks } = serializeComposer(); + if (!text) return; + if (text.length > 4096) return showChatError("Сообщение: 1–4096 символов"); + // Режим правки: Enter изменяет сообщение, а не отправляет новое. + if (editTarget) { + const out = { type: "edit", id: editTarget.id, text }; + if (marks.length) out.fmt = marks; + ws.send(JSON.stringify(out)); + clearEdit(); + stopTyping(); + return; + } + const out = currentGroup ? { type: "send", group: currentGroup.id, text } + : { type: "send", to: currentPeer.id, text }; + if (marks.length) out.fmt = marks; + if (replyTarget) out.reply_to = replyTarget.id; + ws.send(JSON.stringify(out)); + clearComposer(); + clearReply(); + stopTyping(); +} + +/* --- Статус «печатает» --- */ + +let typingState = {}; // from_id -> {name, until} +let typingTick = null; + +function sendTyping() { + if (ws?.readyState !== 1 || me?.banned || (!currentPeer && !currentGroup)) return; + if (typingTimer) return; // не чаще раза в 2.5 с + ws.send(JSON.stringify(currentGroup ? { type: "typing", group: currentGroup.id } + : { type: "typing", to: currentPeer.id })); + typingTimer = setTimeout(() => { typingTimer = null; }, 2500); +} +function stopTyping() { clearTimeout(typingTimer); typingTimer = null; } + +function resetTyping() { + typingState = {}; + clearInterval(typingTick); + typingTick = null; + $("#chat-status").classList.remove("typing"); +} + +function noteTyping(fromId, name) { + typingState[fromId] = { name: name || "", until: Date.now() + 4000 }; + renderTypingStatus(); + clearInterval(typingTick); + typingTick = setInterval(renderTypingStatus, 1000); +} + +function renderTypingStatus() { + const now = Date.now(); + for (const k in typingState) if (typingState[k].until < now) delete typingState[k]; + const names = Object.values(typingState).map(t => t.name); + const el = $("#chat-status"); + if (!names.length) { + clearInterval(typingTick); typingTick = null; + el.classList.remove("typing"); + if (currentGroup) el.textContent = fmtAudience(currentGroup); + else if (currentPeer) el.textContent = fmtStatus(currentPeer); + return; + } + el.classList.add("typing"); + el.textContent = currentGroup + ? (names.length === 1 ? `${names[0]} печатает…` : `${names.length} печатают…`) + : "печатает…"; +} + +/* --- Прочтение: отметка и галочки --- */ + +function sendRead() { + if (currentPeer && !currentGroup && ws?.readyState === 1 && !document.hidden) + ws.send(JSON.stringify({ type: "read", with: currentPeer.id })); +} + +/** Обновляет галочки ✓/✓✓ у моих сообщений в открытой личной переписке. */ +function updateTicks() { + if (currentGroup) return; + document.querySelectorAll("#messages .msg.mine").forEach(div => { + const t = div.querySelector(".ticks"); + if (t && div._m) t.classList.toggle("read", div._m.id <= peerReadUpto); + }); +} + +/* --- Ответ на сообщение --- */ + +/** Короткое текстовое представление сообщения (для панели ответа). */ +function msgShort(m) { + if (m.kind === "text") return m.content; + return ({ photo: "📷 Фото", gif: "GIF", file: "📎 " + (m.extra?.name || "Файл"), + audio: m.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио", call: "📞 Звонок" })[m.kind] || "Сообщение"; +} + +function setReply(m) { + if (me?.banned || $("#msg-form").hidden) return; // отвечать некуда — поле скрыто + clearEdit(); + replyTarget = m; + $("#reply-bar").hidden = false; + $("#reply-bar-name").textContent = m.from === me.id ? "Вы" : (m.from_name || "Сообщение"); + $("#reply-bar-text").textContent = msgShort(m).slice(0, 90); + composer().focus(); +} + +function clearReply() { + replyTarget = null; + $("#reply-bar").hidden = true; +} + +/* --- Редактирование сообщения прямо в поле ввода --- */ + +/** Заполняет композер текстом с форматированием (для редактирования). */ +function fillComposer(text, marks) { + const root = composer(); + root.innerHTML = ""; + if (!marks || !marks.length) { + root.appendChild(document.createTextNode(text)); + } else { + // Та же сегментация, что и при отрисовке пузыря (renderRich), но без ссылок. + let i = 0; + while (i < text.length) { + const st = styleAt(marks, i); + let j = i + 1; + while (j < text.length && sameStyle(styleAt(marks, j), st)) j++; + root.appendChild(styledNode(text.slice(i, j), st)); + i = j; + } + } + updatePlaceholder(); +} + +/** Ставит каретку в конец композера. */ +function caretToEnd() { + const root = composer(); + root.focus(); + const sel = window.getSelection(); + const r = document.createRange(); + r.selectNodeContents(root); + r.collapse(false); + sel.removeAllRanges(); + sel.addRange(r); +} + +/** Входит в режим правки: текст сообщения (с форматированием) попадает в поле ввода. */ +function startEdit(m) { + if (me?.banned || $("#msg-form").hidden) return; + clearReply(); + editTarget = m; + $("#edit-bar").hidden = false; + fillComposer(m.content, m.extra?.fmt); + caretToEnd(); +} + +/** Выходит из режима правки (и очищает поле). */ +function clearEdit() { + if (!editTarget) return; + editTarget = null; + $("#edit-bar").hidden = true; + clearComposer(); +} + +/** Заполняет пузырь сообщения содержимым (используется и при редактировании). */ +function fillMessage(div, m) { + div._m = m; // данные сообщения — для меню действий и реакций + div.innerHTML = ""; + + // В группе у чужих сообщений показываем аватарку автора слева от пузыря. + const groupIncoming = m.group && m.from !== me.id; + div.classList.toggle("has-ava", !!groupIncoming); + if (groupIncoming) { + const av = avaNode(m.from, m.from_username, m.from_name, m.from_avatar_kind); + av.classList.add("msg-ava"); + av.onclick = e => { e.stopPropagation(); openUserProfile(m.from); }; + div.appendChild(av); + } + + // В группе чужие сообщения подписываются автором (клик — его профиль). + if (m.group && m.from !== me.id && m.from_name) { + const author = document.createElement("span"); + author.className = "msg-author"; + author.textContent = m.from_name; + author.onclick = e => { e.stopPropagation(); openUserProfile(m.from); }; + div.appendChild(author); + } + // Дубль поста канала в группе-обсуждении — помечаем источником. + if (m.extra?.channel_post) { + const tag = document.createElement("span"); + tag.className = "channel-post-tag"; + tag.textContent = "📢 Пост из «" + m.extra.channel_post.title + "»"; + div.appendChild(tag); + } + // Цитата сообщения, на которое отвечает это сообщение (клик — переход к нему). + if (m.reply_to_preview) { + const rp = m.reply_to_preview; + const q = document.createElement("div"); + q.className = "reply-quote clickable"; + const who = document.createElement("small"); + who.textContent = rp.from === me.id ? "Вы" : rp.from_name; + const tx = document.createElement("span"); + tx.textContent = rp.text || "сообщение"; + q.append(who, tx); + q.onclick = e => { + e.stopPropagation(); + const target = $(`#messages [data-id="${rp.id}"]`); + if (!target) return toast("Сообщение не загружено"); + target.scrollIntoView({ behavior: "smooth", block: "center" }); + target.classList.add("flash"); + setTimeout(() => target.classList.remove("flash"), 1200); + }; + div.appendChild(q); + } + + if (m.kind === "photo") { + const img = document.createElement("img"); + img.src = "/api/photos/" + m.content; + img.loading = "lazy"; + img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; // просмотр внутри + div.appendChild(img); + } else if (m.kind === "gif") { + const img = document.createElement("img"); // GIF анимируется в сам + img.className = "msg-gif"; + img.src = "/api/gifs/" + m.content; + img.loading = "lazy"; + img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; + div.appendChild(img); + } else if (m.kind === "call") { + renderCall(div, m); + } else if (m.kind === "audio") { + renderAudio(div, m); + } else if (m.kind === "file") { + renderFile(div, m); + } else { + const p = document.createElement("p"); + renderRich(p, m.content, m.extra?.fmt); // форматирование + @упоминания, без XSS + div.appendChild(p); + } + const time = document.createElement("time"); + time.textContent = (m.extra?.pinned ? "📌 " : "") + (m.extra?.edited ? "изм. " : "") + + fmtSmart(m.created_at); + // Галочки «доставлено/прочитано» — у моих сообщений в личной переписке. + if (!m.group && m.from === me.id && me && !me.banned) { + const ticks = document.createElement("span"); + ticks.className = "ticks" + (m.id <= peerReadUpto ? " read" : ""); + time.appendChild(ticks); + } + div.appendChild(time); + renderReactions(div, m); + + // Кнопка комментариев со счётчиком. Под постом канала (extra.disc → зеркало в + // обсуждении) и под дублем поста в самой группе-обсуждении (extra.channel_post). + const disc = m.extra?.disc; // пост в канале + const cpost = m.extra?.channel_post; // дубль поста в обсуждении + if (m.group && (disc || cpost)) { + const cid = disc ? m.group : cpost.cid; // id канала + const rootId = disc || m.id; // id поста-зеркала (корень треда) + const c = document.createElement("button"); + c.className = "link comments-link"; + c.textContent = "Комментарии" + (m.comments ? " " + m.comments : ""); + c.onclick = e => { e.stopPropagation(); openCommentsThread(cid, rootId); }; + div.appendChild(c); + } + + // Кнопки модерации в сообщении-жалобе (видны только админу, пока жалоба открыта). + if (m.extra?.report && me.is_admin && !m.extra.closed) { + const actions = document.createElement("div"); + actions.className = "report-actions"; + for (const [label, act] of [["Забанить", "ban"], ["Отклонить", "dismiss"]]) { + const b = document.createElement("button"); + b.className = "primary small-btn" + (act === "ban" ? " danger-btn" : ""); + b.textContent = label; + b.onclick = async e => { + e.stopPropagation(); + const { ok, data } = await api(`/api/admin/reports/${m.extra.report}/${act}`, {}); + if (!ok) return toast(data.error || "Ошибка"); + actions.remove(); + toast(act === "ban" ? "Пользователь забанен" : "Жалоба отклонена"); + }; + actions.appendChild(b); + } + div.appendChild(actions); + } +} + +function appendMessage(m) { + const box = $("#messages"); + if (box.querySelector(`[data-id="${m.id}"]`)) return; // защита от дублей + const div = document.createElement("div"); + div.className = "msg" + (m.from === me.id ? " mine" : ""); + div.dataset.id = m.id; + fillMessage(div, m); + div.oncontextmenu = e => openMsgMenu(e, div); // ПКМ по сообщению — меню действий + + const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 60; + box.appendChild(div); + if (atBottom || m.from === me.id) scrollMessages(); +} + +/* --- Файлы: карточка, размер, предпросмотр --- */ + +const FILE_SVG = ``; + +function fmtSize(bytes) { + if (bytes < 1024) return bytes + " Б"; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " КБ"; + return (bytes / 1048576).toFixed(1) + " МБ"; +} + +/** Файл-сообщение: видео играет в чате; PDF открывается в окне предпросмотра; + * для документов/презентаций/таблиц показывается текстовый сниппет. */ +function renderFile(div, m) { + const f = m.extra || {}; + const src = "/api/files/" + m.content; + + if (f.mime?.startsWith("video/")) { // предпросмотр видео — плеер прямо в чате + const v = document.createElement("video"); + v.src = src; + v.controls = true; + v.preload = "metadata"; + v.oncontextmenu = e => e.stopPropagation(); // нативное меню видео + div.appendChild(v); + } + + const row = document.createElement("a"); + row.className = "file-row"; + row.href = src + "?download=1"; // клик по карточке — скачать с исходным именем + row.innerHTML = FILE_SVG; + const info = document.createElement("div"); + info.className = "file-info"; + const name = document.createElement("span"); + name.textContent = f.name || "Файл"; + const size = document.createElement("small"); + size.textContent = fmtSize(f.size || 0); + info.append(name, size); + row.appendChild(info); + div.appendChild(row); + + if (f.mime === "application/pdf") { // PDF рендерит сам браузер — во всплывающем окне + const b = document.createElement("button"); + b.type = "button"; + b.className = "link file-preview-btn"; + b.textContent = "Предпросмотр"; + b.onclick = e => { e.stopPropagation(); openFilePreview(src, f.name); }; + div.appendChild(b); + } + if (f.preview) { // текстовый сниппет документа / презентации / таблицы + const p = document.createElement("p"); + p.className = "file-preview"; + p.textContent = f.preview; + div.appendChild(p); + } +} + +/* --- Аудио: голосовые сообщения и аудиофайлы (встроенный плеер) --- */ + +const MIC_SVG = ``; + +function fmtDuration(sec) { + sec = Math.max(0, Math.round(sec || 0)); + return Math.floor(sec / 60) + ":" + String(sec % 60).padStart(2, "0"); +} + +/** Аудио-сообщение: голосовое (🎤 + длительность) или аудиофайл (с именем) — + * оба проигрываются встроенным плеером прямо в пузыре. */ +function renderAudio(div, m) { + const f = m.extra || {}; + const box = document.createElement("div"); + box.className = "audio-msg" + (f.voice ? " voice" : ""); + + const head = document.createElement("div"); + head.className = "audio-head"; + if (f.voice) { + head.innerHTML = MIC_SVG; + const label = document.createElement("span"); + label.textContent = "Голосовое сообщение"; + head.appendChild(label); + } else { + const name = document.createElement("span"); + name.textContent = f.name || "Аудио"; + head.appendChild(name); + } + if (f.duration) { + const dur = document.createElement("small"); + dur.textContent = fmtDuration(f.duration); + head.appendChild(dur); + } + box.appendChild(head); + + const audio = document.createElement("audio"); + audio.controls = true; + audio.preload = "metadata"; + audio.src = "/api/audio/" + m.content; + audio.oncontextmenu = e => e.stopPropagation(); // нативное меню плеера + box.appendChild(audio); + div.appendChild(box); +} + +/** Историческая запись о звонке (звонки больше не поддерживаются — только подпись). */ +function renderCall(div, m) { + const box = document.createElement("div"); + box.className = "call-msg"; + const label = document.createElement("span"); + label.textContent = "📞 " + (m.from === me.id ? "Исходящий звонок" + : `${m.extra?.by || m.from_name || "Собеседник"} звонил(а) вам`); + box.append(label); + div.appendChild(box); +} + +/** Предпросмотр PDF внутри мессенджера (iframe в модальном окне). */ +function openFilePreview(src, name) { + const wrap = document.createElement("div"); + wrap.className = "modal preview-modal"; + const card = document.createElement("div"); + card.className = "preview-card"; + const head = document.createElement("div"); + head.className = "preview-head"; + const title = document.createElement("span"); + title.textContent = name || "Предпросмотр"; + const close = document.createElement("button"); + close.className = "icon-btn"; + close.innerHTML = ``; + head.append(title, close); + const frame = document.createElement("iframe"); + frame.src = src; + card.append(head, frame); + wrap.appendChild(card); + + const closeAll = () => { wrap.remove(); document.removeEventListener("keydown", onKey); }; + const onKey = ev => { if (ev.key === "Escape") closeAll(); }; + close.onclick = closeAll; + wrap.onclick = e => { if (e.target === wrap) closeAll(); }; + document.addEventListener("keydown", onKey); + document.body.appendChild(wrap); +} + +/* --- Реакции --- */ + +const REACTION_SET = [ + "😁", "🤣", "😍", "😘", "🥰", "🥲", "🤩", "🤔", "🫡", "🤨", "😐", "😑", "😶", + "🙄", "😯", "🥱", "😴", "🤤", "🤑", "🙁", "😭", "😨", "🤯", "😬", "😱", "🤬", + "🤮", "🥺", "🥹", "🤡", "🤓", "😈", "👿", "💀", "💩", "👀", "🤞", "🫸", "🤝", + "👌", "👍", "👎", "🤌", "✊", "👋", "👏", "🎉", "💋", "🏆", "🗿", "📝", "❤️", + "❌", "✅", +]; + +/** Полоска реакций под сообщением: группировка по эмодзи, своя — подсвечена. */ +function renderReactions(div, m) { + div.querySelector(".reactions")?.remove(); + if (!m.reactions?.length) return; + const row = document.createElement("div"); + row.className = "reactions"; + const groups = {}; + for (const r of m.reactions) (groups[r.emoji] ??= []).push(r.user_id); + for (const [emoji, users] of Object.entries(groups)) { + const chip = document.createElement("button"); + chip.className = "reaction-chip" + (users.includes(me.id) ? " mine" : ""); + chip.textContent = users.length > 1 ? `${emoji} ${users.length}` : emoji; + chip.onclick = e => { // клик по своей реакции снимает её, по чужой — присоединяется + e.stopPropagation(); + if (!me.banned) ws?.send(JSON.stringify({ type: "react", id: m.id, emoji })); + }; + row.appendChild(chip); + } + div.insertBefore(row, div.querySelector("time")); +} + +/* --- Контекстное меню (ПКМ): реакции, закрепить, изменить, удалить --- */ + +function closeMsgMenu() { document.querySelector(".msg-menu")?.remove(); } +document.addEventListener("click", closeMsgMenu); // клик мимо меню закрывает его +document.addEventListener("contextmenu", closeMsgMenu); // как и ПКМ мимо + +/** Пустое меню, спозиционированное у курсора (общая часть всех контекстных меню). */ +function contextMenu(e) { + e.preventDefault(); + e.stopPropagation(); + closeMsgMenu(); + const menu = document.createElement("div"); + menu.className = "msg-menu"; + menu.oncontextmenu = ev => ev.preventDefault(); + menu._place = () => { + const r = menu.getBoundingClientRect(); // не выходить за край экрана + menu.style.left = Math.max(8, Math.min(e.clientX, innerWidth - r.width - 8)) + "px"; + menu.style.top = Math.max(8, Math.min(e.clientY, innerHeight - r.height - 8)) + "px"; + }; + return menu; +} + +function menuItem(menu, label, danger, onclick) { + const b = document.createElement("button"); + b.type = "button"; + b.className = "msg-menu-item" + (danger ? " danger" : ""); + b.textContent = label; + b.onclick = onclick; + menu.appendChild(b); +} + +/** Кнопка-эмодзи: ЛКМ — поставить реакцию, ПКМ — закрепить/открепить эмодзи. */ +function reactionBtn(m, emoji) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = emoji; + if (me.pinned_reactions.includes(emoji)) b.classList.add("pinned"); + b.onclick = () => ws?.send(JSON.stringify({ type: "react", id: m.id, emoji })); + b.oncontextmenu = ev => { ev.preventDefault(); ev.stopPropagation(); togglePinReaction(emoji); }; + return b; +} + +/** ПКМ по эмодзи: закрепляет его первым в списке (макс. 5) либо открепляет. */ +async function togglePinReaction(emoji) { + let pinned = me.pinned_reactions.slice(); + if (pinned.includes(emoji)) { + pinned = pinned.filter(x => x !== emoji); + toast(`Реакция ${emoji} откреплена`); + } else { + pinned.unshift(emoji); + pinned = pinned.slice(0, 5); + toast(`Реакция ${emoji} закреплена`); + } + me.pinned_reactions = pinned; + closeMsgMenu(); + await api("/api/me/reactions", { pinned }); +} + +function openMsgMenu(e, div) { + if (me.banned) return; // только чтение + const m = div._m; + const menu = contextMenu(e); + + // Быстрый ряд — закреплённые эмодзи + кнопка «вся палитра». + const row = document.createElement("div"); + row.className = "msg-menu-reactions"; + for (const emoji of me.pinned_reactions) row.appendChild(reactionBtn(m, emoji)); + const more = document.createElement("button"); + more.type = "button"; + more.className = "more"; + more.textContent = "⌄"; + more.title = "Все реакции"; + more.onclick = ev => { ev.stopPropagation(); grid.hidden = !grid.hidden; menu._place(); }; + row.appendChild(more); + menu.appendChild(row); + + // Полная палитра: закреплённые первыми, затем остальные. + const grid = document.createElement("div"); + grid.className = "msg-menu-grid"; + grid.hidden = true; + const rest = REACTION_SET.filter(x => !me.pinned_reactions.includes(x)); + for (const emoji of [...me.pinned_reactions, ...rest]) grid.appendChild(reactionBtn(m, emoji)); + menu.appendChild(grid); + + // Ответить — если в этот чат можно писать (поле ввода не скрыто). + if (!$("#msg-form").hidden) + menuItem(menu, "Ответить", false, () => setReply(m)); + menuItem(menu, m.extra?.pinned ? "Открепить" : "Закрепить", false, + () => ws?.send(JSON.stringify({ type: "pin", id: m.id, pinned: !m.extra?.pinned }))); + if (m.from === me.id && m.kind === "text") + menuItem(menu, "Изменить", false, () => startEdit(m)); + // Удалить может автор; в группе — ещё и её владелец (модерация). + if (m.from === me.id || (m.group && currentGroup?.is_owner)) + menuItem(menu, "Удалить", true, () => confirmDialog( + "Удалить сообщение у всех участников?", + () => ws?.send(JSON.stringify({ type: "delete", id: m.id })))); + + document.body.appendChild(menu); + menu._place(); +} + +/* --- Контекстное меню переписки (ПКМ по чату в списке) --- */ + +function deleteChat(peerId) { + confirmDialog("Удалить переписку? Сообщения удалятся у обоих участников.", async () => { + const { ok, data } = await api(`/api/chats/${peerId}/delete`, {}); + if (!ok) return toast(data.error || "Ошибка"); + if (currentPeer?.id === peerId) openView("empty"); + loadChats(); + }); +} + +function openChatMenu(e, peerId) { + if (me.banned) return; + const menu = contextMenu(e); + menuItem(menu, "Удалить переписку", true, () => deleteChat(peerId)); + document.body.appendChild(menu); + menu._place(); +} + +/* --- Контекстное меню группы (ПКМ по группе в списке) --- */ + +function openGroupRowMenu(e, gid) { + if (me.banned) return; + const menu = contextMenu(e); + menuItem(menu, "Профиль группы", false, () => openGroupProfile(gid)); + menuItem(menu, "Выйти из группы", true, () => leaveGroup(gid)); + document.body.appendChild(menu); + menu._place(); +} + +/* --- Просмотр фото внутри мессенджера (лайтбокс) --- */ + +function openPhoto(src) { + const wrap = document.createElement("div"); + wrap.className = "modal photo-modal"; + const img = document.createElement("img"); + img.src = src; + img.alt = "Фото"; + wrap.appendChild(img); + const close = () => { wrap.remove(); document.removeEventListener("keydown", onKey); }; + const onKey = ev => { if (ev.key === "Escape") close(); }; + wrap.onclick = close; + document.addEventListener("keydown", onKey); + document.body.appendChild(wrap); +} + +$("#msg-form").addEventListener("submit", e => { + e.preventDefault(); + sendComposer(); +}); + +// Контекстное меню форматирования и поведение поля ввода (contenteditable). +composer().addEventListener("contextmenu", openFormatMenu); +composer().addEventListener("input", () => { updatePlaceholder(); sendTyping(); }); +composer().addEventListener("keydown", e => { + if (e.key === "Escape" && editTarget) { e.preventDefault(); clearEdit(); return; } + if (e.key !== "Enter" || e.isComposing) return; + e.preventDefault(); + // Shift+Enter и Ctrl+Enter — перенос строки, обычный Enter — отправка. + if (e.shiftKey || e.ctrlKey) insertBreak(); + else sendComposer(); +}); +$("#reply-cancel").onclick = clearReply; +$("#edit-cancel").onclick = clearEdit; +updatePlaceholder(); + +$("#chat-head").onclick = () => { + if (currentGroup) openGroupProfile(currentGroup.id); + else if (currentPeer) openUserProfile(currentPeer.id); +}; + +/* --- Мобильная навигация: кнопки «Назад» возвращают к списку чатов --- */ + +$("#chat-back").onclick = e => { + e.stopPropagation(); // не открывать профиль из шапки + openView("empty"); +}; +document.querySelectorAll("[data-back]").forEach(btn => + btn.addEventListener("click", () => openView("empty"))); + +/* --- Долгое нажатие (тач) = ПКМ: контекстные меню на телефонах. + Android шлёт contextmenu сам, iOS Safari — нет, поэтому синтезируем. --- */ + +let lpTimer = null, lpFired = false; +document.addEventListener("touchstart", e => { + if (e.touches.length !== 1) return; + const { clientX, clientY } = e.touches[0]; + const target = e.target; + lpFired = false; + lpTimer = setTimeout(() => { + if (document.querySelector(".msg-menu")) return; // Android уже открыл меню сам + lpFired = true; + target.dispatchEvent(new MouseEvent("contextmenu", + { bubbles: true, cancelable: true, clientX, clientY })); + }, 500); +}, { passive: true }); +document.addEventListener("touchmove", () => clearTimeout(lpTimer), { passive: true }); +document.addEventListener("touchend", e => { + clearTimeout(lpTimer); + if (lpFired) { e.preventDefault(); lpFired = false; } // погасить click после долгого тапа +}, { passive: false }); +document.addEventListener("touchcancel", () => clearTimeout(lpTimer)); + +/* --- Удаление переписки из шапки чата (дублирует ПКМ по чату в списке) --- */ + +$("#chat-delete").onclick = e => { + e.stopPropagation(); // не открывать профиль из шапки + if (currentGroup) (currentGroup.is_owner ? deleteGroup : leaveGroup)(currentGroup.id); + else if (currentPeer) deleteChat(currentPeer.id); +}; + +/* --- Вложения: кнопка "+", вставка из буфера, перетаскивание. + Картинки сжимаются как фото, остальные файлы (до 25 МБ) — как есть. --- */ + +async function sendAttachment(file) { + if (!file || (!currentPeer && !currentGroup)) return; + if (file.size > 25 * 1024 * 1024) return showChatError("Файл больше 25 МБ"); + if (file.type.startsWith("audio/")) // аудиофайл — отправляем как аудио-сообщение + return uploadAudio(file, { name: file.name }); + const isImage = file.type.startsWith("image/"); + const fd = new FormData(); + if (currentGroup) fd.append("group", currentGroup.id); + else fd.append("to", currentPeer.id); + fd.append(isImage ? "photo" : "file", file); + const res = await fetch(isImage ? "/api/messages/photo" : "/api/messages/file", + { method: "POST", body: fd }); + if (!res.ok) showChatError((await res.json().catch(() => ({}))).error + || "Не удалось отправить файл"); +} + +/** Отправка аудио (голосовое или файл) на /api/messages/audio. */ +async function uploadAudio(blob, { voice = false, duration = 0, name = "" } = {}) { + if (!blob || (!currentPeer && !currentGroup)) return; + if (blob.size > 25 * 1024 * 1024) return showChatError("Аудио больше 25 МБ"); + const fd = new FormData(); + if (currentGroup) fd.append("group", currentGroup.id); + else fd.append("to", currentPeer.id); + fd.append("audio", blob, name || "voice.webm"); + if (voice) fd.append("voice", "1"); + if (duration) fd.append("duration", String(duration)); + const res = await fetch("/api/messages/audio", { method: "POST", body: fd }); + if (!res.ok) showChatError((await res.json().catch(() => ({}))).error + || "Не удалось отправить аудио"); +} + +/* --- Запись голосовых сообщений (MediaRecorder) --- */ + +let mediaRecorder = null, recChunks = [], recStart = 0, recTimer = null, recCancelled = false; + +async function startRecording() { + if (!currentPeer && !currentGroup) return; + if (mediaRecorder) return; // уже идёт запись + if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) + return showChatError("Запись аудио не поддерживается этим браузером"); + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch { + return showChatError("Нет доступа к микрофону"); + } + // Выбираем формат, который умеет браузер (Chrome — webm/opus, Safari — mp4). + const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"] + .find(t => MediaRecorder.isTypeSupported(t)) || ""; + recChunks = []; + recCancelled = false; + mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); + mediaRecorder.ondataavailable = e => { if (e.data.size) recChunks.push(e.data); }; + mediaRecorder.onstop = () => { + stream.getTracks().forEach(t => t.stop()); + clearInterval(recTimer); + $("#rec-bar").hidden = true; + $("#msg-form").hidden = false; + const mr = mediaRecorder; + mediaRecorder = null; + if (recCancelled || !recChunks.length) return; + const duration = (Date.now() - recStart) / 1000; + const type = (mr.mimeType || "audio/webm").split(";")[0]; + const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm"; + uploadAudio(new Blob(recChunks, { type }), + { voice: true, duration, name: "voice." + ext }); + }; + mediaRecorder.start(); + recStart = Date.now(); + $("#msg-form").hidden = true; + const bar = $("#rec-bar"); + bar.hidden = false; + $("#rec-time").textContent = "0:00"; + recTimer = setInterval(() => { + const s = (Date.now() - recStart) / 1000; + $("#rec-time").textContent = fmtDuration(s); + if (s >= 600) stopRecording(false); // защита: максимум 10 минут + }, 200); +} + +function stopRecording(cancel) { + if (!mediaRecorder) return; + recCancelled = cancel; + try { mediaRecorder.stop(); } catch { /* уже остановлен */ } +} + +$("#mic-btn").onclick = () => startRecording(); +$("#rec-send").onclick = () => stopRecording(false); +$("#rec-cancel").onclick = () => stopRecording(true); + +$("#attach-btn").onclick = () => $("#photo-input").click(); +$("#photo-input").onchange = e => { sendAttachment(e.target.files[0]); e.target.value = ""; }; + +$("#msg-input").addEventListener("paste", e => { + const item = [...(e.clipboardData?.items || [])].find(i => i.kind === "file"); + if (item) { e.preventDefault(); sendAttachment(item.getAsFile()); return; } + // Вставляем как простой текст — чтобы не тащить чужое HTML-форматирование. + e.preventDefault(); + const text = e.clipboardData.getData("text/plain"); + document.execCommand("insertText", false, text); + updatePlaceholder(); +}); + +const msgForm = $("#msg-form"); +msgForm.addEventListener("dragover", e => { e.preventDefault(); msgForm.classList.add("drag"); }); +msgForm.addEventListener("dragleave", () => msgForm.classList.remove("drag")); +msgForm.addEventListener("drop", e => { + e.preventDefault(); + msgForm.classList.remove("drag"); + sendAttachment(e.dataTransfer.files[0]); +}); + +/* ===== GIF: общая библиотека ===== */ + +/** Пикер GIF: поиск по ключевым словам, клик по гифке — отправка в открытый чат. */ +function openGifPicker() { + if (!currentPeer && !currentGroup) return; + const wrap = document.createElement("div"); + wrap.className = "modal"; + const card = document.createElement("div"); + card.className = "modal-card gif-picker"; + card.innerHTML = `

    GIF

    + +
    `; + wrap.appendChild(card); + const close = () => wrap.remove(); + wrap.addEventListener("click", e => { if (e.target === wrap) close(); }); + + const grid = card.querySelector("#gif-grid"); + const input = card.querySelector("#gif-search"); + const render = async () => { + const { ok, data } = await api("/api/gifs?q=" + encodeURIComponent(input.value.trim())); + grid.innerHTML = ""; + if (!ok || !data.length) { + grid.innerHTML = `

    Ничего не найдено.
    Добавьте GIF через @addgif.

    `; + return; + } + for (const g of data) { + const img = document.createElement("img"); + img.src = "/api/gifs/" + g.file; + img.loading = "lazy"; + img.onclick = async () => { + const body = currentGroup ? { group: currentGroup.id, file: g.file } + : { to: currentPeer.id, file: g.file }; + const r = await api("/api/messages/gif", body); + if (!r.ok) return toast(r.data.error || "Не удалось отправить"); + close(); + }; + grid.appendChild(img); + } + }; + let t = null; + input.addEventListener("input", () => { clearTimeout(t); t = setTimeout(render, 300); }); + document.body.appendChild(wrap); + input.focus(); + render(); // сразу показать недавние +} + +/** Диалог добавления GIF в общую библиотеку (бот @addgif). */ +function openAddGifDialog() { + const wrap = document.createElement("div"); + wrap.className = "modal"; + const card = document.createElement("div"); + card.className = "modal-card dialog"; + card.innerHTML = `

    Добавить GIF

    +

    GIF попадёт в общую библиотеку и станет доступен всем по ключевым + словам. Можно добав��ть до 5 GIF в сутки.

    + + + +
    + + +
    `; + wrap.appendChild(card); + + const errEl = card.querySelector(".error"); + const fileInput = card.querySelector("#gif-file"); + const nameEl = card.querySelector("#gif-file-name"); + const kw = card.querySelector("#gif-keywords"); + const close = () => wrap.remove(); + card.querySelector("[data-cancel]").onclick = close; + wrap.addEventListener("click", e => { if (e.target === wrap) close(); }); + fileInput.onchange = () => + (nameEl.textContent = fileInput.files[0] ? fileInput.files[0].name : "Выбрать GIF-файл"); + + card.querySelector("[data-add]").onclick = async () => { + errEl.hidden = true; + const fail = msg => { errEl.textContent = msg; errEl.hidden = false; }; + const file = fileInput.files[0]; + if (!file) return fail("Выберите GIF-файл"); + if (file.type !== "image/gif") return fail("Это должен быть GIF-файл"); + if (file.size > 15 * 1024 * 1024) return fail("GIF больше 15 МБ"); + if (!kw.value.trim()) return fail("Укажите ключевые слова"); + const fd = new FormData(); + fd.append("gif", file); + fd.append("keywords", kw.value.trim()); + const res = await fetch("/api/gifs", { method: "POST", body: fd }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return fail(data.error || "Ошибка"); + close(); + toast("GIF добавлен в библиотеку"); + // @addgif пришлёт подтверждение по WS — чат и список обновятся сами. + }; + document.body.appendChild(wrap); +} + +$("#gif-btn").onclick = openGifPicker; +$("#addgif-add").onclick = openAddGifDialog; + +/* ===== Поиск ===== */ + +let searchTimer = null; + +async function runSearch() { + const q = $("#search-input").value.trim(); + const box = $("#search-results"); + $("#chat-list").hidden = !!q; + box.hidden = !q; + $("#search-opts").hidden = !q; + if (!q) return; + + const params = new URLSearchParams({ + q, type: $("#search-type").value, mode: $("#search-mode").value, + }); + const { ok, data } = await api("/api/search?" + params); + box.innerHTML = ""; + if (!ok || !data.length) { + box.innerHTML = `

    Ничего не найдено

    `; + return; + } + + for (const r of data) { + const row = document.createElement("button"); + row.className = "chat-row"; + const type = $("#search-type").value; + if (type === "groups") { // публичные группы: по названию и юзернейму + row.appendChild(groupAvaNode(r.id, r.title)); + const info = document.createElement("div"); + info.className = "chat-row-info"; + const name = document.createElement("span"); + name.textContent = r.title; + const sub = document.createElement("small"); + sub.textContent = (r.is_channel ? "Канал · " : "") + + (r.username ? "@" + r.username + " · " : "") + + (r.is_channel ? fmtSubscribers(r.members) : fmtMembers(r.members)); + info.append(name, sub); + row.appendChild(info); + row.onclick = () => openGroupProfile(r.id); // клик -> профиль группы/канала + } else if (type === "users") { + row.appendChild(avaNode(r.id, r.username, r.display_name, r.avatar_kind)); + const info = document.createElement("div"); + info.className = "chat-row-info"; + const name = document.createElement("span"); + name.textContent = r.display_name; + const uname = document.createElement("small"); + uname.textContent = "@" + r.username; + info.append(name, uname); + row.appendChild(info); + row.onclick = () => openUserProfile(r.id); // клик -> профиль пользователя + } else { + row.appendChild(avaNode(r.peer_id, r.peer_deleted ? null : r.peer_username, + r.peer_name, r.peer_avatar_kind)); + const info = document.createElement("div"); + info.className = "chat-row-info"; + const name = document.createElement("span"); + name.textContent = r.peer_deleted ? "Удалённый аккаунт" : r.peer_name; + const snippet = document.createElement("small"); + snippet.textContent = r.content; + info.append(name, snippet); + const time = document.createElement("time"); + time.textContent = fmtSmart(r.created_at); + row.append(info, time); + row.onclick = () => openChat(r.peer_id); // клик -> чат с сообщением + } + box.appendChild(row); + } +} + +$("#search-input").addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(runSearch, 300); +}); +$("#search-type").onchange = runSearch; +$("#search-mode").onchange = runSearch; + +/* ===== Профиль другого пользователя ===== */ + +async function openUserProfile(id) { + if (id === me.id) return openView("profile"); // свой профиль — отдельный вид + const { ok, data } = await api("/api/peer/" + id); + if (!ok) return; + const u = data; + openView("user"); + + setName($("#user-name"), u.deleted ? "Удалённый аккаунт" : u.display_name, u.is_admin); + $("#user-status").textContent = fmtStatus(u); + $("#user-username").textContent = u.deleted ? "" : "@" + u.username; + renderMentions($("#user-desc"), u.description || ""); + + renderProfileAvatar("user-ava", "user-ava-stub", u.username, + u.has_avatar && !u.deleted, u.avatar_kind); + + // Кнопки: добавить в контакты (если ещё не добавлен) и блокировка. + // В режиме «только чтение» (бан) никакие действия недоступны. + $("#user-add-contact").hidden = !!me.banned || !!u.deleted || u.is_contact; + $("#user-add-contact").onclick = async () => { + await api("/api/me/contacts", { username: u.username }); + openUserProfile(id); + }; + // Админа и системные аккаунты заблокировать нельзя. + $("#user-block").hidden = !!me.banned || !!u.deleted || u.is_admin || u.is_system; + $("#user-block").textContent = u.i_blocked ? "Разблокировать" : "Заблокировать"; + $("#user-block").onclick = async () => { + const { ok, data } = await api(`/api/users/${id}/${u.i_blocked ? "unblock" : "block"}`, {}); + if (!ok) toast(data.error); + openUserProfile(id); + }; + + // Писать нельзя удалённым, забаненным, @reports и в @salgram (если вы не админ). + $("#user-write").hidden = !!u.deleted || !!u.banned + || (u.is_system && (u.username === "reports" + || (u.username === "salgram" && !me.is_admin))); + $("#user-write").onclick = () => openChat(id); + + // Жалоба (не на админа и не на системные аккаунты). + $("#user-report").hidden = !!me.banned || !!u.deleted || u.is_admin || u.is_system; + $("#user-report").onclick = () => confirmDialog( + `Пожаловаться на ${u.display_name}? Переписка с ним будет отправлена администратору.`, + async () => { + const { ok, data } = await api(`/api/users/${id}/report`, {}); + toast(ok ? "Жалоба отправлена администратору" : data.error); + }); + + // Удаление аккаунта — только для админа. + $("#user-delete").hidden = !me.is_admin || !!u.deleted || u.is_admin || u.is_system; + $("#user-delete").onclick = () => confirmDialog( + `Удалить аккаунт ${u.display_name}?`, + async () => { + const { ok, data } = await api(`/api/admin/users/${id}/delete`, {}); + toast(ok ? "Аккаунт удалён" : data.error); + openUserProfile(id); + }); + + // Список контактов (если виден по приватности и нет блокировки). + const sec = $("#user-contacts-sec"); + sec.hidden = !u.contacts; + if (u.contacts) { + const ul = $("#user-contact-list"); + ul.innerHTML = ""; + if (!u.contacts.length) ul.innerHTML = `
  • Контактов нет
  • `; + for (const c of u.contacts) { + const li = document.createElement("li"); + const name = document.createElement("span"); + name.textContent = c.display_name; + const uname = document.createElement("small"); + uname.textContent = "@" + c.username; + li.append(name, uname); + ul.appendChild(li); + } + } +} + +/* ===== Универсальные всплывающие окна ===== */ + +function dialog(title, fields, submitLabel, onSubmit) { + const wrap = document.createElement("div"); + wrap.className = "modal"; + wrap.innerHTML = ` + `; + + const form = wrap.firstElementChild; + fields.forEach(f => { if (f.value) form.elements[f.name].value = f.value; }); // без XSS + + wrap.querySelector("[data-cancel]").onclick = () => wrap.remove(); + wrap.addEventListener("click", e => { if (e.target === wrap) wrap.remove(); }); + form.onsubmit = async e => { + e.preventDefault(); + const errorEl = form.querySelector(".error"); + errorEl.hidden = true; + const err = await onSubmit(Object.fromEntries(new FormData(form))); + if (!err) return wrap.remove(); + errorEl.textContent = err; + errorEl.hidden = false; + }; + + document.body.appendChild(wrap); + form.querySelector("input, textarea")?.focus(); + return form; // для динамики внутри диалога (скрытие полей и т.п.) +} + +function confirmDialog(text, onYes) { + const wrap = document.createElement("div"); + wrap.className = "modal"; + wrap.innerHTML = ` + `; + wrap.querySelector("h2").textContent = text; // textContent — защита от XSS + wrap.querySelector("[data-cancel]").onclick = () => wrap.remove(); + wrap.addEventListener("click", e => { if (e.target === wrap) wrap.remove(); }); + wrap.querySelector("[data-yes]").onclick = () => { wrap.remove(); onYes(); }; + document.body.appendChild(wrap); +} + +/* ===== Авторизация ===== */ + +async function submitAuth(form, path, payload) { + const errorEl = form.querySelector(".error"); + errorEl.hidden = true; + const { ok, data } = await api(path, payload); + if (ok) return enterApp(); + errorEl.textContent = data.error || "Ошибка сети, попробуйте позже"; + errorEl.hidden = false; +} + +$("#register-form").addEventListener("submit", e => { + e.preventDefault(); + const f = e.target.elements; + submitAuth(e.target, "/api/register", { + display_name: f.display_name.value.trim(), + username: f.username.value.trim(), + password: f.password.value, + tos: f.tos.checked, + }); +}); + +$("#login-form").addEventListener("submit", e => { + e.preventDefault(); + const f = e.target.elements; + submitAuth(e.target, "/api/login", { + username: f.username.value.trim(), + password: f.password.value, + }); +}); + +document.querySelectorAll(".switch").forEach(btn => + btn.addEventListener("click", () => { + btn.closest("form").hidden = true; + const next = document.getElementById(btn.dataset.target); + next.hidden = false; + next.classList.remove("appear"); + void next.offsetWidth; // перезапуск CSS-анимации + next.classList.add("appear"); + }) +); + +$("#tos-open").addEventListener("click", () => ($("#tos-modal").hidden = false)); +$("#tos-close").addEventListener("click", () => ($("#tos-modal").hidden = true)); +$("#tos-modal").addEventListener("click", e => { + if (e.target.id === "tos-modal") e.target.hidden = true; +}); + +/* ===== Панель навигации ===== */ + +$("#nav-settings").onclick = () => openView("settings"); +$("#nav-profile").onclick = () => openView("profile"); + +/* Создание группы: публичной обязателен юзернейм (по нему её находят поиском), + приватная — без юзернейма, вход только по ссылке. */ +$("#group-create").onclick = () => { + const form = dialog("Создать", [ + { name: "type", type: "select", options: [ + { value: "group", label: "Группа" }, + { value: "channel", label: "Канал (публикует владелец)" }, + ] }, + { name: "title", placeholder: "Название", maxlength: 64 }, + { name: "public", type: "select", options: [ + { value: "1", label: "Публичный — виден всем в поиске" }, + { value: "0", label: "Приватный — вход только по ссылке" }, + ] }, + { name: "username", placeholder: "Юзернейм", maxlength: 32, optional: true }, + ], "Создать", async v => { + const isPublic = v.public === "1"; + const isChannel = v.type === "channel"; + if (isPublic && !v.username.trim()) + return isPublic && isChannel ? "Публичному каналу нужен юзернейм" + : "Публичной группе нужен юзернейм"; + const { ok, data } = await api("/api/groups", { + title: v.title.trim(), public: isPublic, is_channel: isChannel, + username: isPublic ? v.username.trim() : "" }); + if (!ok) return data.error; + loadChats(); + showGroupProfile(data); // сразу видна пригласительная ссылка + }); + // Юзернейм есть только у публичных групп/каналов — для приватных поле скрывается. + const syncUsername = () => + (form.elements.username.hidden = form.elements.public.value !== "1"); + form.elements.public.onchange = syncUsername; + syncUsername(); +}; +$("#nav-logout").onclick = () => confirmDialog("Выйти из аккаунта?", async () => { + await unsubscribePush(); // снять push-подписку с этого устройства + await api("/api/logout", {}); + clearMsgCache(); // не оставляем переписки на общем устройстве + me = null; + ws?.close(); + unreadCount = 0; updateTitle(); + show("auth"); +}); + +/* ===== Настройки ===== */ + +function openSettingsPage(id) { + document.querySelectorAll(".settings-page").forEach(p => (p.hidden = p.id !== id)); + if (id === "page-chats") renderCacheSize(); // актуальный вес кэша сообщений + if (id === "page-appearance") renderWallpaperRow(); + if (id === "page-app") { // ПК-версия: кэш, обои и автозапуск собраны здесь + renderCacheSize(); + renderWallpaperRow(); + syncAutostartToggle(); + } + const page = document.getElementById(id); + page.classList.remove("appear"); + void page.offsetWidth; + page.classList.add("appear"); +} + +document.querySelectorAll("[data-page]").forEach(btn => + btn.addEventListener("click", () => openSettingsPage(btn.dataset.page)) +); + +function editNick() { + dialog("Изменить ник", [ + { name: "display_name", placeholder: "Новый ник", maxlength: 50, value: me.display_name }, + ], "Сохранить", async v => { + const { ok, data } = await api("/api/me/display_name", v); + if (!ok) return data.error; + me.display_name = v.display_name.trim(); + renderProfile(); + }); +} + +function editUsername() { + dialog("Изменить юзернейм", [ + { name: "username", placeholder: "Новый юзернейм", maxlength: 32, value: me.username }, + ], "Сохранить", async v => { + const { ok, data } = await api("/api/me/username", v); + if (!ok) return data.error; + me.username = v.username.trim(); + renderProfile(); + }); +} + +function editPassword() { + dialog("Изменить пароль", [ + { name: "old_password", placeholder: "Старый пароль", type: "password" }, + { name: "new_password", placeholder: "Новый пароль (мин. 8 символов)", type: "password" }, + ], "Сохранить", async v => { + const { ok, data } = await api("/api/me/password", v); + if (!ok) return data.error; + }); +} + +function editDescription() { + dialog("Изменить описание", [ + { name: "description", placeholder: "Расскажите о себе (до 512 символов)", + type: "textarea", maxlength: 512, value: me.description, optional: true }, + ], "Сохранить", async v => { + const { ok, data } = await api("/api/me/description", v); + if (!ok) return data.error; + me.description = v.description.trim(); + renderProfile(); + }); +} + +$("#opt-nick").onclick = editNick; +$("#opt-username").onclick = editUsername; +$("#opt-password").onclick = editPassword; +$("#opt-delete").onclick = () => + dialog("Удалить аккаунт", [ + { name: "password", placeholder: "Пароль аккаунта", type: "password" }, + ], "Удалить навсегда", async v => { + const { ok, data } = await api("/api/me/delete", v); + if (!ok) return data.error; + clearMsgCache(); + me = null; + ws?.close(); + show("auth"); + }); + +$("#privacy-avatar").onchange = e => api("/api/me/privacy", { avatar: e.target.value }); +$("#privacy-contacts").onchange = e => api("/api/me/privacy", { contacts: e.target.value }); +$("#privacy-group-invite").onchange = e => + api("/api/me/privacy", { group_invite: e.target.value }); + +/* Чаты: медленный режим */ +$("#slow-seconds").onchange = e => { + const v = Math.max(0, Math.min(3600, +e.target.value || 0)); + e.target.value = v; + me.slow_seconds = v; + api("/api/me/settings", { slow_seconds: v }); +}; +$("#slow-scope").onchange = e => { + me.slow_scope = e.target.value; + api("/api/me/settings", { slow_scope: e.target.value }); +}; + +/* Чаты: кэш сообщений */ +$("#cache-clear").onclick = () => confirmDialog( + "Очистить кэш сообщений на этом устройстве?", () => { + clearMsgCache(); + renderCacheSize(); + toast("Кэш сообщений очищен"); + }); + +/* Оформление: цв��товая тема (хранится в cookie устройства) */ +$("#theme-select").onchange = e => { + setCookie("theme", e.target.value); + applyTheme(); +}; + +/* Оформление: обои чата — фото или видео фоном в переписке */ +function renderWallpaper() { + const bg = $("#chat-bg"); + bg.innerHTML = ""; + bg.style.backgroundImage = ""; + bg.classList.toggle("has-wp", !!me.wallpaper); + if (!me.wallpaper) return; + const url = "/api/me/wallpaper?v=" + (me.wallpaper.v || 0); + if (me.wallpaper.kind === "video") { + const v = document.createElement("video"); + v.src = url; + v.autoplay = v.muted = v.loop = true; + v.playsInline = true; + bg.appendChild(v); + } else { + bg.style.backgroundImage = `url("${url}")`; + } +} + +function renderWallpaperRow() { $("#wallpaper-remove").hidden = !me.wallpaper; } + +$("#wallpaper-upload").onclick = () => $("#wallpaper-input").click(); +$("#wallpaper-input").onchange = async e => { + const file = e.target.files[0]; + e.target.value = ""; + if (!file) return; + if (file.size > 25 * 1024 * 1024) return toast("Файл больше 25 МБ"); + const fd = new FormData(); + fd.append("wallpaper", file); + const res = await fetch("/api/me/wallpaper", { method: "POST", body: fd }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return toast(data.error || "Не удалось загрузить обои"); + me.wallpaper = { ...data.wallpaper, v: Date.now() }; // v — сброс кэша браузера + renderWallpaper(); + renderWallpaperRow(); + toast("Обои установлены"); +}; +$("#wallpaper-remove").onclick = () => confirmDialog("Удалить обои?", async () => { + await api("/api/me/wallpaper", undefined, "DELETE"); + me.wallpaper = null; + renderWallpaper(); + renderWallpaperRow(); +}); + +/* Прочее: часовой пояс — «Авто» (определяется браузером) либо UTC-12..UTC+14. + Хранится в cookie устройства и не сбрасывается между сессиями. */ +{ + const auto = document.createElement("option"); + auto.value = "auto"; + const a = autoTz(); + auto.textContent = `Авто (UTC${a >= 0 ? "+" : ""}${a})`; + $("#tz-offset").appendChild(auto); +} +for (let tz = -12; tz <= 14; tz++) { + const opt = document.createElement("option"); + opt.value = tz; + opt.textContent = "UTC" + (tz >= 0 ? "+" : "") + tz; + $("#tz-offset").appendChild(opt); +} +$("#tz-offset").onchange = e => { + setCookie("tz", e.target.value); + me.tz_offset = resolveTz(); + loadChats(); // перерисовываем время в новом поясе +}; + +/* ===== Собственный профиль ===== */ + +/** Большая аватарка в профиле: картинка/гиф (img), видео (video) или заглушка. */ +function renderProfileAvatar(imgId, stubId, username, hasAvatar, kind) { + const img = $("#" + imgId), stub = $("#" + stubId); + const container = img.parentElement; + container.querySelector("video.avatar-media")?.remove(); + if (!hasAvatar) { + img.hidden = true; + stub.style.display = ""; + return; + } + const url = `/api/avatar/${encodeURIComponent(username)}?v=${Date.now()}`; + if (kind === "video") { + img.hidden = true; + stub.style.display = "none"; + const v = document.createElement("video"); + v.className = "avatar-media"; + v.src = url; + v.autoplay = v.muted = v.loop = true; + v.playsInline = true; + container.appendChild(v); + } else { // image/gif + img.src = url; + img.hidden = false; + stub.style.display = "none"; + } +} + +function renderProfile() { + setName($("#profile-name"), me.display_name, me.is_admin); + $("#profile-username").textContent = "@" + me.username; + $("#profile-desc").textContent = me.description || "Нет описания"; + $("#profile-desc").classList.toggle("muted", !me.description); + renderProfileAvatar("avatar-img", "avatar-stub", me.username, me.has_avatar, me.avatar_kind); +} + +$("#edit-nick").onclick = editNick; +$("#edit-username").onclick = editUsername; +$("#edit-desc").onclick = editDescription; + +/** Длительность видео в секундах (метаданные читаются браузером). */ +function videoDuration(file) { + return new Promise((resolve, reject) => { + const v = document.createElement("video"); + v.preload = "metadata"; + v.onloadedmetadata = () => { URL.revokeObjectURL(v.src); resolve(v.duration); }; + v.onerror = () => { URL.revokeObjectURL(v.src); reject(new Error("video")); }; + v.src = URL.createObjectURL(file); + }); +} + +$("#avatar-input").onchange = async e => { + const file = e.target.files[0]; + e.target.value = ""; + if (!file) return; + // Видео-аватарка: не длиннее 10 секунд и не больше 10 МБ (п��оверка в браузере). + if (file.type.startsWith("video/")) { + if (file.size > 10 * 1024 * 1024) return toast("Видео-аватарка больше 10 МБ"); + const dur = await videoDuration(file).catch(() => null); + if (dur === null) return toast("Не удалось прочитать видео"); + if (dur > 10.5) return toast("Видео-аватарка не длиннее 10 секунд"); + } + const fd = new FormData(); + fd.append("avatar", file); + const res = await fetch("/api/me/avatar", { method: "POST", body: fd }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return toast(data.error || "Не удалось загрузить аватарку"); + me.has_avatar = true; + me.avatar_kind = data.avatar_kind; + renderProfile(); + loadChats(); // обновить аватарку в списке чатов +}; + +/* ===== Контакты (свой профиль) ===== */ + +async function loadContacts() { + const { ok, data } = await api("/api/me/contacts"); + const ul = $("#contact-list"); + ul.innerHTML = ""; + if (!ok || !data.length) { + ul.innerHTML = `
  • Контактов пока нет
  • `; + return; + } + for (const c of data) { + const li = document.createElement("li"); + const name = document.createElement("span"); + name.textContent = c.display_name; // textContent — защита от XSS + const uname = document.createElement("small"); + uname.textContent = "@" + c.username; + const del = document.createElement("button"); + del.className = "icon-btn"; + del.title = "Удалить из контактов"; + del.innerHTML = TRASH_SVG; + del.onclick = () => confirmDialog(`Удалить ${c.display_name} из контактов?`, async () => { + await api("/api/me/contacts/" + encodeURIComponent(c.username), undefined, "DELETE"); + loadContacts(); + }); + li.append(name, uname, del); + ul.appendChild(li); + } +} + +/** Сворачивает список контактов (по умолчанию скрыт при открытии профиля). */ +function collapseContacts() { + const ul = $("#contact-list"); + if (ul) ul.hidden = true; + const btn = $("#contacts-toggle"); + if (btn) { btn.classList.remove("open"); btn.setAttribute("aria-expanded", "false"); } +} + +$("#contacts-toggle").onclick = () => { + const ul = $("#contact-list"); + const btn = $("#contacts-toggle"); + const show = ul.hidden; + ul.hidden = !show; + btn.classList.toggle("open", show); + btn.setAttribute("aria-expanded", show ? "true" : "false"); + if (show) loadContacts(); // подгружаем только когда раскрыли +}; + +$("#contact-add").onclick = () => + dialog("Добавить контакт", [ + { name: "username", placeholder: "Юзернейм пользователя", maxlength: 32 }, + ], "Добавить", async v => { + const { ok, data } = await api("/api/me/contacts", v); + if (!ok) return data.error; + $("#contact-list").hidden = false; // показать список после добавления + $("#contacts-toggle").classList.add("open"); + $("#contacts-toggle").setAttribute("aria-expanded", "true"); + loadContacts(); + }); + +/* ===== Старт: проверяем сессию ===== */ +enterApp();