Spaces:
Runtime error
Runtime error
| /* SalGram — фронтенд: авторизация, чаты (WebSocket), поиск, профили, настройки. */ | |
| const $ = sel => document.querySelector(sel); | |
| let me = null; // профиль текущего пользователя (из /api/me) | |
| let ws = null; // WebSocket-соединение | |
| let currentPeer = null; // собеседник открытого личного чата: {id, ...профиль} | |
| let currentGroup = null; // открытая группа: {id, title, members, is_owner, ...} | |
| let statusTimer = null; // периодическое обновление статуса в шапке чата | |
| let pinnedMsgs = []; // закреплённые сообщения открытого чата (по возрастанию id) | |
| let replyTarget = null; // сообщение, на которое отвечаем в открытом чате (или null) | |
| let editTarget = null; // сообщение, которое редактируем прямо в поле ввода (или null) | |
| let peerReadUpto = 0; // докуда собеседник прочитал мои сообщения (id) — для галочек | |
| let typingTimer = null; // троттлинг отправки статуса «печатает» | |
| /* ===== Настройки устройства в cookie: тема и часовой пояс ===== */ | |
| const getCookie = name => | |
| document.cookie.split("; ").find(r => r.startsWith(name + "="))?.slice(name.length + 1); | |
| const setCookie = (name, value) => | |
| (document.cookie = `${name}=${value}; max-age=31536000; path=/; samesite=lax`); | |
| /* Тема применяется сразу при загрузке, ещё до входа в аккаунт */ | |
| function applyTheme() { | |
| document.documentElement.dataset.theme = getCookie("theme") || "dark"; | |
| } | |
| applyTheme(); | |
| /* Часовой пояс: "auto" — определяется браузером, иначе выбранное число */ | |
| const autoTz = () => -new Date().getTimezoneOffset() / 60; | |
| function resolveTz() { | |
| const c = getCookie("tz"); | |
| return c === undefined || c === "auto" ? autoTz() : +c; | |
| } | |
| const TRASH_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`; | |
| const BADGE_SVG = `<svg class="badge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`; | |
| /** Имя с галочкой администратора. */ | |
| function setName(el, name, isAdmin) { | |
| el.textContent = name; | |
| if (isAdmin) el.insertAdjacentHTML("beforeend", BADGE_SVG); | |
| } | |
| /** Всплывающее уведомление внизу экрана. */ | |
| function toast(text) { | |
| const t = document.createElement("div"); | |
| t.className = "toast"; | |
| t.textContent = text; | |
| document.body.appendChild(t); | |
| setTimeout(() => t.remove(), 3500); | |
| } | |
| /** Дописывает в элемент текст, делая @юзернеймы кликабельными (без очистки). */ | |
| function appendMentions(el, text) { | |
| const re = /@([A-Za-z0-9_]{3,32})/g; | |
| let last = 0, m; | |
| while ((m = re.exec(text))) { | |
| el.append(text.slice(last, m.index)); | |
| const a = document.createElement("span"); | |
| a.className = "mention"; | |
| a.textContent = m[0]; | |
| const username = m[1]; | |
| a.onclick = async e => { | |
| e.stopPropagation(); | |
| const { ok, data } = await api("/api/users/" + encodeURIComponent(username)); | |
| if (ok) openUserProfile(data.id); | |
| else toast(`Пользователя @${username} не существует`); | |
| }; | |
| el.appendChild(a); | |
| last = m.index + m[0].length; | |
| } | |
| el.append(text.slice(last)); | |
| } | |
| /** Вставляет текст: ссылки кликабельны (новая вкладка), @юзернеймы открывают | |
| * профиль, а пригласительные ссылки в группы этого сайта — группу в приложении. */ | |
| function renderMentions(el, text) { | |
| el.textContent = ""; | |
| const re = /https?:\/\/[^\s<>"'«»]+/g; | |
| let last = 0, m; | |
| while ((m = re.exec(text))) { | |
| appendMentions(el, text.slice(last, m.index)); | |
| let url = m[0]; | |
| const trail = url.match(/[.,!?;:))\]»]+$/); // пунктуация в конце — не часть ссылки | |
| if (trail) url = url.slice(0, -trail[0].length); | |
| const a = document.createElement("a"); | |
| a.className = "msg-link"; | |
| a.textContent = url; | |
| a.href = url; | |
| a.target = "_blank"; | |
| a.rel = "noopener noreferrer"; | |
| a.onclick = e => e.stopPropagation(); // не открывать профиль/меню по клику | |
| // Ссылка-приглашение в группу на этом же сайте открывается на этой странице. | |
| const join = url.match(/#join=([\w-]{8,64})$/); | |
| if (join && url.startsWith(location.origin + "/")) { | |
| a.onclick = e => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| openJoinLink(join[1]); | |
| }; | |
| } | |
| el.appendChild(a); | |
| if (trail) el.append(trail[0]); | |
| last = m.index + m[0].length; | |
| } | |
| appendMentions(el, text.slice(last)); | |
| } | |
| /** Запрос к API (JSON). Возвращает {ok, data}. */ | |
| async function api(path, body, method) { | |
| const res = await fetch(path, body !== undefined ? { | |
| method: method || "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body), | |
| } : { method: method || "GET" }); | |
| return { ok: res.ok, data: await res.json().catch(() => ({})) }; | |
| } | |
| /* ===== Время: хранится в UTC (ЧП +0), показывается в поясе из настроек ===== */ | |
| const two = n => String(n).padStart(2, "0"); | |
| const parseUTC = s => new Date(s.replace(" ", "T") + "Z"); | |
| const shifted = s => new Date(parseUTC(s).getTime() + me.tz_offset * 3600e3); | |
| function fmtTime(s) { const d = shifted(s); return `${two(d.getUTCHours())}:${two(d.getUTCMinutes())}`; } | |
| function fmtDate(s) { const d = shifted(s); return `${two(d.getUTCDate())}.${two(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`; } | |
| /** Время, если сообщение сегодняшнее, иначе дата + время. */ | |
| function fmtSmart(s) { | |
| const today = new Date(Date.now() + me.tz_offset * 3600e3).toISOString().slice(0, 10); | |
| return shifted(s).toISOString().slice(0, 10) === today ? fmtTime(s) : `${fmtDate(s)} ${fmtTime(s)}`; | |
| } | |
| /** Статус: в сети / был(а) N назад / дата / аккаунт удалён / заблокирован. */ | |
| function fmtStatus(u) { | |
| if (u.deleted) return "аккаунт удалён"; | |
| if (u.is_system) return "автоматизированный аккаунт"; | |
| if (u.banned) return "аккаунт заблокирован"; | |
| if (u.online) return "в сети"; | |
| if (!u.last_seen) return ""; // скрыто (например, для заблокированных) | |
| const diff = (Date.now() - parseUTC(u.last_seen).getTime()) / 1000; | |
| if (diff < 60) return "был(а) только что"; | |
| if (diff < 3600) return `был(а) ${Math.floor(diff / 60)} мин назад`; | |
| if (diff < 86400) return `был(а) ${Math.floor(diff / 3600)} ч назад`; | |
| return `был(а) ${fmtDate(u.last_seen)} в ${fmtTime(u.last_seen)}`; | |
| } | |
| /* ===== Экраны и виды ===== */ | |
| function show(screen) { | |
| $("#auth").hidden = screen !== "auth"; | |
| $("#app").hidden = screen !== "app"; | |
| } | |
| /** Открывает вид справа: "empty" | "chat" | "user" | "group" | "settings" | "profile". */ | |
| function openView(name) { | |
| for (const v of ["empty", "chat", "user", "group", "settings", "profile"]) | |
| $("#view-" + v).hidden = v !== name; | |
| // На телефоне видна одна панель: список чатов (empty) либо открытый вид. | |
| $("#app").classList.toggle("show-main", name !== "empty"); | |
| $("#nav-settings").classList.toggle("active", name === "settings"); | |
| $("#nav-profile").classList.toggle("active", name === "profile"); | |
| if (name !== "chat") { clearInterval(statusTimer); currentPeer = null; currentGroup = null; resetTyping(); } | |
| if (name === "settings") openSettingsPage("page-root"); | |
| if (name === "profile") collapseContacts(); // список контактов по умолчанию скрыт | |
| } | |
| /** Загружает профиль, подключает WebSocket и входит в приложение. */ | |
| async function enterApp() { | |
| const { ok, data } = await api("/api/me"); | |
| if (!ok) return show("auth"); | |
| me = data; | |
| // Часовой пояс — из cookie устройства (по умолчанию автоопределение). | |
| me.tz_offset = resolveTz(); | |
| if (getCookie("tz") === undefined) setCookie("tz", "auto"); | |
| renderProfile(); | |
| $("#privacy-avatar").value = me.privacy.avatar; | |
| $("#privacy-contacts").value = me.privacy.contacts; | |
| $("#privacy-group-invite").value = me.privacy.group_invite; | |
| $("#slow-seconds").value = me.slow_seconds; | |
| $("#slow-scope").value = me.slow_scope; | |
| $("#tz-offset").value = getCookie("tz") || "auto"; | |
| $("#theme-select").value = getCookie("theme") || "dark"; | |
| // Забаненный аккаунт: только чтение — вместо поиска красная надпись, | |
| // настройки и профиль недоступны. | |
| $("#search-input").hidden = !!me.banned; | |
| $("#banned-banner").hidden = !me.banned; | |
| $("#nav-settings").hidden = !!me.banned; | |
| $("#nav-profile").hidden = !!me.banned; | |
| $("#group-create").hidden = !!me.banned; | |
| show("app"); | |
| openView("empty"); | |
| renderWallpaper(); | |
| connectWS(); | |
| initNotifications(); | |
| loadChats(); | |
| handleJoinHash(); // вступление по пригласительной ссылке /#join=<hash> | |
| } | |
| /* ===== WebSocket ===== */ | |
| function connectWS() { | |
| ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws"); | |
| ws.onmessage = e => handleWS(JSON.parse(e.data)); | |
| // Пинг каждые 25 с: прокси хостингов закрывают неактивные WS-соединения. | |
| const ping = setInterval(() => { | |
| if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" })); | |
| }, 25000); | |
| ws.onclose = () => { | |
| clearInterval(ping); | |
| if (me) setTimeout(connectWS, 2000); // авто-переподключение | |
| }; | |
| } | |
| /* ===== Уведомления о сообщениях ===== | |
| Открытое приложение уведомляет само (по WebSocket); когда оно закрыто — | |
| уведомления приходят через Web Push (service worker). Сервер шлёт push только | |
| офлайн-получателям, поэтому дублей нет. В EXE используется нативный мост. */ | |
| let swReg = null; // регистрация service worker (PWA + push) | |
| let unreadCount = 0; // непрочитанные — для счётчика в заголовке вкладки | |
| const notifyOn = () => localStorage.getItem("notify") !== "0"; | |
| const hasBridge = () => !!window.pywebview?.api?.notify; // десктоп-приложение (EXE) | |
| /* ===== ПК-приложение vs веб-версия ===== | |
| В ПК-приложении страница открыта внутри pywebview, поэтому существует объект | |
| window.pywebview (в браузере и PWA его нет). Только там показываем раздел | |
| «Приложение», куда переносим оформление, кэш и уведомления, а «Оформление» | |
| как отдельную кнопку прячем. window.pywebview может появиться чуть позже | |
| загрузки страницы — поэтому applyDesktopUI() вызывается и по событию | |
| pywebviewready. */ | |
| const isDesktopApp = () => !!window.pywebview; | |
| function applyDesktopUI() { | |
| if (!isDesktopApp()) return; | |
| if (document.body.classList.contains("is-desktop")) return; // переносим один раз | |
| document.body.classList.add("is-desktop"); | |
| const page = $("#page-app"); | |
| const heading = text => { | |
| const h = document.createElement("h3"); | |
| h.className = "settings-sub"; | |
| h.textContent = text; | |
| page.appendChild(h); | |
| }; | |
| // append() перемещает существующие узлы — обработчики и id сохраняются. | |
| heading("Оформление"); | |
| page.append($("#row-theme"), $("#row-wallpaper"), $("#hint-wallpaper"), $("#wallpaper-input")); | |
| heading("Кэш"); | |
| page.append($("#row-cache"), $("#hint-cache")); | |
| heading("Уведомления"); | |
| page.append($("#row-notify"), $("#notify-hint")); | |
| // Автозапуск (статичные узлы page-app) переносим в конец, после остальных секций. | |
| page.append($("#sub-autostart"), $("#row-autostart"), $("#hint-autostart")); | |
| // Кнопку «Оформление» прячем — её содержимое теперь в «Приложении». | |
| $("#root-appearance").hidden = true; | |
| $("#root-app").hidden = false; | |
| syncAutostartToggle(); | |
| } | |
| /** Подтягивает состояние автозапуска из нативного моста в чекбокс. */ | |
| async function syncAutostartToggle() { | |
| const cb = $("#autostart-toggle"); | |
| if (!cb) return; | |
| if (!window.pywebview?.api?.get_autostart) { cb.disabled = true; return; } | |
| cb.disabled = false; // мост готов — переключатель активен | |
| try { cb.checked = !!(await window.pywebview.api.get_autostart()); } | |
| catch { /* мост недоступен — оставляем как есть */ } | |
| } | |
| window.addEventListener("pywebviewready", () => { | |
| applyDesktopUI(); // идемпотентно: перенос секций выполнится один раз | |
| syncAutostartToggle(); // мост уже готов — подтягиваем актуальное состояние | |
| }); | |
| document.addEventListener("DOMContentLoaded", () => { | |
| applyDesktopUI(); // на случай, если мост готов ещё до загрузки DOM | |
| const cb = $("#autostart-toggle"); | |
| if (cb) cb.onchange = async e => { | |
| const on = e.target.checked; | |
| try { | |
| const ok = await window.pywebview?.api?.set_autostart(on); | |
| if (ok === false) throw new Error(); | |
| toast(on ? "Автозапуск включён" : "Автозапуск выключен"); | |
| } catch { | |
| e.target.checked = !on; // откатываем переключатель, если не вышло | |
| toast("Не удалось изменить автозапуск"); | |
| } | |
| }; | |
| }); | |
| function updateTitle() { | |
| document.title = unreadCount > 0 ? `(${unreadCount}) SalGram` : "SalGram"; | |
| } | |
| function clearUnread() { if (unreadCount) { unreadCount = 0; updateTitle(); } } | |
| window.addEventListener("focus", () => { clearUnread(); sendRead(); }); | |
| document.addEventListener("visibilitychange", () => { | |
| if (!document.hidden) { clearUnread(); sendRead(); } | |
| }); | |
| /** Показывает уведомление: нативный тост (EXE) либо Web Notification (браузер). */ | |
| function showNotification(title, body, m) { | |
| if (hasBridge()) { try { window.pywebview.api.notify(title, body); } catch { /* ignore */ } return; } | |
| if (typeof Notification === "undefined" || Notification.permission !== "granted") return; | |
| try { | |
| const n = new Notification(title, { | |
| body, icon: "/icon-192.png", badge: "/icon-192.png", | |
| tag: m.group ? "grp-" + m.group : "dm-" + m.from, | |
| }); | |
| n.onclick = () => { | |
| window.focus(); | |
| if (m.group) openGroup(m.group); | |
| else openChat(m.from === me.id ? m.to : m.from); | |
| n.close(); | |
| }; | |
| } catch { /* ignore */ } | |
| } | |
| /** Решает, нужно ли уведомить о пришедшем сообщении, и обновляет счётчик. */ | |
| function maybeNotify(m) { | |
| if (!me || m.from === me.id) return; | |
| const visibleHere = !document.hidden && (m.group | |
| ? currentGroup?.id === m.group | |
| : currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id)); | |
| if (visibleHere) return; // пользователь и так видит это сообщение | |
| unreadCount++; | |
| updateTitle(); | |
| if (!notifyOn()) return; | |
| const title = m.group ? `${m.from_name || "Сообщение"} • группа` : (m.from_name || "Новое сообщение"); | |
| showNotification(title, commentShort(m), m); | |
| } | |
| function urlBase64ToUint8Array(b64) { | |
| const pad = "=".repeat((4 - (b64.length % 4)) % 4); | |
| const raw = atob((b64 + pad).replace(/-/g, "+").replace(/_/g, "/")); | |
| return Uint8Array.from([...raw].map(c => c.charCodeAt(0))); | |
| } | |
| /** Подписывает браузер на Web Push (для уведомлений при закрытом приложении). */ | |
| async function subscribePush() { | |
| if (!swReg || typeof Notification === "undefined" || Notification.permission !== "granted") return; | |
| try { | |
| const { ok, data } = await api("/api/push/key"); | |
| if (!ok || !data.enabled || !data.key) return; | |
| let sub = await swReg.pushManager.getSubscription(); | |
| if (!sub) sub = await swReg.pushManager.subscribe({ | |
| userVisibleOnly: true, | |
| applicationServerKey: urlBase64ToUint8Array(data.key), | |
| }); | |
| await api("/api/push/subscribe", sub.toJSON()); | |
| } catch { /* push недоступен — остаются уведомления при открытом приложении */ } | |
| } | |
| async function unsubscribePush() { | |
| try { | |
| const sub = await swReg?.pushManager.getSubscription(); | |
| if (sub) { await api("/api/push/unsubscribe", { endpoint: sub.endpoint }); await sub.unsubscribe(); } | |
| } catch { /* ignore */ } | |
| } | |
| /** Приводит чекбокс настроек в соответствие текущему состоянию разрешений. */ | |
| function syncNotifyToggle() { | |
| const cb = $("#notify-toggle"); | |
| if (!cb) return; | |
| const supported = hasBridge() || typeof Notification !== "undefined"; | |
| cb.disabled = !supported; | |
| cb.checked = supported && notifyOn() && | |
| (hasBridge() || Notification.permission === "granted"); | |
| } | |
| async function initNotifications() { | |
| if ("serviceWorker" in navigator) { | |
| try { swReg = await navigator.serviceWorker.register("/sw.js"); } catch { /* ignore */ } | |
| navigator.serviceWorker.addEventListener("message", e => { | |
| const d = e.data; // клик по фоновому push-уведомлению | |
| if (d?.type === "notification-click" && me) { | |
| if (d.data?.group) openGroup(d.data.group); | |
| else if (d.data?.peer) openChat(d.data.peer); | |
| } | |
| }); | |
| } | |
| // Уведомления включены по умолчанию: при первом заходе сами спрашиваем разрешение | |
| // браузера/PWA/TWA. Без этого showNotification молча ничего не показывает | |
| // (permission остаётся "default"), и кажется, что уведомления не приходят. | |
| // Десктоп-обёртку (pywebview) пропускаем — там нативный мост, а не Web Notification | |
| // (window.pywebview может появиться чуть позже моста, поэтому проверяем и его). | |
| if (notifyOn() && !hasBridge() && !window.pywebview | |
| && typeof Notification !== "undefined" | |
| && Notification.permission === "default") { | |
| try { await Notification.requestPermission(); } catch { /* ignore */ } | |
| } | |
| syncNotifyToggle(); | |
| if (notifyOn() && !hasBridge() | |
| && typeof Notification !== "undefined" && Notification.permission === "granted") | |
| subscribePush(); | |
| } | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const cb = $("#notify-toggle"); | |
| if (!cb) return; | |
| cb.onchange = async e => { | |
| if (e.target.checked) { | |
| let perm = "granted"; | |
| if (!hasBridge() && typeof Notification !== "undefined") | |
| perm = await Notification.requestPermission(); | |
| if (perm !== "granted") { | |
| e.target.checked = false; | |
| return toast("Уведомления запрещены в настройках браузера"); | |
| } | |
| localStorage.setItem("notify", "1"); | |
| subscribePush(); | |
| toast("Уведомления включены"); | |
| } else { | |
| localStorage.setItem("notify", "0"); | |
| unsubscribePush(); | |
| toast("Уведомления выключены"); | |
| } | |
| }; | |
| }); | |
| function handleWS(msg) { | |
| switch (msg.type) { | |
| case "message": { | |
| const m = msg.message; | |
| cacheAppend(m); | |
| maybeNotify(m); | |
| const inOpen = m.group | |
| ? currentGroup?.id === m.group | |
| : currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id); | |
| if (inOpen) { | |
| appendMessage(m); | |
| // Входящее в открытой личной переписке — сразу отмечаем прочитанным. | |
| if (!m.group && m.from !== me.id) { delete typingState[m.from]; renderTypingStatus(); sendRead(); } | |
| } | |
| if (m.thread_root) { // комментарий: дописать в открытый тред и обновить счётчик | |
| onCommentMessage(m); | |
| bumpCommentCount(m.thread_root); | |
| } | |
| loadChats(); // обновляем превью в списке чатов | |
| break; | |
| } | |
| case "typing": { | |
| const open = msg.group ? currentGroup?.id === msg.group | |
| : currentPeer && currentPeer.id === msg.from; | |
| if (open && msg.from !== me.id) noteTyping(msg.from, msg.from_name); | |
| break; | |
| } | |
| case "read": | |
| if (!currentGroup && currentPeer && msg.with === currentPeer.id) { | |
| peerReadUpto = Math.max(peerReadUpto, msg.upto || 0); | |
| updateTicks(); | |
| } | |
| break; | |
| case "history": | |
| if (msg.thread) { renderCommentHistory(msg); break; } // тред комментариев | |
| // Свежая история с сервера — источник истины, перезаписываем кэш. | |
| cacheSave(msg.group ? "g" + msg.group : msg.with, | |
| { messages: msg.messages, pinned: msg.pinned || [] }); | |
| if (msg.group ? currentGroup?.id === msg.group | |
| : currentPeer && msg.with === currentPeer.id) { | |
| if (!msg.group) peerReadUpto = msg.peer_read || 0; // докуда собеседник прочитал | |
| $("#messages").innerHTML = ""; | |
| msg.messages.forEach(appendMessage); | |
| scrollMessages(); | |
| pinnedMsgs = msg.pinned || []; | |
| renderPinnedBar(); | |
| if (!msg.group) sendRead(); // отметить прочитанным то, что пришло | |
| } | |
| break; | |
| case "message_edited": { | |
| cacheUpdate(msg.message); | |
| const div = $(`#messages [data-id="${msg.message.id}"]`); | |
| if (div) fillMessage(div, msg.message); | |
| pinnedMsgs = pinnedMsgs.map(p => (p.id === msg.message.id ? msg.message : p)); | |
| renderPinnedBar(); | |
| loadChats(); | |
| break; | |
| } | |
| case "message_deleted": | |
| cacheDelete(cachePeer(msg), msg.id); | |
| $(`#messages [data-id="${msg.id}"]`)?.remove(); | |
| pinnedMsgs = pinnedMsgs.filter(p => p.id !== msg.id); | |
| renderPinnedBar(); | |
| loadChats(); | |
| break; | |
| case "message_pinned": { | |
| const m = msg.message; | |
| cacheUpdate(m); | |
| const div = $(`#messages [data-id="${m.id}"]`); | |
| if (div) fillMessage(div, m); // обновить значок 📌 в пузыре | |
| const inOpen = m.group | |
| ? currentGroup?.id === m.group | |
| : currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id); | |
| if (inOpen) { | |
| pinnedMsgs = pinnedMsgs.filter(p => p.id !== m.id); | |
| if (m.extra?.pinned) { | |
| pinnedMsgs.push(m); | |
| pinnedMsgs.sort((a, b) => a.id - b.id); | |
| } | |
| renderPinnedBar(); | |
| } | |
| break; | |
| } | |
| case "reaction": { | |
| cacheReaction(cachePeer(msg), msg.id, msg.reactions); | |
| const div = $(`#messages [data-id="${msg.id}"]`); | |
| if (div) { | |
| div._m.reactions = msg.reactions; | |
| renderReactions(div, div._m); | |
| } | |
| break; | |
| } | |
| case "chat_deleted": | |
| cacheDrop(msg.with); // переписка удалена — чистим её кэш | |
| if (currentPeer?.id === msg.with) openView("empty"); | |
| loadChats(); | |
| break; | |
| case "group_removed": // вышли из группы либо её удалили | |
| cacheDrop("g" + msg.group); | |
| if (currentGroup?.id === msg.group) openView("empty"); | |
| loadChats(); | |
| break; | |
| case "group_added": // нас добавили в группу — она появляется в списке чатов | |
| loadChats(); | |
| break; | |
| case "moderation": // мут/кик/бан/назначение — уведомление и обновление состояния | |
| toast(msg.text); | |
| if (currentGroup?.id === msg.group) refreshGroupHead(); | |
| loadChats(); | |
| break; | |
| case "error": | |
| showChatError(msg.error); | |
| break; | |
| } | |
| } | |
| function showChatError(text) { | |
| const el = $("#chat-error"); | |
| el.textContent = text; | |
| el.hidden = false; | |
| clearTimeout(el._t); | |
| el._t = setTimeout(() => (el.hidden = true), 4000); | |
| } | |
| /* ===== Список чатов ===== */ | |
| /** Кружок-аватарка: картинка/гиф (img), видео (video) либо первая буква имени. */ | |
| function avaNode(peerId, username, name, kind) { | |
| const div = document.createElement("div"); | |
| div.className = "ava"; | |
| div.textContent = (name || "?")[0].toUpperCase(); | |
| if (username) { | |
| const url = "/api/avatar/" + encodeURIComponent(username); | |
| if (kind === "video") { | |
| const v = document.createElement("video"); | |
| v.src = url; | |
| v.autoplay = v.muted = v.loop = true; | |
| v.playsInline = true; | |
| v.onerror = () => v.remove(); // нет аватарки или скрыта приватностью | |
| div.appendChild(v); | |
| } else { // image/gif — гиф анимируется в <img> сам | |
| const img = document.createElement("img"); | |
| img.src = url; | |
| img.onerror = () => img.remove(); | |
| div.appendChild(img); | |
| } | |
| } | |
| return div; | |
| } | |
| async function loadChats() { | |
| const { ok, data } = await api("/api/chats"); | |
| if (!ok) return; | |
| const list = $("#chat-list"); | |
| list.innerHTML = ""; | |
| if (!data.length) { | |
| list.innerHTML = `<p class="empty">Чатов пока нет.<br>Найдите собеседника через поиск.</p>`; | |
| return; | |
| } | |
| for (const c of data) { | |
| const row = document.createElement("button"); | |
| const info = document.createElement("div"); | |
| info.className = "chat-row-info"; | |
| const name = document.createElement("span"); | |
| const preview = document.createElement("small"); | |
| if (c.group_id) { // групповой чат | |
| row.className = "chat-row" + (currentGroup?.id === c.group_id ? " active" : ""); | |
| row.appendChild(groupAvaNode(c.group_id, c.group_title)); | |
| name.textContent = c.group_title; | |
| preview.textContent = !c.kind ? "Группа создана" | |
| : (c.from_me ? "Вы: " : c.sender_name ? c.sender_name + ": " : "") | |
| + (c.kind === "photo" ? "Фото" : c.kind === "gif" ? "GIF" | |
| : c.kind === "call" ? "Видеозвонок" | |
| : c.kind === "audio" ? (c.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио") | |
| : c.kind === "file" ? (c.extra?.name || "Файл") : c.content); | |
| row.onclick = () => openGroup(c.group_id); | |
| row.oncontextmenu = e => openGroupRowMenu(e, c.group_id); | |
| } else { | |
| row.className = "chat-row" + (currentPeer?.id === c.peer_id ? " active" : ""); | |
| row.appendChild(avaNode(c.peer_id, c.peer_deleted ? null : c.peer_username, | |
| c.peer_name, c.peer_avatar_kind)); | |
| name.textContent = c.peer_deleted ? "Удалённый аккаунт" : c.peer_name; | |
| preview.textContent = (c.from_me ? "Вы: " : "") | |
| + (c.kind === "photo" ? "Фото" : c.kind === "gif" ? "GIF" | |
| : c.kind === "call" ? "Видеозвонок" | |
| : c.kind === "audio" ? (c.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио") | |
| : c.kind === "file" ? (c.extra?.name || "Файл") : c.content); | |
| row.onclick = () => openChat(c.peer_id); | |
| row.oncontextmenu = e => openChatMenu(e, c.peer_id); // ПКМ — удалить переписку | |
| } | |
| info.append(name, preview); | |
| const time = document.createElement("time"); | |
| time.textContent = fmtSmart(c.created_at); | |
| row.append(info, time); | |
| list.appendChild(row); | |
| } | |
| } | |
| /* ===== Кэш сообщений (localStorage) ===== | |
| Открытый ранее чат показывается мгновенно из кэша, затем содержимое | |
| заменяется свежей историей с сервера. Кэш синхронизируется с событиями | |
| WS: правки, удаления, реакции и закрепления применяются и к кэшу, | |
| даже если чат сейчас не открыт. Размер и очистка — в настройках «Чаты». */ | |
| const CACHE_LIMIT = 300; // храним не больше N последних сообщений на чат | |
| const cacheKey = peerId => `msgCache:${me.id}:${peerId}`; | |
| const cacheKeys = () => Object.keys(localStorage).filter(k => k.startsWith(`msgCache:${me.id}:`)); | |
| // Ключ чата для сообщения: группа — "g<id>", личный — id собеседника. | |
| const cachePeer = m => (m.group ? "g" + m.group : m.from === me.id ? m.to : m.from); | |
| function cacheLoad(peerId) { | |
| try { return JSON.parse(localStorage.getItem(cacheKey(peerId))); } | |
| catch { return null; } | |
| } | |
| function cacheSave(peerId, data) { | |
| data.messages = data.messages.slice(-CACHE_LIMIT); | |
| try { localStorage.setItem(cacheKey(peerId), JSON.stringify(data)); } | |
| catch { localStorage.removeItem(cacheKey(peerId)); } // хранилище переполнено | |
| } | |
| /** Новое сообщение: дописываем в существующий кэш (новый создаст история). */ | |
| function cacheAppend(m) { | |
| const peerId = cachePeer(m); | |
| const data = cacheLoad(peerId); | |
| if (!data) return; | |
| if (!data.messages.some(x => x.id === m.id)) data.messages.push(m); | |
| cacheSave(peerId, data); | |
| } | |
| /** Правка или закрепление: заменяем сообщение по id, обновляем закреплённые. */ | |
| function cacheUpdate(m) { | |
| const peerId = cachePeer(m); | |
| const data = cacheLoad(peerId); | |
| if (!data) return; | |
| data.messages = data.messages.map(x => (x.id === m.id ? m : x)); | |
| data.pinned = (data.pinned || []).filter(p => p.id !== m.id); | |
| if (m.extra?.pinned) { | |
| data.pinned.push(m); | |
| data.pinned.sort((a, b) => a.id - b.id); | |
| } | |
| cacheSave(peerId, data); | |
| } | |
| /** Удалённое сообщение пропадает и из кэша. */ | |
| function cacheDelete(peerId, id) { | |
| const data = cacheLoad(peerId); | |
| if (!data) return; | |
| data.messages = data.messages.filter(x => x.id !== id); | |
| data.pinned = (data.pinned || []).filter(p => p.id !== id); | |
| cacheSave(peerId, data); | |
| } | |
| function cacheReaction(peerId, id, reactions) { | |
| const data = cacheLoad(peerId); | |
| if (!data) return; | |
| for (const list of [data.messages, data.pinned || []]) | |
| for (const m of list) if (m.id === id) m.reactions = reactions; | |
| cacheSave(peerId, data); | |
| } | |
| function cacheDrop(peerId) { localStorage.removeItem(cacheKey(peerId)); } | |
| function clearMsgCache() { cacheKeys().forEach(k => localStorage.removeItem(k)); } | |
| /** Вес кэша в байтах (строки localStorage — UTF-16, 2 байта на символ). */ | |
| function msgCacheSize() { | |
| let chars = 0; | |
| for (const k of cacheKeys()) chars += k.length + (localStorage.getItem(k) || "").length; | |
| return chars * 2; | |
| } | |
| function renderCacheSize() { | |
| const n = msgCacheSize(); | |
| $("#cache-size").textContent = n ? fmtSize(n) : "пусто"; | |
| $("#cache-clear").hidden = !n; | |
| } | |
| /* ===== Чат ===== */ | |
| async function openChat(peerId) { | |
| openView("chat"); | |
| currentPeer = { id: peerId }; | |
| currentGroup = null; | |
| peerReadUpto = 0; | |
| resetTyping(); | |
| clearReply(); | |
| clearEdit(); | |
| $("#messages").innerHTML = ""; | |
| // Чат открывается мгновенно из кэша; ответ "history" заменит его свежим. | |
| const cached = cacheLoad(peerId); | |
| if (cached) { | |
| cached.messages.forEach(appendMessage); | |
| scrollMessages(); | |
| } | |
| pinnedMsgs = cached?.pinned || []; | |
| renderPinnedBar(); | |
| await refreshChatHead(); | |
| ws?.send(JSON.stringify({ type: "history", with: peerId })); | |
| clearInterval(statusTimer); | |
| statusTimer = setInterval(refreshChatHead, 30000); // статус обновляется каждые 30 с | |
| loadChats(); | |
| } | |
| /** Обновляет шапку чата (имя, статус) и доступность поля ввода. */ | |
| async function refreshChatHead() { | |
| if (!currentPeer) return; | |
| const { ok, data } = await api("/api/peer/" + currentPeer.id); | |
| if (!ok) return; | |
| currentPeer = data; | |
| setName($("#chat-name"), data.deleted ? "Удалённый аккаунт" : data.display_name, data.is_admin); | |
| if (!Object.keys(typingState).length) $("#chat-status").textContent = fmtStatus(data); | |
| $("#chat-ava").replaceWith(Object.assign( | |
| avaNode(data.id, data.username, data.display_name, data.avatar_kind), { id: "chat-ava" })); | |
| // Поле ввода заменяется на причину, по которой писать нельзя. | |
| const reason = me.banned ? "Ваш аккаунт заблокирован" | |
| : data.deleted ? "Аккаунт удалён" | |
| : data.banned ? "Аккаунт заблокирован" | |
| : data.blocked_me ? "Пользователь заблокировал вас" | |
| : data.i_blocked ? "Вы заблокировали пользователя" | |
| : (data.is_system && data.username === "reports") ? "Автоматизированный аккаунт" | |
| // В @salgram пишет только админ; остальным он шлёт рассылки и уведомления. | |
| : (data.is_system && data.username === "salgram" && !me.is_admin) | |
| ? "Официальный аккаунт SalGram" : ""; | |
| // В чате с ботом @addgif вместо поля ввода — кнопка добавления GIF в библиотеку. | |
| const isAddgif = data.is_system && data.username === "addgif"; | |
| $("#addgif-bar").hidden = !isAddgif || !!me.banned; | |
| $("#channel-bar").hidden = true; // панель подписчика канала — только в канале | |
| $("#msg-form").hidden = !!reason || isAddgif; | |
| $("#chat-blocked").hidden = !reason; | |
| $("#chat-blocked").textContent = reason; | |
| $("#chat-delete").hidden = !!me.banned; // в режиме «только чтение» удалять нельзя | |
| } | |
| function scrollMessages() { | |
| const box = $("#messages"); | |
| box.scrollTop = box.scrollHeight; | |
| } | |
| /* ===== Группы ===== */ | |
| /** Кружок-аватарка группы: картинка либо первая буква названия. */ | |
| function groupAvaNode(gid, title) { | |
| const div = document.createElement("div"); | |
| div.className = "ava"; | |
| div.textContent = (title || "?")[0].toUpperCase(); | |
| const img = document.createElement("img"); | |
| img.src = "/api/group_avatar/" + gid; | |
| img.onerror = () => img.remove(); // аватарки нет — остаётся буква | |
| div.appendChild(img); | |
| return div; | |
| } | |
| /** «5 участников» с правильным склонением. */ | |
| function fmtMembers(n) { | |
| const d = n % 10, h = n % 100; | |
| const word = d === 1 && h !== 11 ? "участник" | |
| : d >= 2 && d <= 4 && (h < 12 || h > 14) ? "участника" : "участников"; | |
| return `${n} ${word}`; | |
| } | |
| /** «5 подписчиков» с правильным склонением (для каналов). */ | |
| function fmtSubscribers(n) { | |
| const d = n % 10, h = n % 100; | |
| const word = d === 1 && h !== 11 ? "подписчик" | |
| : d >= 2 && d <= 4 && (h < 12 || h > 14) ? "подписчика" : "подписчиков"; | |
| return `${n} ${word}`; | |
| } | |
| /** Сводка участников/подписчиков по типу (группа или канал). */ | |
| const fmtAudience = g => g.is_channel ? fmtSubscribers(g.members) : fmtMembers(g.members); | |
| async function openGroup(gid) { | |
| openView("chat"); | |
| currentPeer = null; | |
| currentGroup = { id: gid }; | |
| peerReadUpto = 0; | |
| resetTyping(); | |
| clearReply(); | |
| clearEdit(); | |
| $("#messages").innerHTML = ""; | |
| // Как и личные чаты, группа открывается мгновенно из кэша. | |
| const cached = cacheLoad("g" + gid); | |
| if (cached) { | |
| cached.messages.forEach(appendMessage); | |
| scrollMessages(); | |
| } | |
| pinnedMsgs = cached?.pinned || []; | |
| renderPinnedBar(); | |
| await refreshGroupHead(); | |
| ws?.send(JSON.stringify({ type: "history", group: gid })); | |
| clearInterval(statusTimer); | |
| statusTimer = setInterval(refreshGroupHead, 30000); // число участников в шапке | |
| loadChats(); | |
| } | |
| /** Шапка группового чата: название, число участников, доступность ввода. */ | |
| async function refreshGroupHead() { | |
| if (!currentGroup) return; | |
| const { ok, data } = await api("/api/groups/" + currentGroup.id); | |
| if (!ok) return; | |
| currentGroup = data; | |
| $("#chat-name").textContent = data.title; | |
| if (!Object.keys(typingState).length) $("#chat-status").textContent = fmtAudience(data); | |
| $("#chat-ava").replaceWith(Object.assign( | |
| groupAvaNode(data.id, data.title), { id: "chat-ava" })); | |
| // В канале публикует только владелец/админ; подписчики читают и комментируют. | |
| const canPost = !data.is_channel || data.is_owner || me.is_admin; | |
| $("#addgif-bar").hidden = true; // панель бота — только в личном чате с @addgif | |
| // Подписчику канала вместо поля ввода — кнопки «Открыть чат» и «Отписаться». | |
| const subscriberBar = data.is_channel && data.is_member && !canPost && !me.banned; | |
| $("#channel-bar").hidden = !subscriberBar; | |
| $("#channel-open-chat").hidden = !data.linked_group_id; // нет обсуждения — нет кнопки | |
| $("#channel-open-chat").onclick = () => openComments(data.id); | |
| $("#channel-unsub").onclick = () => leaveGroup(data.id, true); | |
| $("#msg-form").hidden = !!me.banned || !data.is_member || !canPost || !!data.my_muted; | |
| // Текст «недоступно» — только для мута/бана; у подписчика канала вместо него кнопки. | |
| const reason = me.banned ? "Ваш аккаунт заблокирован" | |
| : data.my_muted ? "Вы не можете писать в этой группе (мут)" : ""; | |
| $("#chat-blocked").hidden = !reason; | |
| $("#chat-blocked").textContent = reason; | |
| const del = $("#chat-delete"); | |
| del.hidden = !!me.banned; | |
| del.title = data.is_owner ? (data.is_channel ? "Удалить канал" : "Удалить группу") | |
| : (data.is_channel ? "Отписаться" : "Выйти из группы"); | |
| } | |
| async function openGroupProfile(gid) { | |
| const { ok, data } = await api("/api/groups/" + gid); | |
| if (!ok) return toast(data.error || "Группа не найдена"); | |
| showGroupProfile(data); | |
| } | |
| /** invite — хэш ссылки, по которой пришёл не-участник (вступление идёт через неё). */ | |
| function showGroupProfile(g, invite) { | |
| openView("group"); | |
| const ava = $("#group-ava"); | |
| ava.textContent = (g.title || "?")[0].toUpperCase(); // сбрасывает и старую картинку | |
| if (g.has_avatar) { | |
| const img = document.createElement("img"); | |
| img.src = `/api/group_avatar/${g.id}?v=${Date.now()}`; | |
| img.onerror = () => img.remove(); | |
| ava.appendChild(img); | |
| } | |
| // Владелец меняет аватарку кликом по кружку. | |
| const canEditAva = g.is_owner && !me.banned; | |
| ava.classList.toggle("editable", canEditAva); | |
| ava.title = canEditAva ? "Изменить аватарку" : ""; | |
| ava.onclick = canEditAva ? () => $("#group-avatar-input").click() : null; | |
| $("#group-avatar-input").onchange = async e => { | |
| const file = e.target.files[0]; | |
| e.target.value = ""; | |
| if (!file) return; | |
| const fd = new FormData(); | |
| fd.append("avatar", file); | |
| const res = await fetch(`/api/groups/${g.id}/avatar`, { method: "POST", body: fd }); | |
| if (!res.ok) { | |
| const data = await res.json().catch(() => ({})); | |
| return toast(data.error || "Не удалось загрузить аватарку"); | |
| } | |
| openGroupProfile(g.id); // перерисовать профиль с новой аватаркой | |
| loadChats(); // и список чатов | |
| }; | |
| $("#group-title").textContent = g.title; | |
| const kindWord = g.is_channel ? "канал" : "группа"; | |
| const pubWord = g.public ? (g.is_channel ? "Публичный" : "Публичная") | |
| : (g.is_channel ? "Приватный" : "Приватная"); | |
| $("#group-meta").textContent = `${pubWord} ${kindWord}` | |
| + (g.username ? ` · @${g.username}` : "") + ` · ${fmtAudience(g)}`; | |
| $("#group-open").hidden = !g.is_member; | |
| $("#group-open").onclick = () => openGroup(g.id); | |
| $("#group-join").hidden = g.is_member || !!me.banned; | |
| $("#group-join").textContent = g.is_channel ? "Подписаться" : "Вступить"; | |
| $("#group-join").onclick = async () => { | |
| const { ok, data } = await api( | |
| invite ? "/api/groups/join/" + invite : `/api/groups/${g.id}/join`, {}); | |
| if (!ok) return toast(data.error || "Не удалось вступить"); | |
| toast(g.is_channel ? `Вы подписались на «${g.title}»` : `Вы вступили в группу «${g.title}»`); | |
| openGroup(g.id); | |
| loadChats(); | |
| }; | |
| // Пригласительная ссылка — только участникам (сервер не отдаёт хэш чужим). | |
| const inviteRow = $("#group-invite-row"); | |
| inviteRow.hidden = !g.invite_hash; | |
| if (g.invite_hash) { | |
| const link = location.origin + "/#join=" + g.invite_hash; | |
| $("#group-invite").textContent = link; | |
| $("#group-invite-copy").onclick = async () => { | |
| try { await navigator.clipboard.writeText(link); toast("Ссылка скопирована"); } | |
| catch { toast(link); } // буфер недоступен — показываем ссылку | |
| }; | |
| } | |
| const sec = $("#group-members-sec"); | |
| sec.hidden = !g.member_list; | |
| $("#group-members-title").textContent = g.is_channel ? "Подписчики" : "Участники"; | |
| // В группе добавить участника может любой участник; в канал подписчиков добавляет | |
| // только владелец/админ. В режиме «только чтение» (бан) — нельзя. | |
| const addBtn = $("#group-add-member"); | |
| addBtn.hidden = !g.is_member || !!me.banned | |
| || (g.is_channel && !g.is_owner && !me.is_admin); | |
| addBtn.title = g.is_channel ? "Добавить подписчика" : "Добавить участника"; | |
| addBtn.onclick = () => openAddMemberDialog(g.id); | |
| if (g.member_list) { | |
| const ul = $("#group-member-list"); | |
| ul.innerHTML = ""; | |
| for (const u of g.member_list) { | |
| const li = document.createElement("li"); | |
| const name = document.createElement("span"); | |
| name.textContent = u.display_name; | |
| const role = u.is_owner ? " · владелец" : u.is_admin ? " · админ" : ""; | |
| const uname = document.createElement("small"); | |
| uname.textContent = "@" + u.username + role + (u.muted ? " · мут" : ""); | |
| li.append(name, uname); | |
| // Кнопка действий модерации: для модераторов, кроме владельца и себя; | |
| // админа может модерировать только владелец/глобальный админ. | |
| const canActHere = g.can_moderate && !u.is_owner && u.id !== me.id | |
| && (!u.is_admin || g.is_owner || me.is_admin) && !me.banned; | |
| if (canActHere) { | |
| const act = document.createElement("button"); | |
| act.className = "icon-btn member-actions"; | |
| act.textContent = "⋮"; | |
| act.title = "Действия модерации"; | |
| act.onclick = e => { e.stopPropagation(); openMemberMenu(e, g, u); }; | |
| li.appendChild(act); | |
| } | |
| li.style.cursor = "pointer"; | |
| li.onclick = () => openUserProfile(u.id); | |
| ul.appendChild(li); | |
| } | |
| } | |
| renderChannelExtra(g); // комментарии канала: открыть / привязать / отвязать группу | |
| renderModeration(g); // настройки модерации (для владельца/админов) | |
| $("#group-leave").hidden = !g.is_member || g.is_owner || !!me.banned; | |
| $("#group-leave").textContent = g.is_channel ? "Отписаться" : "Выйти из группы"; | |
| $("#group-leave").onclick = () => leaveGroup(g.id, g.is_channel); | |
| $("#group-delete").hidden = !(g.is_owner || me.is_admin) || !!me.banned; | |
| $("#group-delete").textContent = g.is_channel ? "Удалить канал" : "Удалить группу"; | |
| $("#group-delete").onclick = () => deleteGroup(g.id); | |
| } | |
| /** Блок комментариев канала в его профиле: переход к комментариям и (для владельца) | |
| * привязка/отвязка группы для комментариев. */ | |
| function renderChannelExtra(g) { | |
| const box = $("#group-channel-extra"); | |
| box.innerHTML = ""; | |
| if (!g.is_channel) return; | |
| if (g.linked_group_id && g.is_member) { | |
| const open = document.createElement("button"); | |
| open.className = "primary write-btn"; | |
| open.textContent = "Комментарии"; | |
| open.onclick = () => openComments(g.id); | |
| box.appendChild(open); | |
| } | |
| if ((g.is_owner || me.is_admin) && !me.banned) { | |
| const ctl = document.createElement("button"); | |
| ctl.className = "link"; | |
| if (g.linked_group_id) { | |
| ctl.textContent = `Отвязать комментарии (${g.linked_group_title || "группа"})`; | |
| ctl.onclick = () => confirmDialog("Отвязать группу комментариев?", async () => { | |
| const { ok, data } = await api(`/api/channels/${g.id}/unlink`, {}); | |
| if (!ok) return toast(data.error || "Ошибка"); | |
| openGroupProfile(g.id); | |
| }); | |
| } else { | |
| ctl.textContent = "Привязать группу для комментариев"; | |
| ctl.onclick = () => linkCommentsDialog(g.id); | |
| } | |
| box.appendChild(ctl); | |
| } | |
| } | |
| /** Открытие комментариев канала: вступаем в привязанную группу и открываем её чат. */ | |
| async function openComments(cid) { | |
| const { ok, data } = await api(`/api/channels/${cid}/comments`, {}); | |
| if (!ok) return toast(data.error || "Комментарии недоступны"); | |
| openGroup(data.id); | |
| loadChats(); | |
| } | |
| /* ===== Окно комментариев (тред под постом канала, как в Telegram) ===== */ | |
| let commentThread = null; // { cid, discGid, rootId, replyTo, byId, listEl, replyBar, input } | |
| const commentShort = m => m.kind === "text" ? m.content | |
| : m.kind === "photo" ? "Фото" : m.kind === "gif" ? "GIF" | |
| : m.kind === "audio" ? (m.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио") | |
| : m.kind === "file" ? (m.extra?.name || "Файл") : "сообщение"; | |
| /** Тело комментария: текст/фото/гиф/файл (как в обычном сообщении). */ | |
| function renderCommentBody(div, m) { | |
| if (m.kind === "photo") { | |
| const img = document.createElement("img"); | |
| img.src = "/api/photos/" + m.content; | |
| img.loading = "lazy"; | |
| img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; | |
| div.appendChild(img); | |
| } else if (m.kind === "gif") { | |
| const img = document.createElement("img"); | |
| img.className = "msg-gif"; | |
| img.src = "/api/gifs/" + m.content; | |
| img.loading = "lazy"; | |
| img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; | |
| div.appendChild(img); | |
| } else if (m.kind === "file") { | |
| renderFile(div, m); | |
| } else { | |
| const p = document.createElement("p"); | |
| renderMentions(p, m.content); | |
| div.appendChild(p); | |
| } | |
| } | |
| /** Узел комментария в треде. isRoot — корневой пост сверху. */ | |
| function renderCommentNode(m, isRoot) { | |
| const div = document.createElement("div"); | |
| div.className = "comment" + (isRoot ? " root" : (m.from === me.id ? " mine" : "")); | |
| div.dataset.id = m.id; | |
| div._m = m; | |
| if (m.from_name && !isRoot) { | |
| const a = document.createElement("span"); | |
| a.className = "msg-author"; | |
| a.textContent = m.from_name; | |
| a.onclick = e => { e.stopPropagation(); openUserProfile(m.from); }; | |
| div.appendChild(a); | |
| } | |
| // Цитата родителя (ответ на другой комментарий, а не на сам пост). | |
| if (!isRoot && m.reply_to && m.reply_to !== commentThread?.rootId) { | |
| const parent = commentThread?.byId.get(m.reply_to); | |
| const q = document.createElement("div"); | |
| q.className = "reply-quote"; | |
| q.textContent = parent ? commentShort(parent).slice(0, 80) : "комментарий"; | |
| div.appendChild(q); | |
| } | |
| renderCommentBody(div, m); | |
| const time = document.createElement("time"); | |
| time.textContent = fmtSmart(m.created_at); | |
| div.appendChild(time); | |
| if (!isRoot && !me.banned) { | |
| const r = document.createElement("button"); | |
| r.className = "link comment-reply"; | |
| r.textContent = "Ответить"; | |
| r.onclick = e => { e.stopPropagation(); setCommentReply(m); }; | |
| div.appendChild(r); | |
| } | |
| return div; | |
| } | |
| function setCommentReply(m) { | |
| if (!commentThread) return; | |
| commentThread.replyTo = m.id; | |
| commentThread.replyBar.hidden = false; | |
| commentThread.replyBar.querySelector("span").textContent = "В ответ: " + commentShort(m).slice(0, 60); | |
| commentThread.input.focus(); | |
| } | |
| function clearCommentReply() { | |
| if (!commentThread) return; | |
| commentThread.replyTo = commentThread.rootId; | |
| commentThread.replyBar.hidden = true; | |
| } | |
| /** Открыть окно комментариев под постом. cid — канал, rootId — id поста-зеркала. */ | |
| async function openCommentsThread(cid, rootId) { | |
| // Вступаем в привязанную группу-обсуждение (чтобы читать/писать) и получаем её id. | |
| const { ok, data } = await api(`/api/channels/${cid}/comments`, {}); | |
| if (!ok) return toast(data.error || "Комментарии недоступны"); | |
| closeCommentsThread(); | |
| const wrap = document.createElement("div"); | |
| wrap.className = "thread-modal"; | |
| wrap.innerHTML = | |
| `<div class="thread-head"> | |
| <span class="thread-title">Комментарии</span> | |
| <button type="button" class="icon-btn thread-close" title="Закрыть">✕</button> | |
| </div> | |
| <div class="thread-root"></div> | |
| <div class="thread-list"></div> | |
| <div class="thread-reply" hidden><span></span> | |
| <button type="button" class="icon-btn" title="Отменить">✕</button></div> | |
| <form class="thread-form"> | |
| <input class="thread-input" placeholder="Комментарий" autocomplete="off" maxlength="4096"> | |
| <button class="icon-btn big" title="Отправить"> | |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> | |
| </button> | |
| </form>`; | |
| document.body.appendChild(wrap); | |
| commentThread = { | |
| cid, discGid: data.id, rootId, replyTo: rootId, byId: new Map(), | |
| el: wrap, rootEl: wrap.querySelector(".thread-root"), | |
| listEl: wrap.querySelector(".thread-list"), | |
| replyBar: wrap.querySelector(".thread-reply"), | |
| input: wrap.querySelector(".thread-input"), | |
| }; | |
| wrap.querySelector(".thread-close").onclick = closeCommentsThread; | |
| commentThread.replyBar.querySelector("button").onclick = clearCommentReply; | |
| wrap.querySelector(".thread-form").addEventListener("submit", e => { | |
| e.preventDefault(); | |
| const text = commentThread.input.value.trim(); | |
| if (!text || ws?.readyState !== 1) return; | |
| ws.send(JSON.stringify({ type: "send", group: commentThread.discGid, text, | |
| reply_to: commentThread.replyTo })); | |
| commentThread.input.value = ""; | |
| clearCommentReply(); | |
| }); | |
| document.addEventListener("keydown", commentThreadKey); | |
| // Запрос истории треда (корень + комментарии). | |
| ws?.send(JSON.stringify({ type: "history", group: data.id, thread: rootId })); | |
| } | |
| function commentThreadKey(e) { if (e.key === "Escape") closeCommentsThread(); } | |
| function closeCommentsThread() { | |
| if (!commentThread) return; | |
| document.removeEventListener("keydown", commentThreadKey); | |
| commentThread.el.remove(); | |
| commentThread = null; | |
| } | |
| /** История треда с сервера: рендер корневого поста и комментариев. */ | |
| function renderCommentHistory(msg) { | |
| if (!commentThread || commentThread.rootId !== msg.thread) return; | |
| commentThread.byId.clear(); | |
| commentThread.rootEl.innerHTML = ""; | |
| commentThread.rootEl.appendChild(renderCommentNode(msg.root, true)); | |
| commentThread.listEl.innerHTML = ""; | |
| for (const m of msg.messages) { | |
| commentThread.byId.set(m.id, m); | |
| commentThread.listEl.appendChild(renderCommentNode(m, false)); | |
| } | |
| commentThread.listEl.scrollTop = commentThread.listEl.scrollHeight; | |
| } | |
| /** Новый комментарий пришёл по WS — добавить в открытый тред. */ | |
| function onCommentMessage(m) { | |
| if (!commentThread || m.thread_root !== commentThread.rootId) return; | |
| if (commentThread.listEl.querySelector(`[data-id="${m.id}"]`)) return; | |
| commentThread.byId.set(m.id, m); | |
| const atBottom = commentThread.listEl.scrollHeight - commentThread.listEl.scrollTop | |
| - commentThread.listEl.clientHeight < 60; | |
| commentThread.listEl.appendChild(renderCommentNode(m, false)); | |
| if (atBottom || m.from === me.id) commentThread.listEl.scrollTop = commentThread.listEl.scrollHeight; | |
| } | |
| /** Обновить счётчик «Комментарии N» под постом/дублем в открытом чате. */ | |
| function bumpCommentCount(rootId) { | |
| for (const div of $("#messages").querySelectorAll(".msg")) { | |
| const mm = div._m; | |
| const root = mm?.extra?.disc || (mm?.extra?.channel_post ? mm.id : null); | |
| if (root === rootId) { | |
| mm.comments = (mm.comments || 0) + 1; | |
| fillMessage(div, mm); | |
| } | |
| } | |
| } | |
| /** Диалог привязки группы комментариев: выбор из своих групп (не каналов). */ | |
| async function linkCommentsDialog(cid) { | |
| const { ok, data } = await api("/api/me/groups"); | |
| if (!ok) return toast("Не удалось загрузить группы"); | |
| if (!data.length) | |
| return toast("Нет подходящих групп. Создайте обычную группу для комментариев."); | |
| dialog("Привязать группу для комментариев", [ | |
| { name: "group_id", type: "select", | |
| options: data.map(grp => ({ value: String(grp.id), label: grp.title })) }, | |
| ], "Привязать", async v => { | |
| const r = await api(`/api/channels/${cid}/link`, { group_id: +v.group_id }); | |
| if (!r.ok) return r.data.error; | |
| openGroupProfile(cid); | |
| }); | |
| } | |
| /* ===== Модерация группы/канала ===== */ | |
| /** Кнопка «Настройки модерации» в профиле (для владельца/админов). */ | |
| function renderModeration(g) { | |
| const box = $("#group-mod-row"); | |
| box.innerHTML = ""; | |
| if (!g.can_moderate || me.banned) return; | |
| const b = document.createElement("button"); | |
| b.className = "link"; | |
| b.textContent = "Настройки модерации" + (g.moderation?.enabled ? " (включена)" : ""); | |
| b.onclick = () => openModerationDialog(g); | |
| box.appendChild(b); | |
| } | |
| const splitLines = s => s.split("\n").map(x => x.trim()).filter(Boolean); | |
| /** Диалог настроек модерации: бан-лист, белый список и режим, медленный режим. */ | |
| function openModerationDialog(g) { | |
| const m = g.moderation || {}; | |
| const form = dialog("Модерация", [ | |
| { name: "enabled", type: "select", options: [ | |
| { value: "1", label: "Модерация включена" }, | |
| { value: "0", label: "Модерация выключена" } ] }, | |
| { name: "banned_words", type: "textarea", maxlength: 4000, optional: true, | |
| placeholder: "Запрещённые слова или фразы — по одной на строку" }, | |
| { name: "whitelist_words", type: "textarea", maxlength: 4000, optional: true, | |
| placeholder: "Разрешённые слова или фразы — по одной на строку" }, | |
| { name: "whitelist_mode", type: "select", options: [ | |
| { value: "any", label: "Белый список: хватает одного слова" }, | |
| { value: "all", label: "Белый список: нужны все слова" } ] }, | |
| { name: "slow_seconds", type: "number", optional: true, | |
| placeholder: "Медленный режим (секунды)" }, | |
| ], "Сохранить", async v => { | |
| const r = await api(`/api/groups/${g.id}/moderation`, { | |
| enabled: v.enabled === "1", | |
| banned_words: splitLines(v.banned_words), | |
| whitelist_words: splitLines(v.whitelist_words), | |
| whitelist_mode: v.whitelist_mode, | |
| slow_seconds: Math.max(0, +v.slow_seconds || 0), | |
| }); | |
| if (!r.ok) return r.data.error; | |
| toast("Настройки модерации сохранены"); | |
| openGroupProfile(g.id); | |
| }); | |
| // Заполняем текущими значениями. | |
| form.elements.enabled.value = m.enabled ? "1" : "0"; | |
| form.elements.banned_words.value = (m.banned_words || []).join("\n"); | |
| form.elements.whitelist_words.value = (m.whitelist_words || []).join("\n"); | |
| form.elements.whitelist_mode.value = m.whitelist_mode || "any"; | |
| form.elements.slow_seconds.value = m.slow_seconds || 0; | |
| } | |
| /** Меню действий над участником: мут, кик, бан, (владелец) назначить админом. */ | |
| function openMemberMenu(e, g, u) { | |
| const menu = contextMenu(e); | |
| menuItem(menu, u.muted ? "Размутить" : "Замутить", false, | |
| () => modAction(g.id, u.id, "mute", { muted: !u.muted })); | |
| menuItem(menu, "Исключить", false, () => confirmDialog( | |
| `Исключить ${u.display_name}?`, () => modAction(g.id, u.id, "kick"))); | |
| menuItem(menu, "Заблокировать", true, () => confirmDialog( | |
| `Заблокировать ${u.display_name} в этой группе?`, () => modAction(g.id, u.id, "ban"))); | |
| if (g.is_owner || me.is_admin) | |
| menuItem(menu, u.is_admin ? "Снять администратора" : "Назначить администратором", false, | |
| () => modAction(g.id, u.id, "admin", { admin: !u.is_admin })); | |
| document.body.appendChild(menu); | |
| menu._place(); | |
| } | |
| async function modAction(gid, uid, action, body) { | |
| const { ok, data } = await api(`/api/groups/${gid}/members/${uid}/${action}`, body || {}); | |
| if (!ok) return toast(data.error || "Ошибка"); | |
| openGroupProfile(gid); // обновить список участников и роли | |
| loadChats(); | |
| } | |
| function leaveGroup(gid, isChannel) { | |
| confirmDialog(isChannel ? "Отписаться от канала?" : "Выйти из группы?", async () => { | |
| const { ok, data } = await api(`/api/groups/${gid}/leave`, {}); | |
| if (!ok) return toast(data.error || "Ошибка"); | |
| cacheDrop("g" + gid); | |
| if (currentGroup?.id === gid || !$("#view-group").hidden) openView("empty"); | |
| loadChats(); | |
| }); | |
| } | |
| function deleteGroup(gid) { | |
| confirmDialog("Удалить группу? Все сообщения удалятся у всех участников.", async () => { | |
| const { ok, data } = await api(`/api/groups/${gid}/delete`, {}); | |
| if (!ok) return toast(data.error || "Ошибка"); | |
| cacheDrop("g" + gid); | |
| openView("empty"); | |
| loadChats(); | |
| }); | |
| } | |
| /** Диалог добавления участника: ввод юзернейма + быстрый выбор из контактов. */ | |
| async function openAddMemberDialog(gid) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "modal"; | |
| const card = document.createElement("div"); | |
| card.className = "modal-card dialog"; | |
| card.innerHTML = `<h2>Добавить участника</h2> | |
| <input id="add-member-input" placeholder="Юзернейм пользователя" maxlength="32" autocomplete="off"> | |
| <p class="error" hidden></p> | |
| <ul class="member-picker" id="add-member-contacts"></ul> | |
| <div class="dialog-buttons"> | |
| <button type="button" class="link" data-cancel>Закрыть</button> | |
| <button type="button" class="primary" data-add>Добавить</button> | |
| </div>`; | |
| wrap.appendChild(card); | |
| const errEl = card.querySelector(".error"); | |
| const input = card.querySelector("#add-member-input"); | |
| const close = () => wrap.remove(); | |
| card.querySelector("[data-cancel]").onclick = close; | |
| wrap.addEventListener("click", e => { if (e.target === wrap) close(); }); | |
| const add = async username => { | |
| username = username.trim().replace(/^@/, ""); | |
| errEl.hidden = true; | |
| if (!username) return; | |
| const { ok, data } = await api(`/api/groups/${gid}/add`, { username }); | |
| if (!ok) { errEl.textContent = data.error || "Ошибка"; errEl.hidden = false; return; } | |
| toast(`@${username} добавлен(а) в группу`); | |
| close(); | |
| openGroupProfile(gid); // обновить список участников | |
| loadChats(); | |
| }; | |
| card.querySelector("[data-add]").onclick = () => add(input.value); | |
| input.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); add(input.value); } }); | |
| // Быстрый выбор из списка контактов. | |
| const ul = card.querySelector("#add-member-contacts"); | |
| const { ok, data } = await api("/api/me/contacts"); | |
| if (ok && data.length) { | |
| for (const c of data) { | |
| const li = document.createElement("li"); | |
| const name = document.createElement("span"); | |
| name.textContent = c.display_name; | |
| const uname = document.createElement("small"); | |
| uname.textContent = "@" + c.username; | |
| li.append(name, uname); | |
| li.style.cursor = "pointer"; | |
| li.onclick = () => add(c.username); | |
| ul.appendChild(li); | |
| } | |
| } else { | |
| ul.innerHTML = `<li class="empty">Контактов нет — введите юзернейм выше</li>`; | |
| } | |
| document.body.appendChild(wrap); | |
| input.focus(); | |
| } | |
| // Ссылка-приглашение может прийти и в уже открытое приложение (меняется только hash). | |
| window.addEventListener("hashchange", () => { if (me) handleJoinHash(); }); | |
| /** Открывает группу по хэшу приглашения: участнику — чат, | |
| * остальным — экран группы с кнопкой «Вступить». */ | |
| async function openJoinLink(hash) { | |
| const { ok, data } = await api("/api/groups/hash/" + hash); | |
| if (!ok) return toast(data.error || "Ссылка недействительна"); | |
| if (data.is_member) return openGroup(data.id); | |
| showGroupProfile(data, hash); | |
| } | |
| /** Вступление по пригласительной ссылке /#join=<hash> (работает и для приватных). */ | |
| function handleJoinHash() { | |
| const m = location.hash.match(/^#join=([\w-]{8,64})$/); | |
| if (!m) return; | |
| history.replaceState(null, "", location.pathname); // хэш обработан — убираем из URL | |
| openJoinLink(m[1]); | |
| } | |
| /* --- Панель закреплённого сообщения (показывает последнее закреплённое) --- */ | |
| function renderPinnedBar() { | |
| const bar = $("#pinned-bar"); | |
| const last = pinnedMsgs[pinnedMsgs.length - 1]; | |
| bar.hidden = !last; | |
| if (!last) return; | |
| $("#pinned-text").textContent = last.kind === "photo" ? "Фото" : last.kind === "gif" ? "GIF" | |
| : last.kind === "call" ? "Видеозвонок" | |
| : last.kind === "audio" ? (last.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио") | |
| : last.kind === "file" ? (last.extra?.name || "Файл") : last.content; | |
| bar.onclick = () => { // клик — прокрутка к сообщению с подсветкой | |
| const div = $(`#messages [data-id="${last.id}"]`); | |
| if (!div) return; | |
| div.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| div.classList.add("flash"); | |
| setTimeout(() => div.classList.remove("flash"), 1200); | |
| }; | |
| $("#pinned-unpin").hidden = !!me.banned; | |
| $("#pinned-unpin").onclick = e => { | |
| e.stopPropagation(); | |
| ws?.send(JSON.stringify({ type: "pin", id: last.id, pinned: false })); | |
| }; | |
| } | |
| /* ===== Форматирование текста (жирный, курсив, размер, цвет и т.д.) ===== | |
| Сообщение хранится как обычный текст + список пометок extra.fmt: | |
| {s, e, t, v?} — диапазон [s,e) символов, тип t, значение v (для size/color). | |
| И композер, и отрисовка строят одинаковые <span> с этими классами. */ | |
| const FMT_TYPES = ["b", "i", "u", "s", "code", "quote", "size", "color"]; | |
| const FMT_CLASS = { b: "f-b", i: "f-i", u: "f-u", s: "f-s", code: "f-code", quote: "f-quote" }; | |
| /** Создаёт <span> для одного типа форматирования. */ | |
| function fmtSpan(t, v) { | |
| const span = document.createElement("span"); | |
| if (t === "size") { span.className = "f-size"; span.dataset.size = v; } | |
| else if (t === "color") { span.className = "f-color"; span.dataset.color = v; span.style.color = v; } | |
| else span.className = FMT_CLASS[t]; | |
| return span; | |
| } | |
| const EMPTY_STYLE = () => ({ b: 0, i: 0, u: 0, s: 0, code: 0, quote: 0, size: null, color: null }); | |
| /** Эффективный стиль символа i (для size/color побеждает последняя пометка в списке). */ | |
| function styleAt(marks, i) { | |
| const st = EMPTY_STYLE(); | |
| for (const m of marks) { | |
| if (i < m.s || i >= m.e) continue; | |
| if (m.t === "size") st.size = m.v; | |
| else if (m.t === "color") st.color = m.v; | |
| else st[m.t] = 1; | |
| } | |
| return st; | |
| } | |
| function sameStyle(a, b) { | |
| return a.b === b.b && a.i === b.i && a.u === b.u && a.s === b.s && a.code === b.code | |
| && a.quote === b.quote && a.size === b.size && a.color === b.color; | |
| } | |
| /** Оборачивает строку в нужные span'ы (текст — через textNode, без XSS). */ | |
| function styledNode(text, st) { | |
| let node = document.createTextNode(text); | |
| const wrap = (t, v) => { const sp = fmtSpan(t, v); sp.appendChild(node); node = sp; }; | |
| if (st.code) wrap("code"); | |
| if (st.quote) wrap("quote"); | |
| if (st.b) wrap("b"); | |
| if (st.i) wrap("i"); | |
| if (st.u) wrap("u"); | |
| if (st.s) wrap("s"); | |
| if (st.size && st.size !== "normal") wrap("size", st.size); | |
| if (st.color) wrap("color", st.color); | |
| return node; | |
| } | |
| /** Отрисовывает текст с форматированием в контейнер. Без пометок — обычный | |
| * путь с кликабельными ссылками и @упоминаниями. */ | |
| function renderRich(container, text, marks) { | |
| if (!marks || !marks.length) return renderMentions(container, text); | |
| let i = 0; | |
| while (i < text.length) { | |
| const st = styleAt(marks, i); | |
| let j = i + 1; | |
| while (j < text.length && sameStyle(styleAt(marks, j), st)) j++; | |
| container.appendChild(styledNode(text.slice(i, j), st)); | |
| i = j; | |
| } | |
| } | |
| /* --- Композер: contenteditable #msg-input --- */ | |
| const composer = () => $("#msg-input"); | |
| /** Стиль текстового узла по цепочке предков-обёрток до корня композера. */ | |
| function nodeStyle(node, root) { | |
| const st = EMPTY_STYLE(); | |
| let el = node.parentElement; | |
| while (el && el !== root) { | |
| const c = el.classList; | |
| if (c) { | |
| if (c.contains("f-b")) st.b = 1; | |
| if (c.contains("f-i")) st.i = 1; | |
| if (c.contains("f-u")) st.u = 1; | |
| if (c.contains("f-s")) st.s = 1; | |
| if (c.contains("f-code")) st.code = 1; | |
| if (c.contains("f-quote")) st.quote = 1; | |
| if (st.size === null && el.dataset.size) st.size = el.dataset.size; // innermost-wins | |
| if (st.color === null && el.dataset.color) st.color = el.dataset.color; | |
| } | |
| el = el.parentElement; | |
| } | |
| return st; | |
| } | |
| /** Сырая сериализация композера в {text, marks} БЕЗ обрезки краёв. | |
| * Индексы символов совпадают с boundaryAtChar (BR = 1 символ). */ | |
| function rawSerialize() { | |
| const root = composer(); | |
| let text = ""; | |
| const active = {}; // type -> {s, v} | |
| const marks = []; | |
| const close = (t, cur) => { | |
| const mk = { s: cur.s, e: text.length, t }; | |
| if (cur.v !== true) mk.v = cur.v; | |
| marks.push(mk); | |
| }; | |
| const applyStyle = st => { | |
| for (const t of FMT_TYPES) { | |
| let dv; | |
| if (t === "size") dv = (st.size && st.size !== "normal") ? st.size : null; | |
| else if (t === "color") dv = st.color || null; | |
| else dv = st[t] ? true : null; | |
| const cur = active[t]; | |
| if (dv) { | |
| if (!cur || cur.v !== dv) { if (cur) close(t, cur); active[t] = { s: text.length, v: dv }; } | |
| } else if (cur) { close(t, cur); delete active[t]; } | |
| } | |
| }; | |
| const walk = node => { | |
| for (const child of node.childNodes) { | |
| if (child.nodeType === 3) { applyStyle(nodeStyle(child, root)); text += child.nodeValue; } | |
| else if (child.nodeType === 1) { | |
| if (child.tagName === "BR") { applyStyle(EMPTY_STYLE()); text += "\n"; } | |
| else walk(child); | |
| } | |
| } | |
| }; | |
| walk(root); | |
| for (const t of FMT_TYPES) if (active[t]) close(t, active[t]); | |
| text = text.replace(/ /g, " "); // -> normal space (length unchanged) | |
| return { text, marks }; | |
| } | |
| /** Сериализует содержимое композера в {text, marks} для отправки (с обрезкой краёв). */ | |
| function serializeComposer() { | |
| const { text: raw, marks } = rawSerialize(); | |
| // сервер делает strip(); обрезаем края и сдвигаем пометки, чтобы смещения совпали | |
| const lead = raw.match(/^\s+/); | |
| const shift = lead ? lead[0].length : 0; | |
| const text = raw.trim(); | |
| const shifted = marks.map(m => ({ ...m, s: m.s - shift, e: m.e - shift })); | |
| return { text, marks: clampMarks(shifted, text.length) }; | |
| } | |
| /** Отбрасывает/подрезает пометки по границам текста. */ | |
| function clampMarks(marks, len) { | |
| const out = []; | |
| for (const m of marks) { | |
| const s = Math.max(0, m.s), e = Math.min(m.e, len); | |
| if (s < e) out.push({ ...m, s, e }); | |
| } | |
| return out; | |
| } | |
| /** Длина содержимого узла в «символах» (текст + BR как 1), как в rawSerialize. */ | |
| function measureLen(node) { | |
| let n = 0; | |
| for (const c of node.childNodes) { | |
| if (c.nodeType === 3) n += c.nodeValue.length; | |
| else if (c.nodeType === 1) n += (c.tagName === "BR") ? 1 : measureLen(c); | |
| } | |
| return n; | |
| } | |
| /** Индекс символа для границы выделения (container, offset) в координатах rawSerialize. */ | |
| function charIndexOf(root, container, offset) { | |
| const r = document.createRange(); | |
| r.setStart(root, 0); | |
| r.setEnd(container, offset); | |
| return measureLen(r.cloneContents()); | |
| } | |
| /** Находит позицию (node, offset) по индексу символа — для восстановления выделения. */ | |
| function boundaryAtChar(root, target) { | |
| let idx = 0, found = null; | |
| (function walk(node) { | |
| for (const child of node.childNodes) { | |
| if (found) return; | |
| if (child.nodeType === 3) { | |
| const len = child.nodeValue.length; | |
| if (target <= idx + len) { found = { node: child, offset: target - idx }; return; } | |
| idx += len; | |
| } else if (child.nodeType === 1) { | |
| if (child.tagName === "BR") { | |
| if (target <= idx) { const p = child.parentNode; | |
| found = { node: p, offset: [...p.childNodes].indexOf(child) }; return; } | |
| idx += 1; | |
| } else walk(child); | |
| } | |
| } | |
| })(root); | |
| return found || { node: root, offset: root.childNodes.length }; | |
| } | |
| /** Есть ли формат t у каждого символа диапазона [s,e). */ | |
| function rangeHasFormat(marks, s, e, t) { | |
| if (e <= s) return false; | |
| for (let i = s; i < e; i++) { | |
| const st = styleAt(marks, i); | |
| const has = t === "size" ? (st.size && st.size !== "normal") | |
| : t === "color" ? !!st.color : !!st[t]; | |
| if (!has) return false; | |
| } | |
| return true; | |
| } | |
| /** Убирает формат t из диапазона [s,e), разрезая пометки по краям. */ | |
| function removeFormat(marks, s, e, t) { | |
| const out = []; | |
| for (const m of marks) { | |
| if (m.t !== t) { out.push(m); continue; } | |
| if (m.s < s) out.push({ ...m, e: Math.min(m.e, s) }); // кусок слева | |
| if (m.e > e) out.push({ ...m, s: Math.max(m.s, e) }); // кусок справа | |
| } | |
| return out.filter(m => m.s < m.e); | |
| } | |
| /** Переключает/задаёт формат на текущем выделении (с перерисовкой композера). */ | |
| function applyFormat(t, v) { | |
| editFormat((marks, s, e) => { | |
| if (t === "size" || t === "color") { | |
| const out = removeFormat(marks, s, e, t); | |
| const clear = (t === "size" && (!v || v === "normal")) || (t === "color" && !v); | |
| if (!clear) out.push({ s, e, t, v }); | |
| return out; | |
| } | |
| return rangeHasFormat(marks, s, e, t) | |
| ? removeFormat(marks, s, e, t) | |
| : [...marks, { s, e, t }]; | |
| }); | |
| } | |
| /** Полностью снимает форматирование с выделения. */ | |
| function clearFormatting() { | |
| editFormat((marks, s, e) => { | |
| let out = marks; | |
| for (const t of FMT_TYPES) out = removeFormat(out, s, e, t); | |
| return out; | |
| }); | |
| } | |
| /** Общий механизм: берёт выделение, меняет пометки, перерисовывает и возвращает выделение. */ | |
| function editFormat(transform) { | |
| const root = composer(); | |
| const sel = window.getSelection(); | |
| if (!sel.rangeCount) return; | |
| const range = sel.getRangeAt(0); | |
| if (range.collapsed || !root.contains(range.commonAncestorContainer)) return; | |
| const a = charIndexOf(root, range.startContainer, range.startOffset); | |
| const b = charIndexOf(root, range.endContainer, range.endOffset); | |
| const s = Math.min(a, b), e = Math.max(a, b); | |
| if (s >= e) return; | |
| const { text, marks } = rawSerialize(); | |
| fillComposer(text, transform(marks, s, e)); | |
| // Восстанавливаем выделение по символьным индексам. | |
| const bs = boundaryAtChar(root, s), be = boundaryAtChar(root, e); | |
| const r = document.createRange(); | |
| r.setStart(bs.node, bs.offset); | |
| r.setEnd(be.node, be.offset); | |
| sel.removeAllRanges(); | |
| sel.addRange(r); | |
| root.focus(); | |
| } | |
| /** Контекстное меню форматирования (ПКМ по выделенному тексту в поле ввода). */ | |
| function openFormatMenu(e) { | |
| const root = composer(); | |
| const sel = window.getSelection(); | |
| if (!sel.rangeCount || sel.isCollapsed | |
| || !root.contains(sel.getRangeAt(0).commonAncestorContainer)) | |
| return; // нет выделения — оставляем обычное меню браузера | |
| if (me?.banned) return; | |
| const saved = sel.getRangeAt(0).cloneRange(); | |
| const menu = contextMenu(e); | |
| const restore = () => { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); }; | |
| const item = (label, fn) => menuItem(menu, label, false, ev => { | |
| ev?.stopPropagation(); restore(); fn(); closeMsgMenu(); | |
| }); | |
| item("Жирный", () => applyFormat("b")); | |
| item("Курсив", () => applyFormat("i")); | |
| item("Подчёркнутый", () => applyFormat("u")); | |
| item("Зачёркнутый", () => applyFormat("s")); | |
| item("Цитата", () => applyFormat("quote")); | |
| item("Моноширинный (код)", () => applyFormat("code")); | |
| const div = document.createElement("div"); | |
| div.className = "msg-menu-sep"; | |
| div.textContent = "Размер"; | |
| menu.appendChild(div); | |
| for (const [label, val] of [["Огромный", "huge"], ["Большой", "big"], | |
| ["Обычный", "normal"], ["Маленький", "small"], ["Очень маленький", "tiny"]]) | |
| item(label, () => applyFormat("size", val)); | |
| const sep = document.createElement("div"); | |
| sep.className = "msg-menu-sep"; | |
| menu.appendChild(sep); | |
| item("Цвет…", () => { | |
| const input = $("#fmt-color"); | |
| input.onchange = () => { restore(); applyFormat("color", input.value); }; | |
| input.click(); | |
| }); | |
| const sep2 = document.createElement("div"); | |
| sep2.className = "msg-menu-sep"; | |
| menu.appendChild(sep2); | |
| item("Убрать форматирование", () => clearFormatting()); | |
| document.body.appendChild(menu); | |
| menu._place(); | |
| } | |
| /* --- Плавающая панель форматирования над выделенным текстом в композере. | |
| Появляется при выделении текста, чтобы не лезть в контекстное меню. --- */ | |
| let fmtBar = null; | |
| /** Создаёт панель один раз и возвращает её (ленивая инициализация). */ | |
| function ensureFmtBar() { | |
| if (fmtBar) return fmtBar; | |
| const bar = document.createElement("div"); | |
| bar.className = "fmt-bar"; | |
| bar.hidden = true; | |
| // Клик по кнопке не должен снимать выделение в поле ввода. | |
| bar.addEventListener("mousedown", e => e.preventDefault()); | |
| const btn = (html, title, fn) => { | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.className = "fmt-bar-btn"; | |
| b.innerHTML = html; | |
| b.title = title; | |
| b.addEventListener("click", e => { e.preventDefault(); e.stopPropagation(); fn(b); }); | |
| bar.appendChild(b); | |
| return b; | |
| }; | |
| // Простые переключатели — применяются к текущему выделению. | |
| const apply = t => () => { applyFormat(t); positionFmtBar(); }; | |
| btn("<b>Ж</b>", "Жирный", apply("b")); | |
| btn("<i>К</i>", "Курсив", apply("i")); | |
| btn("<u>Ч</u>", "Подчёркнутый", apply("u")); | |
| btn("<s>З</s>", "Зачёркнутый", apply("s")); | |
| btn("«»", "Цитата", apply("quote")); | |
| btn("</>", "Моноширинный (код)", apply("code")); | |
| btn("A<small>±</small>", "Размер", anchor => openSizeMenu(anchor)); | |
| btn("<span class='fmt-bar-swatch'></span>", "Цвет", () => pickColor()); | |
| // Самая правая кнопка — снять всё форматирование с выделения. | |
| const clearBtn = btn( | |
| "<svg viewBox='0 0 24 24' width='15' height='15' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M4 7V4h16v3'/><path d='M5 20h6'/><path d='M13 4 8 20'/><line x1='18' y1='14' x2='22' y2='18'/><line x1='22' y1='14' x2='18' y2='18'/></svg>", | |
| "Убрать форматирование", () => { clearFormatting(); positionFmtBar(); }); | |
| clearBtn.classList.add("fmt-bar-clear"); | |
| document.body.appendChild(bar); | |
| fmtBar = bar; | |
| return bar; | |
| } | |
| /** Сохраняет выделение, открывает системный выбор цвета и применяет его. */ | |
| function pickColor() { | |
| const sel = window.getSelection(); | |
| const saved = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; | |
| const input = $("#fmt-color"); | |
| input.onchange = () => { | |
| if (saved) { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); } | |
| applyFormat("color", input.value); | |
| positionFmtBar(); | |
| }; | |
| input.click(); | |
| } | |
| /** Маленькое меню выбора размера, привязанное к кнопке панели. */ | |
| function openSizeMenu(anchor) { | |
| closeMsgMenu(); | |
| const sel = window.getSelection(); | |
| const saved = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; | |
| const menu = document.createElement("div"); | |
| menu.className = "msg-menu"; | |
| for (const [label, val] of [["Огромный", "huge"], ["Большой", "big"], | |
| ["Обычный", "normal"], ["Маленький", "small"], ["Очень маленький", "tiny"]]) { | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.className = "msg-menu-item"; | |
| b.textContent = label; | |
| b.addEventListener("mousedown", e => e.preventDefault()); | |
| b.onclick = e => { | |
| e.stopPropagation(); | |
| if (saved) { const s = window.getSelection(); s.removeAllRanges(); s.addRange(saved); } | |
| applyFormat("size", val); | |
| closeMsgMenu(); | |
| positionFmtBar(); | |
| }; | |
| menu.appendChild(b); | |
| } | |
| document.body.appendChild(menu); | |
| const r = anchor.getBoundingClientRect(); | |
| const mr = menu.getBoundingClientRect(); | |
| menu.style.left = Math.max(8, Math.min(r.left, innerWidth - mr.width - 8)) + "px"; | |
| menu.style.top = Math.min(r.bottom + 4, innerHeight - mr.height - 8) + "px"; | |
| } | |
| /** Прячет панель форматирования. */ | |
| function hideFmtBar() { if (fmtBar) fmtBar.hidden = true; } | |
| /** Ставит панель по центру над выделением (или под ним, если сверху не влезает). */ | |
| function positionFmtBar(range) { | |
| const bar = ensureFmtBar(); | |
| const sel = window.getSelection(); | |
| range = range || (sel.rangeCount ? sel.getRangeAt(0) : null); | |
| if (!range) return; | |
| const rect = range.getBoundingClientRect(); | |
| bar.hidden = false; | |
| const br = bar.getBoundingClientRect(); | |
| let left = rect.left + rect.width / 2 - br.width / 2; | |
| left = Math.max(8, Math.min(left, innerWidth - br.width - 8)); | |
| let top = rect.top - br.height - 8; | |
| if (top < 8) top = rect.bottom + 8; // не помещается сверху — показываем снизу | |
| bar.style.left = left + "px"; | |
| bar.style.top = top + "px"; | |
| } | |
| /** Решает, показывать ли панель: есть выделение внутри композера и не забанен. */ | |
| function updateFmtBar() { | |
| const root = composer(); | |
| if (!root) return; | |
| const sel = window.getSelection(); | |
| if (me?.banned || !sel.rangeCount || sel.isCollapsed) return hideFmtBar(); | |
| const range = sel.getRangeAt(0); | |
| if (!root.contains(range.commonAncestorContainer)) return hideFmtBar(); | |
| positionFmtBar(range); | |
| } | |
| document.addEventListener("selectionchange", updateFmtBar); | |
| window.addEventListener("resize", () => { if (fmtBar && !fmtBar.hidden) updateFmtBar(); }); | |
| /* --- Отправка сообщения из композера --- */ | |
| function clearComposer() { composer().innerHTML = ""; updatePlaceholder(); hideFmtBar(); } | |
| function updatePlaceholder() { | |
| const ed = composer(); | |
| ed.classList.toggle("is-empty", !ed.textContent.replace(/ /g, " ").trim()); | |
| } | |
| /** Вставляет перенос строки в позицию каретки (Shift/Ctrl+Enter). */ | |
| function insertBreak() { | |
| // Нативная команда вставляет <br> и сама корректно двигает каретку и ставит | |
| // «филлер» — ручная вставка в Chrome требовала двойного нажатия. | |
| if (document.execCommand("insertLineBreak")) { updatePlaceholder(); return; } | |
| // Запасной путь, если execCommand недоступен. | |
| const sel = window.getSelection(); | |
| if (!sel.rangeCount) return; | |
| const range = sel.getRangeAt(0); | |
| range.deleteContents(); | |
| const br = document.createElement("br"); | |
| range.insertNode(br); | |
| if (!br.nextSibling) br.after(document.createElement("br")); // чтобы каретка перешла вниз | |
| range.setStartAfter(br); | |
| range.collapse(true); | |
| sel.removeAllRanges(); | |
| sel.addRange(range); | |
| updatePlaceholder(); | |
| } | |
| function sendComposer() { | |
| if (ws?.readyState !== 1 || (!currentPeer && !currentGroup)) return; | |
| const { text, marks } = serializeComposer(); | |
| if (!text) return; | |
| if (text.length > 4096) return showChatError("Сообщение: 1–4096 символов"); | |
| // Режим правки: Enter изменяет сообщение, а не отправляет новое. | |
| if (editTarget) { | |
| const out = { type: "edit", id: editTarget.id, text }; | |
| if (marks.length) out.fmt = marks; | |
| ws.send(JSON.stringify(out)); | |
| clearEdit(); | |
| stopTyping(); | |
| return; | |
| } | |
| const out = currentGroup ? { type: "send", group: currentGroup.id, text } | |
| : { type: "send", to: currentPeer.id, text }; | |
| if (marks.length) out.fmt = marks; | |
| if (replyTarget) out.reply_to = replyTarget.id; | |
| ws.send(JSON.stringify(out)); | |
| clearComposer(); | |
| clearReply(); | |
| stopTyping(); | |
| } | |
| /* --- Статус «печатает» --- */ | |
| let typingState = {}; // from_id -> {name, until} | |
| let typingTick = null; | |
| function sendTyping() { | |
| if (ws?.readyState !== 1 || me?.banned || (!currentPeer && !currentGroup)) return; | |
| if (typingTimer) return; // не чаще раза в 2.5 с | |
| ws.send(JSON.stringify(currentGroup ? { type: "typing", group: currentGroup.id } | |
| : { type: "typing", to: currentPeer.id })); | |
| typingTimer = setTimeout(() => { typingTimer = null; }, 2500); | |
| } | |
| function stopTyping() { clearTimeout(typingTimer); typingTimer = null; } | |
| function resetTyping() { | |
| typingState = {}; | |
| clearInterval(typingTick); | |
| typingTick = null; | |
| $("#chat-status").classList.remove("typing"); | |
| } | |
| function noteTyping(fromId, name) { | |
| typingState[fromId] = { name: name || "", until: Date.now() + 4000 }; | |
| renderTypingStatus(); | |
| clearInterval(typingTick); | |
| typingTick = setInterval(renderTypingStatus, 1000); | |
| } | |
| function renderTypingStatus() { | |
| const now = Date.now(); | |
| for (const k in typingState) if (typingState[k].until < now) delete typingState[k]; | |
| const names = Object.values(typingState).map(t => t.name); | |
| const el = $("#chat-status"); | |
| if (!names.length) { | |
| clearInterval(typingTick); typingTick = null; | |
| el.classList.remove("typing"); | |
| if (currentGroup) el.textContent = fmtAudience(currentGroup); | |
| else if (currentPeer) el.textContent = fmtStatus(currentPeer); | |
| return; | |
| } | |
| el.classList.add("typing"); | |
| el.textContent = currentGroup | |
| ? (names.length === 1 ? `${names[0]} печатает…` : `${names.length} печатают…`) | |
| : "печатает…"; | |
| } | |
| /* --- Прочтение: отметка и галочки --- */ | |
| function sendRead() { | |
| if (currentPeer && !currentGroup && ws?.readyState === 1 && !document.hidden) | |
| ws.send(JSON.stringify({ type: "read", with: currentPeer.id })); | |
| } | |
| /** Обновляет галочки ✓/✓✓ у моих сообщений в открытой личной переписке. */ | |
| function updateTicks() { | |
| if (currentGroup) return; | |
| document.querySelectorAll("#messages .msg.mine").forEach(div => { | |
| const t = div.querySelector(".ticks"); | |
| if (t && div._m) t.classList.toggle("read", div._m.id <= peerReadUpto); | |
| }); | |
| } | |
| /* --- Ответ на сообщение --- */ | |
| /** Короткое текстовое представление сообщения (для панели ответа). */ | |
| function msgShort(m) { | |
| if (m.kind === "text") return m.content; | |
| return ({ photo: "📷 Фото", gif: "GIF", file: "📎 " + (m.extra?.name || "Файл"), | |
| audio: m.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио", call: "📞 Звонок" })[m.kind] || "Сообщение"; | |
| } | |
| function setReply(m) { | |
| if (me?.banned || $("#msg-form").hidden) return; // отвечать некуда — поле скрыто | |
| clearEdit(); | |
| replyTarget = m; | |
| $("#reply-bar").hidden = false; | |
| $("#reply-bar-name").textContent = m.from === me.id ? "Вы" : (m.from_name || "Сообщение"); | |
| $("#reply-bar-text").textContent = msgShort(m).slice(0, 90); | |
| composer().focus(); | |
| } | |
| function clearReply() { | |
| replyTarget = null; | |
| $("#reply-bar").hidden = true; | |
| } | |
| /* --- Редактирование сообщения прямо в поле ввода --- */ | |
| /** Заполняет композер текстом с форматированием (для редактирования). */ | |
| function fillComposer(text, marks) { | |
| const root = composer(); | |
| root.innerHTML = ""; | |
| if (!marks || !marks.length) { | |
| root.appendChild(document.createTextNode(text)); | |
| } else { | |
| // Та же сегментация, что и при отрисовке пузыря (renderRich), но без ссылок. | |
| let i = 0; | |
| while (i < text.length) { | |
| const st = styleAt(marks, i); | |
| let j = i + 1; | |
| while (j < text.length && sameStyle(styleAt(marks, j), st)) j++; | |
| root.appendChild(styledNode(text.slice(i, j), st)); | |
| i = j; | |
| } | |
| } | |
| updatePlaceholder(); | |
| } | |
| /** Ставит каретку в конец композера. */ | |
| function caretToEnd() { | |
| const root = composer(); | |
| root.focus(); | |
| const sel = window.getSelection(); | |
| const r = document.createRange(); | |
| r.selectNodeContents(root); | |
| r.collapse(false); | |
| sel.removeAllRanges(); | |
| sel.addRange(r); | |
| } | |
| /** Входит в режим правки: текст сообщения (с форматированием) попадает в поле ввода. */ | |
| function startEdit(m) { | |
| if (me?.banned || $("#msg-form").hidden) return; | |
| clearReply(); | |
| editTarget = m; | |
| $("#edit-bar").hidden = false; | |
| fillComposer(m.content, m.extra?.fmt); | |
| caretToEnd(); | |
| } | |
| /** Выходит из режима правки (и очищает поле). */ | |
| function clearEdit() { | |
| if (!editTarget) return; | |
| editTarget = null; | |
| $("#edit-bar").hidden = true; | |
| clearComposer(); | |
| } | |
| /** Заполняет пузырь сообщения содержимым (используется и при редактировании). */ | |
| function fillMessage(div, m) { | |
| div._m = m; // данные сообщения — для меню действий и реакций | |
| div.innerHTML = ""; | |
| // В группе у чужих сообщений показываем аватарку автора слева от пузыря. | |
| const groupIncoming = m.group && m.from !== me.id; | |
| div.classList.toggle("has-ava", !!groupIncoming); | |
| if (groupIncoming) { | |
| const av = avaNode(m.from, m.from_username, m.from_name, m.from_avatar_kind); | |
| av.classList.add("msg-ava"); | |
| av.onclick = e => { e.stopPropagation(); openUserProfile(m.from); }; | |
| div.appendChild(av); | |
| } | |
| // В группе чужие сообщения подписываются автором (клик — его профиль). | |
| if (m.group && m.from !== me.id && m.from_name) { | |
| const author = document.createElement("span"); | |
| author.className = "msg-author"; | |
| author.textContent = m.from_name; | |
| author.onclick = e => { e.stopPropagation(); openUserProfile(m.from); }; | |
| div.appendChild(author); | |
| } | |
| // Дубль поста канала в группе-обсуждении — помечаем источником. | |
| if (m.extra?.channel_post) { | |
| const tag = document.createElement("span"); | |
| tag.className = "channel-post-tag"; | |
| tag.textContent = "📢 Пост из «" + m.extra.channel_post.title + "»"; | |
| div.appendChild(tag); | |
| } | |
| // Цитата сообщения, на которое отвечает это сообщение (клик — переход к нему). | |
| if (m.reply_to_preview) { | |
| const rp = m.reply_to_preview; | |
| const q = document.createElement("div"); | |
| q.className = "reply-quote clickable"; | |
| const who = document.createElement("small"); | |
| who.textContent = rp.from === me.id ? "Вы" : rp.from_name; | |
| const tx = document.createElement("span"); | |
| tx.textContent = rp.text || "сообщение"; | |
| q.append(who, tx); | |
| q.onclick = e => { | |
| e.stopPropagation(); | |
| const target = $(`#messages [data-id="${rp.id}"]`); | |
| if (!target) return toast("Сообщение не загружено"); | |
| target.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| target.classList.add("flash"); | |
| setTimeout(() => target.classList.remove("flash"), 1200); | |
| }; | |
| div.appendChild(q); | |
| } | |
| if (m.kind === "photo") { | |
| const img = document.createElement("img"); | |
| img.src = "/api/photos/" + m.content; | |
| img.loading = "lazy"; | |
| img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; // просмотр внутри | |
| div.appendChild(img); | |
| } else if (m.kind === "gif") { | |
| const img = document.createElement("img"); // GIF анимируется в <img> сам | |
| img.className = "msg-gif"; | |
| img.src = "/api/gifs/" + m.content; | |
| img.loading = "lazy"; | |
| img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; | |
| div.appendChild(img); | |
| } else if (m.kind === "call") { | |
| renderCall(div, m); | |
| } else if (m.kind === "audio") { | |
| renderAudio(div, m); | |
| } else if (m.kind === "file") { | |
| renderFile(div, m); | |
| } else { | |
| const p = document.createElement("p"); | |
| renderRich(p, m.content, m.extra?.fmt); // форматирование + @упоминания, без XSS | |
| div.appendChild(p); | |
| } | |
| const time = document.createElement("time"); | |
| time.textContent = (m.extra?.pinned ? "📌 " : "") + (m.extra?.edited ? "изм. " : "") | |
| + fmtSmart(m.created_at); | |
| // Галочки «доставлено/прочитано» — у моих сообщений в личной переписке. | |
| if (!m.group && m.from === me.id && me && !me.banned) { | |
| const ticks = document.createElement("span"); | |
| ticks.className = "ticks" + (m.id <= peerReadUpto ? " read" : ""); | |
| time.appendChild(ticks); | |
| } | |
| div.appendChild(time); | |
| renderReactions(div, m); | |
| // Кнопка комментариев со счётчиком. Под постом канала (extra.disc → зеркало в | |
| // обсуждении) и под дублем поста в самой группе-обсуждении (extra.channel_post). | |
| const disc = m.extra?.disc; // пост в канале | |
| const cpost = m.extra?.channel_post; // дубль поста в обсуждении | |
| if (m.group && (disc || cpost)) { | |
| const cid = disc ? m.group : cpost.cid; // id канала | |
| const rootId = disc || m.id; // id поста-зеркала (корень треда) | |
| const c = document.createElement("button"); | |
| c.className = "link comments-link"; | |
| c.textContent = "Комментарии" + (m.comments ? " " + m.comments : ""); | |
| c.onclick = e => { e.stopPropagation(); openCommentsThread(cid, rootId); }; | |
| div.appendChild(c); | |
| } | |
| // Кнопки модерации в сообщении-жалобе (видны только админу, пока жалоба открыта). | |
| if (m.extra?.report && me.is_admin && !m.extra.closed) { | |
| const actions = document.createElement("div"); | |
| actions.className = "report-actions"; | |
| for (const [label, act] of [["Забанить", "ban"], ["Отклонить", "dismiss"]]) { | |
| const b = document.createElement("button"); | |
| b.className = "primary small-btn" + (act === "ban" ? " danger-btn" : ""); | |
| b.textContent = label; | |
| b.onclick = async e => { | |
| e.stopPropagation(); | |
| const { ok, data } = await api(`/api/admin/reports/${m.extra.report}/${act}`, {}); | |
| if (!ok) return toast(data.error || "Ошибка"); | |
| actions.remove(); | |
| toast(act === "ban" ? "Пользователь забанен" : "Жалоба отклонена"); | |
| }; | |
| actions.appendChild(b); | |
| } | |
| div.appendChild(actions); | |
| } | |
| } | |
| function appendMessage(m) { | |
| const box = $("#messages"); | |
| if (box.querySelector(`[data-id="${m.id}"]`)) return; // защита от дублей | |
| const div = document.createElement("div"); | |
| div.className = "msg" + (m.from === me.id ? " mine" : ""); | |
| div.dataset.id = m.id; | |
| fillMessage(div, m); | |
| div.oncontextmenu = e => openMsgMenu(e, div); // ПКМ по сообщению — меню действий | |
| const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 60; | |
| box.appendChild(div); | |
| if (atBottom || m.from === me.id) scrollMessages(); | |
| } | |
| /* --- Файлы: карточка, размер, предпросмотр --- */ | |
| const FILE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`; | |
| function fmtSize(bytes) { | |
| if (bytes < 1024) return bytes + " Б"; | |
| if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " КБ"; | |
| return (bytes / 1048576).toFixed(1) + " МБ"; | |
| } | |
| /** Файл-сообщение: видео играет в чате; PDF открывается в окне предпросмотра; | |
| * для документов/презентаций/таблиц показывается текстовый сниппет. */ | |
| function renderFile(div, m) { | |
| const f = m.extra || {}; | |
| const src = "/api/files/" + m.content; | |
| if (f.mime?.startsWith("video/")) { // предпросмотр видео — плеер прямо в чате | |
| const v = document.createElement("video"); | |
| v.src = src; | |
| v.controls = true; | |
| v.preload = "metadata"; | |
| v.oncontextmenu = e => e.stopPropagation(); // нативное меню видео | |
| div.appendChild(v); | |
| } | |
| const row = document.createElement("a"); | |
| row.className = "file-row"; | |
| row.href = src + "?download=1"; // клик по карточке — скачать с исходным именем | |
| row.innerHTML = FILE_SVG; | |
| const info = document.createElement("div"); | |
| info.className = "file-info"; | |
| const name = document.createElement("span"); | |
| name.textContent = f.name || "Файл"; | |
| const size = document.createElement("small"); | |
| size.textContent = fmtSize(f.size || 0); | |
| info.append(name, size); | |
| row.appendChild(info); | |
| div.appendChild(row); | |
| if (f.mime === "application/pdf") { // PDF рендерит сам браузер — во всплывающем окне | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.className = "link file-preview-btn"; | |
| b.textContent = "Предпросмотр"; | |
| b.onclick = e => { e.stopPropagation(); openFilePreview(src, f.name); }; | |
| div.appendChild(b); | |
| } | |
| if (f.preview) { // текстовый сниппет документа / презентации / таблицы | |
| const p = document.createElement("p"); | |
| p.className = "file-preview"; | |
| p.textContent = f.preview; | |
| div.appendChild(p); | |
| } | |
| } | |
| /* --- Аудио: голосовые сообщения и аудиофайлы (встроенный плеер) --- */ | |
| const MIC_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`; | |
| function fmtDuration(sec) { | |
| sec = Math.max(0, Math.round(sec || 0)); | |
| return Math.floor(sec / 60) + ":" + String(sec % 60).padStart(2, "0"); | |
| } | |
| /** Аудио-сообщение: голосовое (🎤 + длительность) или аудиофайл (с именем) — | |
| * оба проигрываются встроенным плеером прямо в пузыре. */ | |
| function renderAudio(div, m) { | |
| const f = m.extra || {}; | |
| const box = document.createElement("div"); | |
| box.className = "audio-msg" + (f.voice ? " voice" : ""); | |
| const head = document.createElement("div"); | |
| head.className = "audio-head"; | |
| if (f.voice) { | |
| head.innerHTML = MIC_SVG; | |
| const label = document.createElement("span"); | |
| label.textContent = "Голосовое сообщение"; | |
| head.appendChild(label); | |
| } else { | |
| const name = document.createElement("span"); | |
| name.textContent = f.name || "Аудио"; | |
| head.appendChild(name); | |
| } | |
| if (f.duration) { | |
| const dur = document.createElement("small"); | |
| dur.textContent = fmtDuration(f.duration); | |
| head.appendChild(dur); | |
| } | |
| box.appendChild(head); | |
| const audio = document.createElement("audio"); | |
| audio.controls = true; | |
| audio.preload = "metadata"; | |
| audio.src = "/api/audio/" + m.content; | |
| audio.oncontextmenu = e => e.stopPropagation(); // нативное меню плеера | |
| box.appendChild(audio); | |
| div.appendChild(box); | |
| } | |
| /** Историческая запись о звонке (звонки больше не поддерживаются — только подпись). */ | |
| function renderCall(div, m) { | |
| const box = document.createElement("div"); | |
| box.className = "call-msg"; | |
| const label = document.createElement("span"); | |
| label.textContent = "📞 " + (m.from === me.id ? "Исходящий звонок" | |
| : `${m.extra?.by || m.from_name || "Собеседник"} звонил(а) вам`); | |
| box.append(label); | |
| div.appendChild(box); | |
| } | |
| /** Предпросмотр PDF внутри мессенджера (iframe в модальном окне). */ | |
| function openFilePreview(src, name) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "modal preview-modal"; | |
| const card = document.createElement("div"); | |
| card.className = "preview-card"; | |
| const head = document.createElement("div"); | |
| head.className = "preview-head"; | |
| const title = document.createElement("span"); | |
| title.textContent = name || "Предпросмотр"; | |
| const close = document.createElement("button"); | |
| close.className = "icon-btn"; | |
| close.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`; | |
| head.append(title, close); | |
| const frame = document.createElement("iframe"); | |
| frame.src = src; | |
| card.append(head, frame); | |
| wrap.appendChild(card); | |
| const closeAll = () => { wrap.remove(); document.removeEventListener("keydown", onKey); }; | |
| const onKey = ev => { if (ev.key === "Escape") closeAll(); }; | |
| close.onclick = closeAll; | |
| wrap.onclick = e => { if (e.target === wrap) closeAll(); }; | |
| document.addEventListener("keydown", onKey); | |
| document.body.appendChild(wrap); | |
| } | |
| /* --- Реакции --- */ | |
| const REACTION_SET = [ | |
| "😁", "🤣", "😍", "😘", "🥰", "🥲", "🤩", "🤔", "🫡", "🤨", "😐", "😑", "😶", | |
| "🙄", "😯", "🥱", "😴", "🤤", "🤑", "🙁", "😭", "😨", "🤯", "😬", "😱", "🤬", | |
| "🤮", "🥺", "🥹", "🤡", "🤓", "😈", "👿", "💀", "💩", "👀", "🤞", "🫸", "🤝", | |
| "👌", "👍", "👎", "🤌", "✊", "👋", "👏", "🎉", "💋", "🏆", "🗿", "📝", "❤️", | |
| "❌", "✅", | |
| ]; | |
| /** Полоска реакций под сообщением: группировка по эмодзи, своя — подсвечена. */ | |
| function renderReactions(div, m) { | |
| div.querySelector(".reactions")?.remove(); | |
| if (!m.reactions?.length) return; | |
| const row = document.createElement("div"); | |
| row.className = "reactions"; | |
| const groups = {}; | |
| for (const r of m.reactions) (groups[r.emoji] ??= []).push(r.user_id); | |
| for (const [emoji, users] of Object.entries(groups)) { | |
| const chip = document.createElement("button"); | |
| chip.className = "reaction-chip" + (users.includes(me.id) ? " mine" : ""); | |
| chip.textContent = users.length > 1 ? `${emoji} ${users.length}` : emoji; | |
| chip.onclick = e => { // клик по своей реакции снимает её, по чужой — присоединяется | |
| e.stopPropagation(); | |
| if (!me.banned) ws?.send(JSON.stringify({ type: "react", id: m.id, emoji })); | |
| }; | |
| row.appendChild(chip); | |
| } | |
| div.insertBefore(row, div.querySelector("time")); | |
| } | |
| /* --- Контекстное меню (ПКМ): реакции, закрепить, изменить, удалить --- */ | |
| function closeMsgMenu() { document.querySelector(".msg-menu")?.remove(); } | |
| document.addEventListener("click", closeMsgMenu); // клик мимо меню закрывает его | |
| document.addEventListener("contextmenu", closeMsgMenu); // как и ПКМ мимо | |
| /** Пустое меню, спозиционированное у курсора (общая часть всех контекстных меню). */ | |
| function contextMenu(e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| closeMsgMenu(); | |
| const menu = document.createElement("div"); | |
| menu.className = "msg-menu"; | |
| menu.oncontextmenu = ev => ev.preventDefault(); | |
| menu._place = () => { | |
| const r = menu.getBoundingClientRect(); // не выходить за край экрана | |
| menu.style.left = Math.max(8, Math.min(e.clientX, innerWidth - r.width - 8)) + "px"; | |
| menu.style.top = Math.max(8, Math.min(e.clientY, innerHeight - r.height - 8)) + "px"; | |
| }; | |
| return menu; | |
| } | |
| function menuItem(menu, label, danger, onclick) { | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.className = "msg-menu-item" + (danger ? " danger" : ""); | |
| b.textContent = label; | |
| b.onclick = onclick; | |
| menu.appendChild(b); | |
| } | |
| /** Кнопка-эмодзи: ЛКМ — поставить реакцию, ПКМ — закрепить/открепить эмодзи. */ | |
| function reactionBtn(m, emoji) { | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.textContent = emoji; | |
| if (me.pinned_reactions.includes(emoji)) b.classList.add("pinned"); | |
| b.onclick = () => ws?.send(JSON.stringify({ type: "react", id: m.id, emoji })); | |
| b.oncontextmenu = ev => { ev.preventDefault(); ev.stopPropagation(); togglePinReaction(emoji); }; | |
| return b; | |
| } | |
| /** ПКМ по эмодзи: закрепляет его первым в списке (макс. 5) либо открепляет. */ | |
| async function togglePinReaction(emoji) { | |
| let pinned = me.pinned_reactions.slice(); | |
| if (pinned.includes(emoji)) { | |
| pinned = pinned.filter(x => x !== emoji); | |
| toast(`Реакция ${emoji} откреплена`); | |
| } else { | |
| pinned.unshift(emoji); | |
| pinned = pinned.slice(0, 5); | |
| toast(`Реакция ${emoji} закреплена`); | |
| } | |
| me.pinned_reactions = pinned; | |
| closeMsgMenu(); | |
| await api("/api/me/reactions", { pinned }); | |
| } | |
| function openMsgMenu(e, div) { | |
| if (me.banned) return; // только чтение | |
| const m = div._m; | |
| const menu = contextMenu(e); | |
| // Быстрый ряд — закреплённые эмодзи + кнопка «вся палитра». | |
| const row = document.createElement("div"); | |
| row.className = "msg-menu-reactions"; | |
| for (const emoji of me.pinned_reactions) row.appendChild(reactionBtn(m, emoji)); | |
| const more = document.createElement("button"); | |
| more.type = "button"; | |
| more.className = "more"; | |
| more.textContent = "⌄"; | |
| more.title = "Все реакции"; | |
| more.onclick = ev => { ev.stopPropagation(); grid.hidden = !grid.hidden; menu._place(); }; | |
| row.appendChild(more); | |
| menu.appendChild(row); | |
| // Полная палитра: закреплённые первыми, затем остальные. | |
| const grid = document.createElement("div"); | |
| grid.className = "msg-menu-grid"; | |
| grid.hidden = true; | |
| const rest = REACTION_SET.filter(x => !me.pinned_reactions.includes(x)); | |
| for (const emoji of [...me.pinned_reactions, ...rest]) grid.appendChild(reactionBtn(m, emoji)); | |
| menu.appendChild(grid); | |
| // Ответить — если в этот чат можно писать (поле ввода не скрыто). | |
| if (!$("#msg-form").hidden) | |
| menuItem(menu, "Ответить", false, () => setReply(m)); | |
| menuItem(menu, m.extra?.pinned ? "Открепить" : "Закрепить", false, | |
| () => ws?.send(JSON.stringify({ type: "pin", id: m.id, pinned: !m.extra?.pinned }))); | |
| if (m.from === me.id && m.kind === "text") | |
| menuItem(menu, "Изменить", false, () => startEdit(m)); | |
| // Удалить может автор; в группе — ещё и её владелец (модерация). | |
| if (m.from === me.id || (m.group && currentGroup?.is_owner)) | |
| menuItem(menu, "Удалить", true, () => confirmDialog( | |
| "Удалить сообщение у всех участников?", | |
| () => ws?.send(JSON.stringify({ type: "delete", id: m.id })))); | |
| document.body.appendChild(menu); | |
| menu._place(); | |
| } | |
| /* --- Контекстное меню переписки (ПКМ по чату в списке) --- */ | |
| function deleteChat(peerId) { | |
| confirmDialog("Удалить переписку? Сообщения удалятся у обоих участников.", async () => { | |
| const { ok, data } = await api(`/api/chats/${peerId}/delete`, {}); | |
| if (!ok) return toast(data.error || "Ошибка"); | |
| if (currentPeer?.id === peerId) openView("empty"); | |
| loadChats(); | |
| }); | |
| } | |
| function openChatMenu(e, peerId) { | |
| if (me.banned) return; | |
| const menu = contextMenu(e); | |
| menuItem(menu, "Удалить переписку", true, () => deleteChat(peerId)); | |
| document.body.appendChild(menu); | |
| menu._place(); | |
| } | |
| /* --- Контекстное меню группы (ПКМ по группе в списке) --- */ | |
| function openGroupRowMenu(e, gid) { | |
| if (me.banned) return; | |
| const menu = contextMenu(e); | |
| menuItem(menu, "Профиль группы", false, () => openGroupProfile(gid)); | |
| menuItem(menu, "Выйти из группы", true, () => leaveGroup(gid)); | |
| document.body.appendChild(menu); | |
| menu._place(); | |
| } | |
| /* --- Просмотр фото внутри мессенджера (лайтбокс) --- */ | |
| function openPhoto(src) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "modal photo-modal"; | |
| const img = document.createElement("img"); | |
| img.src = src; | |
| img.alt = "Фото"; | |
| wrap.appendChild(img); | |
| const close = () => { wrap.remove(); document.removeEventListener("keydown", onKey); }; | |
| const onKey = ev => { if (ev.key === "Escape") close(); }; | |
| wrap.onclick = close; | |
| document.addEventListener("keydown", onKey); | |
| document.body.appendChild(wrap); | |
| } | |
| $("#msg-form").addEventListener("submit", e => { | |
| e.preventDefault(); | |
| sendComposer(); | |
| }); | |
| // Контекстное меню форматирования и поведение поля ввода (contenteditable). | |
| composer().addEventListener("contextmenu", openFormatMenu); | |
| composer().addEventListener("input", () => { updatePlaceholder(); sendTyping(); }); | |
| composer().addEventListener("keydown", e => { | |
| if (e.key === "Escape" && editTarget) { e.preventDefault(); clearEdit(); return; } | |
| if (e.key !== "Enter" || e.isComposing) return; | |
| e.preventDefault(); | |
| // Shift+Enter и Ctrl+Enter — перенос строки, обычный Enter — отправка. | |
| if (e.shiftKey || e.ctrlKey) insertBreak(); | |
| else sendComposer(); | |
| }); | |
| $("#reply-cancel").onclick = clearReply; | |
| $("#edit-cancel").onclick = clearEdit; | |
| updatePlaceholder(); | |
| $("#chat-head").onclick = () => { | |
| if (currentGroup) openGroupProfile(currentGroup.id); | |
| else if (currentPeer) openUserProfile(currentPeer.id); | |
| }; | |
| /* --- Мобильная навигация: кнопки «Назад» возвращают к списку чатов --- */ | |
| $("#chat-back").onclick = e => { | |
| e.stopPropagation(); // не открывать профиль из шапки | |
| openView("empty"); | |
| }; | |
| document.querySelectorAll("[data-back]").forEach(btn => | |
| btn.addEventListener("click", () => openView("empty"))); | |
| /* --- Долгое нажатие (тач) = ПКМ: контекстные меню на телефонах. | |
| Android шлёт contextmenu сам, iOS Safari — нет, поэтому синтезируем. --- */ | |
| let lpTimer = null, lpFired = false; | |
| document.addEventListener("touchstart", e => { | |
| if (e.touches.length !== 1) return; | |
| const { clientX, clientY } = e.touches[0]; | |
| const target = e.target; | |
| lpFired = false; | |
| lpTimer = setTimeout(() => { | |
| if (document.querySelector(".msg-menu")) return; // Android уже открыл меню сам | |
| lpFired = true; | |
| target.dispatchEvent(new MouseEvent("contextmenu", | |
| { bubbles: true, cancelable: true, clientX, clientY })); | |
| }, 500); | |
| }, { passive: true }); | |
| document.addEventListener("touchmove", () => clearTimeout(lpTimer), { passive: true }); | |
| document.addEventListener("touchend", e => { | |
| clearTimeout(lpTimer); | |
| if (lpFired) { e.preventDefault(); lpFired = false; } // погасить click после долгого тапа | |
| }, { passive: false }); | |
| document.addEventListener("touchcancel", () => clearTimeout(lpTimer)); | |
| /* --- Удаление переписки из шапки чата (дублирует ПКМ по чату в списке) --- */ | |
| $("#chat-delete").onclick = e => { | |
| e.stopPropagation(); // не открывать профиль из шапки | |
| if (currentGroup) (currentGroup.is_owner ? deleteGroup : leaveGroup)(currentGroup.id); | |
| else if (currentPeer) deleteChat(currentPeer.id); | |
| }; | |
| /* --- Вложения: кнопка "+", вставка из буфера, перетаскивание. | |
| Картинки сжимаются как фото, остальные файлы (до 25 МБ) — как есть. --- */ | |
| async function sendAttachment(file) { | |
| if (!file || (!currentPeer && !currentGroup)) return; | |
| if (file.size > 25 * 1024 * 1024) return showChatError("Файл больше 25 МБ"); | |
| if (file.type.startsWith("audio/")) // аудиофайл — отправляем как аудио-сообщение | |
| return uploadAudio(file, { name: file.name }); | |
| const isImage = file.type.startsWith("image/"); | |
| const fd = new FormData(); | |
| if (currentGroup) fd.append("group", currentGroup.id); | |
| else fd.append("to", currentPeer.id); | |
| fd.append(isImage ? "photo" : "file", file); | |
| const res = await fetch(isImage ? "/api/messages/photo" : "/api/messages/file", | |
| { method: "POST", body: fd }); | |
| if (!res.ok) showChatError((await res.json().catch(() => ({}))).error | |
| || "Не удалось отправить файл"); | |
| } | |
| /** Отправка аудио (голосовое или файл) на /api/messages/audio. */ | |
| async function uploadAudio(blob, { voice = false, duration = 0, name = "" } = {}) { | |
| if (!blob || (!currentPeer && !currentGroup)) return; | |
| if (blob.size > 25 * 1024 * 1024) return showChatError("Аудио больше 25 МБ"); | |
| const fd = new FormData(); | |
| if (currentGroup) fd.append("group", currentGroup.id); | |
| else fd.append("to", currentPeer.id); | |
| fd.append("audio", blob, name || "voice.webm"); | |
| if (voice) fd.append("voice", "1"); | |
| if (duration) fd.append("duration", String(duration)); | |
| const res = await fetch("/api/messages/audio", { method: "POST", body: fd }); | |
| if (!res.ok) showChatError((await res.json().catch(() => ({}))).error | |
| || "Не удалось отправить аудио"); | |
| } | |
| /* --- Запись голосовых сообщений (MediaRecorder) --- */ | |
| let mediaRecorder = null, recChunks = [], recStart = 0, recTimer = null, recCancelled = false; | |
| async function startRecording() { | |
| if (!currentPeer && !currentGroup) return; | |
| if (mediaRecorder) return; // уже идёт запись | |
| if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) | |
| return showChatError("Запись аудио не поддерживается этим браузером"); | |
| let stream; | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| } catch { | |
| return showChatError("Нет доступа к микрофону"); | |
| } | |
| // Выбираем формат, который умеет браузер (Chrome — webm/opus, Safari — mp4). | |
| const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg"] | |
| .find(t => MediaRecorder.isTypeSupported(t)) || ""; | |
| recChunks = []; | |
| recCancelled = false; | |
| mediaRecorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined); | |
| mediaRecorder.ondataavailable = e => { if (e.data.size) recChunks.push(e.data); }; | |
| mediaRecorder.onstop = () => { | |
| stream.getTracks().forEach(t => t.stop()); | |
| clearInterval(recTimer); | |
| $("#rec-bar").hidden = true; | |
| $("#msg-form").hidden = false; | |
| const mr = mediaRecorder; | |
| mediaRecorder = null; | |
| if (recCancelled || !recChunks.length) return; | |
| const duration = (Date.now() - recStart) / 1000; | |
| const type = (mr.mimeType || "audio/webm").split(";")[0]; | |
| const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm"; | |
| uploadAudio(new Blob(recChunks, { type }), | |
| { voice: true, duration, name: "voice." + ext }); | |
| }; | |
| mediaRecorder.start(); | |
| recStart = Date.now(); | |
| $("#msg-form").hidden = true; | |
| const bar = $("#rec-bar"); | |
| bar.hidden = false; | |
| $("#rec-time").textContent = "0:00"; | |
| recTimer = setInterval(() => { | |
| const s = (Date.now() - recStart) / 1000; | |
| $("#rec-time").textContent = fmtDuration(s); | |
| if (s >= 600) stopRecording(false); // защита: максимум 10 минут | |
| }, 200); | |
| } | |
| function stopRecording(cancel) { | |
| if (!mediaRecorder) return; | |
| recCancelled = cancel; | |
| try { mediaRecorder.stop(); } catch { /* уже остановлен */ } | |
| } | |
| $("#mic-btn").onclick = () => startRecording(); | |
| $("#rec-send").onclick = () => stopRecording(false); | |
| $("#rec-cancel").onclick = () => stopRecording(true); | |
| $("#attach-btn").onclick = () => $("#photo-input").click(); | |
| $("#photo-input").onchange = e => { sendAttachment(e.target.files[0]); e.target.value = ""; }; | |
| $("#msg-input").addEventListener("paste", e => { | |
| const item = [...(e.clipboardData?.items || [])].find(i => i.kind === "file"); | |
| if (item) { e.preventDefault(); sendAttachment(item.getAsFile()); return; } | |
| // Вставляем как простой текст — чтобы не тащить чужое HTML-форматирование. | |
| e.preventDefault(); | |
| const text = e.clipboardData.getData("text/plain"); | |
| document.execCommand("insertText", false, text); | |
| updatePlaceholder(); | |
| }); | |
| const msgForm = $("#msg-form"); | |
| msgForm.addEventListener("dragover", e => { e.preventDefault(); msgForm.classList.add("drag"); }); | |
| msgForm.addEventListener("dragleave", () => msgForm.classList.remove("drag")); | |
| msgForm.addEventListener("drop", e => { | |
| e.preventDefault(); | |
| msgForm.classList.remove("drag"); | |
| sendAttachment(e.dataTransfer.files[0]); | |
| }); | |
| /* ===== GIF: общая библиотека ===== */ | |
| /** Пикер GIF: поиск по ключевым словам, клик по гифке — отправка в открытый чат. */ | |
| function openGifPicker() { | |
| if (!currentPeer && !currentGroup) return; | |
| const wrap = document.createElement("div"); | |
| wrap.className = "modal"; | |
| const card = document.createElement("div"); | |
| card.className = "modal-card gif-picker"; | |
| card.innerHTML = `<h2>GIF</h2> | |
| <input id="gif-search" placeholder="Поиск по ключевым словам" autocomplete="off"> | |
| <div class="gif-grid" id="gif-grid"></div>`; | |
| wrap.appendChild(card); | |
| const close = () => wrap.remove(); | |
| wrap.addEventListener("click", e => { if (e.target === wrap) close(); }); | |
| const grid = card.querySelector("#gif-grid"); | |
| const input = card.querySelector("#gif-search"); | |
| const render = async () => { | |
| const { ok, data } = await api("/api/gifs?q=" + encodeURIComponent(input.value.trim())); | |
| grid.innerHTML = ""; | |
| if (!ok || !data.length) { | |
| grid.innerHTML = `<p class="empty">Ничего не найдено.<br>Добавьте GIF через @addgif.</p>`; | |
| return; | |
| } | |
| for (const g of data) { | |
| const img = document.createElement("img"); | |
| img.src = "/api/gifs/" + g.file; | |
| img.loading = "lazy"; | |
| img.onclick = async () => { | |
| const body = currentGroup ? { group: currentGroup.id, file: g.file } | |
| : { to: currentPeer.id, file: g.file }; | |
| const r = await api("/api/messages/gif", body); | |
| if (!r.ok) return toast(r.data.error || "Не удалось отправить"); | |
| close(); | |
| }; | |
| grid.appendChild(img); | |
| } | |
| }; | |
| let t = null; | |
| input.addEventListener("input", () => { clearTimeout(t); t = setTimeout(render, 300); }); | |
| document.body.appendChild(wrap); | |
| input.focus(); | |
| render(); // сразу показать недавние | |
| } | |
| /** Диалог добавления GIF в общую библиотеку (бот @addgif). */ | |
| function openAddGifDialog() { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "modal"; | |
| const card = document.createElement("div"); | |
| card.className = "modal-card dialog"; | |
| card.innerHTML = `<h2>Добавить GIF</h2> | |
| <p class="hint">GIF попадёт в общую библиотеку и станет доступен всем по ключевым | |
| словам. Можно добавить до 5 GIF в сутки.</p> | |
| <label class="gif-file-label"><span id="gif-file-name">Выбрать GIF-файл</span> | |
| <input type="file" id="gif-file" accept="image/gif" hidden> | |
| </label> | |
| <input id="gif-keywords" placeholder="Ключевые слова (через пробел)" maxlength="200" autocomplete="off"> | |
| <p class="error" hidden></p> | |
| <div class="dialog-buttons"> | |
| <button type="button" class="link" data-cancel>Отмена</button> | |
| <button type="button" class="primary" data-add>Добавить</button> | |
| </div>`; | |
| wrap.appendChild(card); | |
| const errEl = card.querySelector(".error"); | |
| const fileInput = card.querySelector("#gif-file"); | |
| const nameEl = card.querySelector("#gif-file-name"); | |
| const kw = card.querySelector("#gif-keywords"); | |
| const close = () => wrap.remove(); | |
| card.querySelector("[data-cancel]").onclick = close; | |
| wrap.addEventListener("click", e => { if (e.target === wrap) close(); }); | |
| fileInput.onchange = () => | |
| (nameEl.textContent = fileInput.files[0] ? fileInput.files[0].name : "Выбрать GIF-файл"); | |
| card.querySelector("[data-add]").onclick = async () => { | |
| errEl.hidden = true; | |
| const fail = msg => { errEl.textContent = msg; errEl.hidden = false; }; | |
| const file = fileInput.files[0]; | |
| if (!file) return fail("Выберите GIF-файл"); | |
| if (file.type !== "image/gif") return fail("Это должен быть GIF-файл"); | |
| if (file.size > 15 * 1024 * 1024) return fail("GIF больше 15 МБ"); | |
| if (!kw.value.trim()) return fail("Укажите ключевые слова"); | |
| const fd = new FormData(); | |
| fd.append("gif", file); | |
| fd.append("keywords", kw.value.trim()); | |
| const res = await fetch("/api/gifs", { method: "POST", body: fd }); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) return fail(data.error || "Ошибка"); | |
| close(); | |
| toast("GIF добавлен в библиотеку"); | |
| // @addgif пришлёт подтверждение по WS — чат и список обновятся сами. | |
| }; | |
| document.body.appendChild(wrap); | |
| } | |
| $("#gif-btn").onclick = openGifPicker; | |
| $("#addgif-add").onclick = openAddGifDialog; | |
| /* ===== Поиск ===== */ | |
| let searchTimer = null; | |
| async function runSearch() { | |
| const q = $("#search-input").value.trim(); | |
| const box = $("#search-results"); | |
| $("#chat-list").hidden = !!q; | |
| box.hidden = !q; | |
| $("#search-opts").hidden = !q; | |
| if (!q) return; | |
| const params = new URLSearchParams({ | |
| q, type: $("#search-type").value, mode: $("#search-mode").value, | |
| }); | |
| const { ok, data } = await api("/api/search?" + params); | |
| box.innerHTML = ""; | |
| if (!ok || !data.length) { | |
| box.innerHTML = `<p class="empty">Ничего не найдено</p>`; | |
| return; | |
| } | |
| for (const r of data) { | |
| const row = document.createElement("button"); | |
| row.className = "chat-row"; | |
| const type = $("#search-type").value; | |
| if (type === "groups") { // публичные группы: по названию и юзернейму | |
| row.appendChild(groupAvaNode(r.id, r.title)); | |
| const info = document.createElement("div"); | |
| info.className = "chat-row-info"; | |
| const name = document.createElement("span"); | |
| name.textContent = r.title; | |
| const sub = document.createElement("small"); | |
| sub.textContent = (r.is_channel ? "Канал · " : "") | |
| + (r.username ? "@" + r.username + " · " : "") | |
| + (r.is_channel ? fmtSubscribers(r.members) : fmtMembers(r.members)); | |
| info.append(name, sub); | |
| row.appendChild(info); | |
| row.onclick = () => openGroupProfile(r.id); // клик -> профиль группы/канала | |
| } else if (type === "users") { | |
| row.appendChild(avaNode(r.id, r.username, r.display_name, r.avatar_kind)); | |
| const info = document.createElement("div"); | |
| info.className = "chat-row-info"; | |
| const name = document.createElement("span"); | |
| name.textContent = r.display_name; | |
| const uname = document.createElement("small"); | |
| uname.textContent = "@" + r.username; | |
| info.append(name, uname); | |
| row.appendChild(info); | |
| row.onclick = () => openUserProfile(r.id); // клик -> профиль пользователя | |
| } else { | |
| row.appendChild(avaNode(r.peer_id, r.peer_deleted ? null : r.peer_username, | |
| r.peer_name, r.peer_avatar_kind)); | |
| const info = document.createElement("div"); | |
| info.className = "chat-row-info"; | |
| const name = document.createElement("span"); | |
| name.textContent = r.peer_deleted ? "Удалённый аккаунт" : r.peer_name; | |
| const snippet = document.createElement("small"); | |
| snippet.textContent = r.content; | |
| info.append(name, snippet); | |
| const time = document.createElement("time"); | |
| time.textContent = fmtSmart(r.created_at); | |
| row.append(info, time); | |
| row.onclick = () => openChat(r.peer_id); // клик -> чат с сообщением | |
| } | |
| box.appendChild(row); | |
| } | |
| } | |
| $("#search-input").addEventListener("input", () => { | |
| clearTimeout(searchTimer); | |
| searchTimer = setTimeout(runSearch, 300); | |
| }); | |
| $("#search-type").onchange = runSearch; | |
| $("#search-mode").onchange = runSearch; | |
| /* ===== Профиль другого пользователя ===== */ | |
| async function openUserProfile(id) { | |
| if (id === me.id) return openView("profile"); // свой профиль — отдельный вид | |
| const { ok, data } = await api("/api/peer/" + id); | |
| if (!ok) return; | |
| const u = data; | |
| openView("user"); | |
| setName($("#user-name"), u.deleted ? "Удалённый аккаунт" : u.display_name, u.is_admin); | |
| $("#user-status").textContent = fmtStatus(u); | |
| $("#user-username").textContent = u.deleted ? "" : "@" + u.username; | |
| renderMentions($("#user-desc"), u.description || ""); | |
| renderProfileAvatar("user-ava", "user-ava-stub", u.username, | |
| u.has_avatar && !u.deleted, u.avatar_kind); | |
| // Кнопки: добавить в контакты (если ещё не добавлен) и блокировка. | |
| // В режиме «только чтение» (бан) никакие действия недоступны. | |
| $("#user-add-contact").hidden = !!me.banned || !!u.deleted || u.is_contact; | |
| $("#user-add-contact").onclick = async () => { | |
| await api("/api/me/contacts", { username: u.username }); | |
| openUserProfile(id); | |
| }; | |
| // Админа и системные аккаунты заблокировать нельзя. | |
| $("#user-block").hidden = !!me.banned || !!u.deleted || u.is_admin || u.is_system; | |
| $("#user-block").textContent = u.i_blocked ? "Разблокировать" : "Заблокировать"; | |
| $("#user-block").onclick = async () => { | |
| const { ok, data } = await api(`/api/users/${id}/${u.i_blocked ? "unblock" : "block"}`, {}); | |
| if (!ok) toast(data.error); | |
| openUserProfile(id); | |
| }; | |
| // Писать нельзя удалённым, забаненным, @reports и в @salgram (если вы не админ). | |
| $("#user-write").hidden = !!u.deleted || !!u.banned | |
| || (u.is_system && (u.username === "reports" | |
| || (u.username === "salgram" && !me.is_admin))); | |
| $("#user-write").onclick = () => openChat(id); | |
| // Жалоба (не на админа и не на системные аккаунты). | |
| $("#user-report").hidden = !!me.banned || !!u.deleted || u.is_admin || u.is_system; | |
| $("#user-report").onclick = () => confirmDialog( | |
| `Пожаловаться на ${u.display_name}? Переписка с ним будет отправлена администратору.`, | |
| async () => { | |
| const { ok, data } = await api(`/api/users/${id}/report`, {}); | |
| toast(ok ? "Жалоба отправлена администратору" : data.error); | |
| }); | |
| // Удаление аккаунта — только для админа. | |
| $("#user-delete").hidden = !me.is_admin || !!u.deleted || u.is_admin || u.is_system; | |
| $("#user-delete").onclick = () => confirmDialog( | |
| `Удалить аккаунт ${u.display_name}?`, | |
| async () => { | |
| const { ok, data } = await api(`/api/admin/users/${id}/delete`, {}); | |
| toast(ok ? "Аккаунт удалён" : data.error); | |
| openUserProfile(id); | |
| }); | |
| // Список контактов (если виден по приватности и нет блокировки). | |
| const sec = $("#user-contacts-sec"); | |
| sec.hidden = !u.contacts; | |
| if (u.contacts) { | |
| const ul = $("#user-contact-list"); | |
| ul.innerHTML = ""; | |
| if (!u.contacts.length) ul.innerHTML = `<li class="empty">Контактов нет</li>`; | |
| for (const c of u.contacts) { | |
| const li = document.createElement("li"); | |
| const name = document.createElement("span"); | |
| name.textContent = c.display_name; | |
| const uname = document.createElement("small"); | |
| uname.textContent = "@" + c.username; | |
| li.append(name, uname); | |
| ul.appendChild(li); | |
| } | |
| } | |
| } | |
| /* ===== Универсальные всплывающие окна ===== */ | |
| function dialog(title, fields, submitLabel, onSubmit) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "modal"; | |
| wrap.innerHTML = ` | |
| <form class="modal-card dialog"> | |
| <h2>${title}</h2> | |
| ${fields.map(f => f.type === "textarea" | |
| ? `<textarea name="${f.name}" placeholder="${f.placeholder}" maxlength="${f.maxlength || 512}"></textarea>` | |
| : f.type === "select" | |
| ? `<select name="${f.name}">${(f.options || []).map(o => | |
| `<option value="${o.value}">${o.label}</option>`).join("")}</select>` | |
| : `<input name="${f.name}" type="${f.type || "text"}" placeholder="${f.placeholder}" | |
| ${f.maxlength ? `maxlength="${f.maxlength}"` : ""} ${f.optional ? "" : "required"}>` | |
| ).join("")} | |
| <p class="error" hidden></p> | |
| <div class="dialog-buttons"> | |
| <button type="button" class="link" data-cancel>Отмена</button> | |
| <button class="primary">${submitLabel}</button> | |
| </div> | |
| </form>`; | |
| const form = wrap.firstElementChild; | |
| fields.forEach(f => { if (f.value) form.elements[f.name].value = f.value; }); // без XSS | |
| wrap.querySelector("[data-cancel]").onclick = () => wrap.remove(); | |
| wrap.addEventListener("click", e => { if (e.target === wrap) wrap.remove(); }); | |
| form.onsubmit = async e => { | |
| e.preventDefault(); | |
| const errorEl = form.querySelector(".error"); | |
| errorEl.hidden = true; | |
| const err = await onSubmit(Object.fromEntries(new FormData(form))); | |
| if (!err) return wrap.remove(); | |
| errorEl.textContent = err; | |
| errorEl.hidden = false; | |
| }; | |
| document.body.appendChild(wrap); | |
| form.querySelector("input, textarea")?.focus(); | |
| return form; // для динамики внутри диалога (скрытие полей и т.п.) | |
| } | |
| function confirmDialog(text, onYes) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "modal"; | |
| wrap.innerHTML = ` | |
| <div class="modal-card dialog"> | |
| <h2></h2> | |
| <div class="dialog-buttons"> | |
| <button type="button" class="link" data-cancel>Отмена</button> | |
| <button type="button" class="primary" data-yes>Да</button> | |
| </div> | |
| </div>`; | |
| wrap.querySelector("h2").textContent = text; // textContent — защита от XSS | |
| wrap.querySelector("[data-cancel]").onclick = () => wrap.remove(); | |
| wrap.addEventListener("click", e => { if (e.target === wrap) wrap.remove(); }); | |
| wrap.querySelector("[data-yes]").onclick = () => { wrap.remove(); onYes(); }; | |
| document.body.appendChild(wrap); | |
| } | |
| /* ===== Авторизация ===== */ | |
| async function submitAuth(form, path, payload) { | |
| const errorEl = form.querySelector(".error"); | |
| errorEl.hidden = true; | |
| const { ok, data } = await api(path, payload); | |
| if (ok) return enterApp(); | |
| errorEl.textContent = data.error || "Ошибка сети, попробуйте позже"; | |
| errorEl.hidden = false; | |
| } | |
| $("#register-form").addEventListener("submit", e => { | |
| e.preventDefault(); | |
| const f = e.target.elements; | |
| submitAuth(e.target, "/api/register", { | |
| display_name: f.display_name.value.trim(), | |
| username: f.username.value.trim(), | |
| password: f.password.value, | |
| tos: f.tos.checked, | |
| }); | |
| }); | |
| $("#login-form").addEventListener("submit", e => { | |
| e.preventDefault(); | |
| const f = e.target.elements; | |
| submitAuth(e.target, "/api/login", { | |
| username: f.username.value.trim(), | |
| password: f.password.value, | |
| }); | |
| }); | |
| document.querySelectorAll(".switch").forEach(btn => | |
| btn.addEventListener("click", () => { | |
| btn.closest("form").hidden = true; | |
| const next = document.getElementById(btn.dataset.target); | |
| next.hidden = false; | |
| next.classList.remove("appear"); | |
| void next.offsetWidth; // перезапуск CSS-анимации | |
| next.classList.add("appear"); | |
| }) | |
| ); | |
| $("#tos-open").addEventListener("click", () => ($("#tos-modal").hidden = false)); | |
| $("#tos-close").addEventListener("click", () => ($("#tos-modal").hidden = true)); | |
| $("#tos-modal").addEventListener("click", e => { | |
| if (e.target.id === "tos-modal") e.target.hidden = true; | |
| }); | |
| /* ===== Панель навигации ===== */ | |
| $("#nav-settings").onclick = () => openView("settings"); | |
| $("#nav-profile").onclick = () => openView("profile"); | |
| /* Создание группы: публичной обязателен юзернейм (по нему её находят поиском), | |
| приватная — без юзернейма, вход только по ссылке. */ | |
| $("#group-create").onclick = () => { | |
| const form = dialog("Создать", [ | |
| { name: "type", type: "select", options: [ | |
| { value: "group", label: "Группа" }, | |
| { value: "channel", label: "Канал (публикует владелец)" }, | |
| ] }, | |
| { name: "title", placeholder: "Название", maxlength: 64 }, | |
| { name: "public", type: "select", options: [ | |
| { value: "1", label: "Публичный — виден всем в поиске" }, | |
| { value: "0", label: "Приватный — вход только по ссылке" }, | |
| ] }, | |
| { name: "username", placeholder: "Юзернейм", maxlength: 32, optional: true }, | |
| ], "Создать", async v => { | |
| const isPublic = v.public === "1"; | |
| const isChannel = v.type === "channel"; | |
| if (isPublic && !v.username.trim()) | |
| return isPublic && isChannel ? "Публичному каналу нужен юзернейм" | |
| : "Публичной группе нужен юзернейм"; | |
| const { ok, data } = await api("/api/groups", { | |
| title: v.title.trim(), public: isPublic, is_channel: isChannel, | |
| username: isPublic ? v.username.trim() : "" }); | |
| if (!ok) return data.error; | |
| loadChats(); | |
| showGroupProfile(data); // сразу видна пригласительная ссылка | |
| }); | |
| // Юзернейм есть только у публичных групп/каналов — для приватных поле скрывается. | |
| const syncUsername = () => | |
| (form.elements.username.hidden = form.elements.public.value !== "1"); | |
| form.elements.public.onchange = syncUsername; | |
| syncUsername(); | |
| }; | |
| $("#nav-logout").onclick = () => confirmDialog("Выйти из аккаунта?", async () => { | |
| await unsubscribePush(); // снять push-подписку с этого устройства | |
| await api("/api/logout", {}); | |
| clearMsgCache(); // не оставляем переписки на общем устройстве | |
| me = null; | |
| ws?.close(); | |
| unreadCount = 0; updateTitle(); | |
| show("auth"); | |
| }); | |
| /* ===== Настройки ===== */ | |
| function openSettingsPage(id) { | |
| document.querySelectorAll(".settings-page").forEach(p => (p.hidden = p.id !== id)); | |
| if (id === "page-chats") renderCacheSize(); // актуальный вес кэша сообщений | |
| if (id === "page-appearance") renderWallpaperRow(); | |
| if (id === "page-app") { // ПК-версия: кэш, обои и автозапуск собраны здесь | |
| renderCacheSize(); | |
| renderWallpaperRow(); | |
| syncAutostartToggle(); | |
| } | |
| const page = document.getElementById(id); | |
| page.classList.remove("appear"); | |
| void page.offsetWidth; | |
| page.classList.add("appear"); | |
| } | |
| document.querySelectorAll("[data-page]").forEach(btn => | |
| btn.addEventListener("click", () => openSettingsPage(btn.dataset.page)) | |
| ); | |
| function editNick() { | |
| dialog("Изменить ник", [ | |
| { name: "display_name", placeholder: "Новый ник", maxlength: 50, value: me.display_name }, | |
| ], "Сохранить", async v => { | |
| const { ok, data } = await api("/api/me/display_name", v); | |
| if (!ok) return data.error; | |
| me.display_name = v.display_name.trim(); | |
| renderProfile(); | |
| }); | |
| } | |
| function editUsername() { | |
| dialog("Изменить юзернейм", [ | |
| { name: "username", placeholder: "Новый юзернейм", maxlength: 32, value: me.username }, | |
| ], "Сохранить", async v => { | |
| const { ok, data } = await api("/api/me/username", v); | |
| if (!ok) return data.error; | |
| me.username = v.username.trim(); | |
| renderProfile(); | |
| }); | |
| } | |
| function editPassword() { | |
| dialog("Изменить пароль", [ | |
| { name: "old_password", placeholder: "Старый пароль", type: "password" }, | |
| { name: "new_password", placeholder: "Новый пароль (мин. 8 символов)", type: "password" }, | |
| ], "Сохранить", async v => { | |
| const { ok, data } = await api("/api/me/password", v); | |
| if (!ok) return data.error; | |
| }); | |
| } | |
| function editDescription() { | |
| dialog("Изменить описание", [ | |
| { name: "description", placeholder: "Расскажите о себе (до 512 символов)", | |
| type: "textarea", maxlength: 512, value: me.description, optional: true }, | |
| ], "Сохранить", async v => { | |
| const { ok, data } = await api("/api/me/description", v); | |
| if (!ok) return data.error; | |
| me.description = v.description.trim(); | |
| renderProfile(); | |
| }); | |
| } | |
| $("#opt-nick").onclick = editNick; | |
| $("#opt-username").onclick = editUsername; | |
| $("#opt-password").onclick = editPassword; | |
| $("#opt-delete").onclick = () => | |
| dialog("Удалить аккаунт", [ | |
| { name: "password", placeholder: "Пароль аккаунта", type: "password" }, | |
| ], "Удалить навсегда", async v => { | |
| const { ok, data } = await api("/api/me/delete", v); | |
| if (!ok) return data.error; | |
| clearMsgCache(); | |
| me = null; | |
| ws?.close(); | |
| show("auth"); | |
| }); | |
| $("#privacy-avatar").onchange = e => api("/api/me/privacy", { avatar: e.target.value }); | |
| $("#privacy-contacts").onchange = e => api("/api/me/privacy", { contacts: e.target.value }); | |
| $("#privacy-group-invite").onchange = e => | |
| api("/api/me/privacy", { group_invite: e.target.value }); | |
| /* Чаты: медленный режим */ | |
| $("#slow-seconds").onchange = e => { | |
| const v = Math.max(0, Math.min(3600, +e.target.value || 0)); | |
| e.target.value = v; | |
| me.slow_seconds = v; | |
| api("/api/me/settings", { slow_seconds: v }); | |
| }; | |
| $("#slow-scope").onchange = e => { | |
| me.slow_scope = e.target.value; | |
| api("/api/me/settings", { slow_scope: e.target.value }); | |
| }; | |
| /* Чаты: кэш сообщений */ | |
| $("#cache-clear").onclick = () => confirmDialog( | |
| "Очистить кэш сообщений на этом устройстве?", () => { | |
| clearMsgCache(); | |
| renderCacheSize(); | |
| toast("Кэш сообщений очищен"); | |
| }); | |
| /* Оформление: цветовая тема (хранится в cookie устройства) */ | |
| $("#theme-select").onchange = e => { | |
| setCookie("theme", e.target.value); | |
| applyTheme(); | |
| }; | |
| /* Оформление: обои чата — фото или видео фоном в переписке */ | |
| function renderWallpaper() { | |
| const bg = $("#chat-bg"); | |
| bg.innerHTML = ""; | |
| bg.style.backgroundImage = ""; | |
| bg.classList.toggle("has-wp", !!me.wallpaper); | |
| if (!me.wallpaper) return; | |
| const url = "/api/me/wallpaper?v=" + (me.wallpaper.v || 0); | |
| if (me.wallpaper.kind === "video") { | |
| const v = document.createElement("video"); | |
| v.src = url; | |
| v.autoplay = v.muted = v.loop = true; | |
| v.playsInline = true; | |
| bg.appendChild(v); | |
| } else { | |
| bg.style.backgroundImage = `url("${url}")`; | |
| } | |
| } | |
| function renderWallpaperRow() { $("#wallpaper-remove").hidden = !me.wallpaper; } | |
| $("#wallpaper-upload").onclick = () => $("#wallpaper-input").click(); | |
| $("#wallpaper-input").onchange = async e => { | |
| const file = e.target.files[0]; | |
| e.target.value = ""; | |
| if (!file) return; | |
| if (file.size > 25 * 1024 * 1024) return toast("Файл больше 25 МБ"); | |
| const fd = new FormData(); | |
| fd.append("wallpaper", file); | |
| const res = await fetch("/api/me/wallpaper", { method: "POST", body: fd }); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) return toast(data.error || "Не удалось загрузить обои"); | |
| me.wallpaper = { ...data.wallpaper, v: Date.now() }; // v — сброс кэша браузера | |
| renderWallpaper(); | |
| renderWallpaperRow(); | |
| toast("Обои установлены"); | |
| }; | |
| $("#wallpaper-remove").onclick = () => confirmDialog("Удалить обои?", async () => { | |
| await api("/api/me/wallpaper", undefined, "DELETE"); | |
| me.wallpaper = null; | |
| renderWallpaper(); | |
| renderWallpaperRow(); | |
| }); | |
| /* Прочее: часовой пояс — «Авто» (определяется браузером) либо UTC-12..UTC+14. | |
| Хранится в cookie устройства и не сбрасывается между сессиями. */ | |
| { | |
| const auto = document.createElement("option"); | |
| auto.value = "auto"; | |
| const a = autoTz(); | |
| auto.textContent = `Авто (UTC${a >= 0 ? "+" : ""}${a})`; | |
| $("#tz-offset").appendChild(auto); | |
| } | |
| for (let tz = -12; tz <= 14; tz++) { | |
| const opt = document.createElement("option"); | |
| opt.value = tz; | |
| opt.textContent = "UTC" + (tz >= 0 ? "+" : "") + tz; | |
| $("#tz-offset").appendChild(opt); | |
| } | |
| $("#tz-offset").onchange = e => { | |
| setCookie("tz", e.target.value); | |
| me.tz_offset = resolveTz(); | |
| loadChats(); // перерисовываем время в новом поясе | |
| }; | |
| /* ===== Собственный профиль ===== */ | |
| /** Большая аватарка в профиле: картинка/гиф (img), видео (video) или заглушка. */ | |
| function renderProfileAvatar(imgId, stubId, username, hasAvatar, kind) { | |
| const img = $("#" + imgId), stub = $("#" + stubId); | |
| const container = img.parentElement; | |
| container.querySelector("video.avatar-media")?.remove(); | |
| if (!hasAvatar) { | |
| img.hidden = true; | |
| stub.style.display = ""; | |
| return; | |
| } | |
| const url = `/api/avatar/${encodeURIComponent(username)}?v=${Date.now()}`; | |
| if (kind === "video") { | |
| img.hidden = true; | |
| stub.style.display = "none"; | |
| const v = document.createElement("video"); | |
| v.className = "avatar-media"; | |
| v.src = url; | |
| v.autoplay = v.muted = v.loop = true; | |
| v.playsInline = true; | |
| container.appendChild(v); | |
| } else { // image/gif | |
| img.src = url; | |
| img.hidden = false; | |
| stub.style.display = "none"; | |
| } | |
| } | |
| function renderProfile() { | |
| setName($("#profile-name"), me.display_name, me.is_admin); | |
| $("#profile-username").textContent = "@" + me.username; | |
| $("#profile-desc").textContent = me.description || "Нет описания"; | |
| $("#profile-desc").classList.toggle("muted", !me.description); | |
| renderProfileAvatar("avatar-img", "avatar-stub", me.username, me.has_avatar, me.avatar_kind); | |
| } | |
| $("#edit-nick").onclick = editNick; | |
| $("#edit-username").onclick = editUsername; | |
| $("#edit-desc").onclick = editDescription; | |
| /** Длительность видео в секундах (метаданные читаются браузером). */ | |
| function videoDuration(file) { | |
| return new Promise((resolve, reject) => { | |
| const v = document.createElement("video"); | |
| v.preload = "metadata"; | |
| v.onloadedmetadata = () => { URL.revokeObjectURL(v.src); resolve(v.duration); }; | |
| v.onerror = () => { URL.revokeObjectURL(v.src); reject(new Error("video")); }; | |
| v.src = URL.createObjectURL(file); | |
| }); | |
| } | |
| $("#avatar-input").onchange = async e => { | |
| const file = e.target.files[0]; | |
| e.target.value = ""; | |
| if (!file) return; | |
| // Видео-аватарка: не длиннее 10 секунд и не больше 10 МБ (проверка в браузере). | |
| if (file.type.startsWith("video/")) { | |
| if (file.size > 10 * 1024 * 1024) return toast("Видео-аватарка больше 10 МБ"); | |
| const dur = await videoDuration(file).catch(() => null); | |
| if (dur === null) return toast("Не удалось прочитать видео"); | |
| if (dur > 10.5) return toast("Видео-аватарка не длиннее 10 секунд"); | |
| } | |
| const fd = new FormData(); | |
| fd.append("avatar", file); | |
| const res = await fetch("/api/me/avatar", { method: "POST", body: fd }); | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) return toast(data.error || "Не удалось загрузить аватарку"); | |
| me.has_avatar = true; | |
| me.avatar_kind = data.avatar_kind; | |
| renderProfile(); | |
| loadChats(); // обновить аватарку в списке чатов | |
| }; | |
| /* ===== Контакты (свой профиль) ===== */ | |
| async function loadContacts() { | |
| const { ok, data } = await api("/api/me/contacts"); | |
| const ul = $("#contact-list"); | |
| ul.innerHTML = ""; | |
| if (!ok || !data.length) { | |
| ul.innerHTML = `<li class="empty">Контактов пока нет</li>`; | |
| return; | |
| } | |
| for (const c of data) { | |
| const li = document.createElement("li"); | |
| const name = document.createElement("span"); | |
| name.textContent = c.display_name; // textContent — защита от XSS | |
| const uname = document.createElement("small"); | |
| uname.textContent = "@" + c.username; | |
| const del = document.createElement("button"); | |
| del.className = "icon-btn"; | |
| del.title = "Удалить из контактов"; | |
| del.innerHTML = TRASH_SVG; | |
| del.onclick = () => confirmDialog(`Удалить ${c.display_name} из контактов?`, async () => { | |
| await api("/api/me/contacts/" + encodeURIComponent(c.username), undefined, "DELETE"); | |
| loadContacts(); | |
| }); | |
| li.append(name, uname, del); | |
| ul.appendChild(li); | |
| } | |
| } | |
| /** Сворачивает список контактов (по умолчанию скрыт при открытии профиля). */ | |
| function collapseContacts() { | |
| const ul = $("#contact-list"); | |
| if (ul) ul.hidden = true; | |
| const btn = $("#contacts-toggle"); | |
| if (btn) { btn.classList.remove("open"); btn.setAttribute("aria-expanded", "false"); } | |
| } | |
| $("#contacts-toggle").onclick = () => { | |
| const ul = $("#contact-list"); | |
| const btn = $("#contacts-toggle"); | |
| const show = ul.hidden; | |
| ul.hidden = !show; | |
| btn.classList.toggle("open", show); | |
| btn.setAttribute("aria-expanded", show ? "true" : "false"); | |
| if (show) loadContacts(); // подгружаем только когда раскрыли | |
| }; | |
| $("#contact-add").onclick = () => | |
| dialog("Добавить контакт", [ | |
| { name: "username", placeholder: "Юзернейм пользователя", maxlength: 32 }, | |
| ], "Добавить", async v => { | |
| const { ok, data } = await api("/api/me/contacts", v); | |
| if (!ok) return data.error; | |
| $("#contact-list").hidden = false; // показать список после добавления | |
| $("#contacts-toggle").classList.add("open"); | |
| $("#contacts-toggle").setAttribute("aria-expanded", "true"); | |
| loadContacts(); | |
| }); | |
| /* ===== Старт: проверяем сессию ===== */ | |
| enterApp(); | |