/* SalGram — фронтенд: авторизация, чаты (WebSocket), поиск, профили, настройки. */ const $ = sel => document.querySelector(sel); let me = null; // профиль текущего пользователя (из /api/me) let ws = null; // WebSocket-соединение let currentPeer = null; // собеседник открытого личного чата: {id, ...профиль} let currentGroup = null; // открытая группа: {id, title, members, is_owner, ...} let statusTimer = null; // периодическое обновление статуса в шапке чата let pinnedMsgs = []; // закреплённые сообщения открытого чата (по возрастанию id) let replyTarget = null; // сообщение, на которое отвечаем в открытом чате (или null) let editTarget = null; // сообщение, которое редактируем прямо в поле ввода (или null) let peerReadUpto = 0; // докуда собеседник прочитал мои сообщения (id) — для галочек let typingTimer = null; // троттлинг отправки статуса «печатает» /* ===== Настройки устройства в cookie: тема и часовой пояс ===== */ const getCookie = name => document.cookie.split("; ").find(r => r.startsWith(name + "="))?.slice(name.length + 1); const setCookie = (name, value) => (document.cookie = `${name}=${value}; max-age=31536000; path=/; samesite=lax`); /* Тема применяется сразу при загрузке, ещё до входа в аккаунт */ function applyTheme() { document.documentElement.dataset.theme = getCookie("theme") || "dark"; } applyTheme(); /* Часовой пояс: "auto" — определяется браузером, иначе выбранное число */ const autoTz = () => -new Date().getTimezoneOffset() / 60; function resolveTz() { const c = getCookie("tz"); return c === undefined || c === "auto" ? autoTz() : +c; } const TRASH_SVG = ``; const BADGE_SVG = ``; /** Имя с галочкой администратора. */ function setName(el, name, isAdmin) { el.textContent = name; if (isAdmin) el.insertAdjacentHTML("beforeend", BADGE_SVG); } /** Всплывающее уведомление внизу экрана. */ function toast(text) { const t = document.createElement("div"); t.className = "toast"; t.textContent = text; document.body.appendChild(t); setTimeout(() => t.remove(), 3500); } /** Дописывает в элемент текст, делая @юзернеймы кликабельными (без очистки). */ function appendMentions(el, text) { const re = /@([A-Za-z0-9_]{3,32})/g; let last = 0, m; while ((m = re.exec(text))) { el.append(text.slice(last, m.index)); const a = document.createElement("span"); a.className = "mention"; a.textContent = m[0]; const username = m[1]; a.onclick = async e => { e.stopPropagation(); const { ok, data } = await api("/api/users/" + encodeURIComponent(username)); if (ok) openUserProfile(data.id); else toast(`Пользователя @${username} не существует`); }; el.appendChild(a); last = m.index + m[0].length; } el.append(text.slice(last)); } /** Вставляет текст: ссылки кликабельны (новая вкладка), @юзернеймы открывают * профиль, а пригласительные ссылки в группы этого сайта — группу в приложении. */ function renderMentions(el, text) { el.textContent = ""; const re = /https?:\/\/[^\s<>"'«»]+/g; let last = 0, m; while ((m = re.exec(text))) { appendMentions(el, text.slice(last, m.index)); let url = m[0]; const trail = url.match(/[.,!?;:))\]»]+$/); // пунктуация в конце — не часть ссылки if (trail) url = url.slice(0, -trail[0].length); const a = document.createElement("a"); a.className = "msg-link"; a.textContent = url; a.href = url; a.target = "_blank"; a.rel = "noopener noreferrer"; a.onclick = e => e.stopPropagation(); // не открывать профиль/меню по клику // Ссылка-приглашение в группу на этом же сайте открывается на этой странице. const join = url.match(/#join=([\w-]{8,64})$/); if (join && url.startsWith(location.origin + "/")) { a.onclick = e => { e.preventDefault(); e.stopPropagation(); openJoinLink(join[1]); }; } el.appendChild(a); if (trail) el.append(trail[0]); last = m.index + m[0].length; } appendMentions(el, text.slice(last)); } /** Запрос к API (JSON). Возвращает {ok, data}. */ async function api(path, body, method) { const res = await fetch(path, body !== undefined ? { method: method || "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), } : { method: method || "GET" }); return { ok: res.ok, data: await res.json().catch(() => ({})) }; } /* ===== Время: хранится в UTC (ЧП +0), показывается в поясе из настроек ===== */ const two = n => String(n).padStart(2, "0"); const parseUTC = s => new Date(s.replace(" ", "T") + "Z"); const shifted = s => new Date(parseUTC(s).getTime() + me.tz_offset * 3600e3); function fmtTime(s) { const d = shifted(s); return `${two(d.getUTCHours())}:${two(d.getUTCMinutes())}`; } function fmtDate(s) { const d = shifted(s); return `${two(d.getUTCDate())}.${two(d.getUTCMonth() + 1)}.${d.getUTCFullYear()}`; } /** Время, если сообщение сегодняшнее, иначе дата + время. */ function fmtSmart(s) { const today = new Date(Date.now() + me.tz_offset * 3600e3).toISOString().slice(0, 10); return shifted(s).toISOString().slice(0, 10) === today ? fmtTime(s) : `${fmtDate(s)} ${fmtTime(s)}`; } /** Статус: в сети / был(а) N назад / дата / аккаунт удалён / заблокирован. */ function fmtStatus(u) { if (u.deleted) return "аккаунт удалён"; if (u.is_system) return "автоматизированный аккаунт"; if (u.banned) return "аккаунт заблокирован"; if (u.online) return "в сети"; if (!u.last_seen) return ""; // скрыто (например, для заблокированных) const diff = (Date.now() - parseUTC(u.last_seen).getTime()) / 1000; if (diff < 60) return "был(а) только что"; if (diff < 3600) return `был(а) ${Math.floor(diff / 60)} мин назад`; if (diff < 86400) return `был(а) ${Math.floor(diff / 3600)} ч назад`; return `был(а) ${fmtDate(u.last_seen)} в ${fmtTime(u.last_seen)}`; } /* ===== Экраны и виды ===== */ function show(screen) { $("#auth").hidden = screen !== "auth"; $("#app").hidden = screen !== "app"; } /** Открывает вид справа: "empty" | "chat" | "user" | "group" | "settings" | "profile". */ function openView(name) { for (const v of ["empty", "chat", "user", "group", "settings", "profile"]) $("#view-" + v).hidden = v !== name; // На телефоне видна одна панель: список чатов (empty) либо открытый вид. $("#app").classList.toggle("show-main", name !== "empty"); $("#nav-settings").classList.toggle("active", name === "settings"); $("#nav-profile").classList.toggle("active", name === "profile"); if (name !== "chat") { clearInterval(statusTimer); currentPeer = null; currentGroup = null; resetTyping(); } if (name === "settings") openSettingsPage("page-root"); if (name === "profile") collapseContacts(); // список контактов по умолчанию скрыт } /** Загружает профиль, подключает WebSocket и входит в приложение. */ async function enterApp() { const { ok, data } = await api("/api/me"); if (!ok) return show("auth"); me = data; // Часовой пояс — из cookie устройства (по умолчанию автоопределение). me.tz_offset = resolveTz(); if (getCookie("tz") === undefined) setCookie("tz", "auto"); renderProfile(); $("#privacy-avatar").value = me.privacy.avatar; $("#privacy-contacts").value = me.privacy.contacts; $("#privacy-group-invite").value = me.privacy.group_invite; $("#slow-seconds").value = me.slow_seconds; $("#slow-scope").value = me.slow_scope; $("#tz-offset").value = getCookie("tz") || "auto"; $("#theme-select").value = getCookie("theme") || "dark"; // Забаненный аккаунт: только чтение — вместо поиска красная надпись, // настройки и профиль недоступны. $("#search-input").hidden = !!me.banned; $("#banned-banner").hidden = !me.banned; $("#nav-settings").hidden = !!me.banned; $("#nav-profile").hidden = !!me.banned; $("#group-create").hidden = !!me.banned; show("app"); openView("empty"); renderWallpaper(); connectWS(); initNotifications(); loadChats(); handleJoinHash(); // вступление по пригласительной ссылке /#join= } /* ===== WebSocket ===== */ function connectWS() { ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws"); ws.onmessage = e => handleWS(JSON.parse(e.data)); // Пинг каждые 25 с: прокси хостингов закрывают неактивные WS-соединения. const ping = setInterval(() => { if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" })); }, 25000); ws.onclose = () => { clearInterval(ping); if (me) setTimeout(connectWS, 2000); // авто-переподключение }; } /* ===== Уведомления о сообщениях ===== Открытое приложение уведомляет само (по WebSocket); когда оно закрыто — уведомления приходят через Web Push (service worker). Сервер шлёт push только офлайн-получателям, поэтому дублей нет. В EXE используется нативный мост. */ let swReg = null; // регистрация service worker (PWA + push) let unreadCount = 0; // непрочитанные — для счётчика в заголовке вкладки const notifyOn = () => localStorage.getItem("notify") !== "0"; const hasBridge = () => !!window.pywebview?.api?.notify; // десктоп-приложение (EXE) /* ===== ПК-приложение vs веб-версия ===== В ПК-приложении страница открыта внутри pywebview, поэтому существует объект window.pywebview (в браузере и PWA его нет). Только там показываем раздел «Приложение», куда переносим оформление, кэш и уведомления, а «Оформление» как отдельную кнопку прячем. window.pywebview может появиться чуть позже загрузки страницы — поэтому applyDesktopUI() вызывается и по событию pywebviewready. */ const isDesktopApp = () => !!window.pywebview; function applyDesktopUI() { if (!isDesktopApp()) return; if (document.body.classList.contains("is-desktop")) return; // переносим один раз document.body.classList.add("is-desktop"); const page = $("#page-app"); const heading = text => { const h = document.createElement("h3"); h.className = "settings-sub"; h.textContent = text; page.appendChild(h); }; // append() перемещает существующие узлы — обработчики и id сохраняются. heading("Оформление"); page.append($("#row-theme"), $("#row-wallpaper"), $("#hint-wallpaper"), $("#wallpaper-input")); heading("Кэш"); page.append($("#row-cache"), $("#hint-cache")); heading("Уведомления"); page.append($("#row-notify"), $("#notify-hint")); // Автозапуск (статичные узлы page-app) переносим в конец, после остальных секций. page.append($("#sub-autostart"), $("#row-autostart"), $("#hint-autostart")); // Кнопку «Оформление» прячем — её содержимое теперь в «Приложении». $("#root-appearance").hidden = true; $("#root-app").hidden = false; syncAutostartToggle(); } /** Подтягивает состояние автозапуска из нативного моста в чекбокс. */ async function syncAutostartToggle() { const cb = $("#autostart-toggle"); if (!cb) return; if (!window.pywebview?.api?.get_autostart) { cb.disabled = true; return; } cb.disabled = false; // мост готов — переключатель активен try { cb.checked = !!(await window.pywebview.api.get_autostart()); } catch { /* мост недоступен — оставляем как есть */ } } window.addEventListener("pywebviewready", () => { applyDesktopUI(); // идемпотентно: перенос секций выполнится один раз syncAutostartToggle(); // мост уже готов — подтягиваем актуальное состояние }); document.addEventListener("DOMContentLoaded", () => { applyDesktopUI(); // на случай, если мост готов ещё до загрузки DOM const cb = $("#autostart-toggle"); if (cb) cb.onchange = async e => { const on = e.target.checked; try { const ok = await window.pywebview?.api?.set_autostart(on); if (ok === false) throw new Error(); toast(on ? "Автозапуск включён" : "Автозапуск выключен"); } catch { e.target.checked = !on; // откатываем переключатель, если не вышло toast("Не удалось изменить автозапуск"); } }; }); function updateTitle() { document.title = unreadCount > 0 ? `(${unreadCount}) SalGram` : "SalGram"; } function clearUnread() { if (unreadCount) { unreadCount = 0; updateTitle(); } } window.addEventListener("focus", () => { clearUnread(); sendRead(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden) { clearUnread(); sendRead(); } }); /** Показывает уведомление: нативный тост (EXE) либо Web Notification (браузер). */ function showNotification(title, body, m) { if (hasBridge()) { try { window.pywebview.api.notify(title, body); } catch { /* ignore */ } return; } if (typeof Notification === "undefined" || Notification.permission !== "granted") return; try { const n = new Notification(title, { body, icon: "/icon-192.png", badge: "/icon-192.png", tag: m.group ? "grp-" + m.group : "dm-" + m.from, }); n.onclick = () => { window.focus(); if (m.group) openGroup(m.group); else openChat(m.from === me.id ? m.to : m.from); n.close(); }; } catch { /* ignore */ } } /** Решает, нужно ли уведомить о пришедшем сообщении, и обновляет счётчик. */ function maybeNotify(m) { if (!me || m.from === me.id) return; const visibleHere = !document.hidden && (m.group ? currentGroup?.id === m.group : currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id)); if (visibleHere) return; // пользователь и так видит это сообщение unreadCount++; updateTitle(); if (!notifyOn()) return; const title = m.group ? `${m.from_name || "Сообщение"} • группа` : (m.from_name || "Новое сообщение"); showNotification(title, commentShort(m), m); } function urlBase64ToUint8Array(b64) { const pad = "=".repeat((4 - (b64.length % 4)) % 4); const raw = atob((b64 + pad).replace(/-/g, "+").replace(/_/g, "/")); return Uint8Array.from([...raw].map(c => c.charCodeAt(0))); } /** Подписывает браузер на Web Push (для уведомлений при закрытом приложении). */ async function subscribePush() { if (!swReg || typeof Notification === "undefined" || Notification.permission !== "granted") return; try { const { ok, data } = await api("/api/push/key"); if (!ok || !data.enabled || !data.key) return; let sub = await swReg.pushManager.getSubscription(); if (!sub) sub = await swReg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(data.key), }); await api("/api/push/subscribe", sub.toJSON()); } catch { /* push недоступен — остаются уведомления при открытом приложении */ } } async function unsubscribePush() { try { const sub = await swReg?.pushManager.getSubscription(); if (sub) { await api("/api/push/unsubscribe", { endpoint: sub.endpoint }); await sub.unsubscribe(); } } catch { /* ignore */ } } /** Приводит чекбокс настроек в соответствие текущему состоянию разрешений. */ function syncNotifyToggle() { const cb = $("#notify-toggle"); if (!cb) return; const supported = hasBridge() || typeof Notification !== "undefined"; cb.disabled = !supported; cb.checked = supported && notifyOn() && (hasBridge() || Notification.permission === "granted"); } async function initNotifications() { if ("serviceWorker" in navigator) { try { swReg = await navigator.serviceWorker.register("/sw.js"); } catch { /* ignore */ } navigator.serviceWorker.addEventListener("message", e => { const d = e.data; // клик по фоновому push-уведомлению if (d?.type === "notification-click" && me) { if (d.data?.group) openGroup(d.data.group); else if (d.data?.peer) openChat(d.data.peer); } }); } // Уведомления включены по умолчанию: при первом заходе сами спрашиваем разрешение // браузера/PWA/TWA. Без этого showNotification молча ничего не показывает // (permission остаётся "default"), и кажется, что уведомления не приходят. // Десктоп-обёртку (pywebview) пропускаем — там нативный мост, а не Web Notification // (window.pywebview может появиться чуть позже моста, поэтому проверяем и его). if (notifyOn() && !hasBridge() && !window.pywebview && typeof Notification !== "undefined" && Notification.permission === "default") { try { await Notification.requestPermission(); } catch { /* ignore */ } } syncNotifyToggle(); if (notifyOn() && !hasBridge() && typeof Notification !== "undefined" && Notification.permission === "granted") subscribePush(); } document.addEventListener("DOMContentLoaded", () => { const cb = $("#notify-toggle"); if (!cb) return; cb.onchange = async e => { if (e.target.checked) { let perm = "granted"; if (!hasBridge() && typeof Notification !== "undefined") perm = await Notification.requestPermission(); if (perm !== "granted") { e.target.checked = false; return toast("Уведомления запрещены в настройках браузера"); } localStorage.setItem("notify", "1"); subscribePush(); toast("Уведомления включены"); } else { localStorage.setItem("notify", "0"); unsubscribePush(); toast("Уведомления выключены"); } }; }); function handleWS(msg) { switch (msg.type) { case "message": { const m = msg.message; cacheAppend(m); maybeNotify(m); const inOpen = m.group ? currentGroup?.id === m.group : currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id); if (inOpen) { appendMessage(m); // Входящее в открытой личной переписке — сразу отмечаем прочитанным. if (!m.group && m.from !== me.id) { delete typingState[m.from]; renderTypingStatus(); sendRead(); } } if (m.thread_root) { // комментарий: дописать в открытый тред и обновить счётчик onCommentMessage(m); bumpCommentCount(m.thread_root); } loadChats(); // обновляем превью в списке чатов break; } case "typing": { const open = msg.group ? currentGroup?.id === msg.group : currentPeer && currentPeer.id === msg.from; if (open && msg.from !== me.id) noteTyping(msg.from, msg.from_name); break; } case "read": if (!currentGroup && currentPeer && msg.with === currentPeer.id) { peerReadUpto = Math.max(peerReadUpto, msg.upto || 0); updateTicks(); } break; case "history": if (msg.thread) { renderCommentHistory(msg); break; } // тред комментариев // Свежая история с сервера — источник истины, перезаписываем кэш. cacheSave(msg.group ? "g" + msg.group : msg.with, { messages: msg.messages, pinned: msg.pinned || [] }); if (msg.group ? currentGroup?.id === msg.group : currentPeer && msg.with === currentPeer.id) { if (!msg.group) peerReadUpto = msg.peer_read || 0; // докуда собеседник прочитал $("#messages").innerHTML = ""; msg.messages.forEach(appendMessage); scrollMessages(); pinnedMsgs = msg.pinned || []; renderPinnedBar(); if (!msg.group) sendRead(); // отметить прочитанным то, что пришло } break; case "message_edited": { cacheUpdate(msg.message); const div = $(`#messages [data-id="${msg.message.id}"]`); if (div) fillMessage(div, msg.message); pinnedMsgs = pinnedMsgs.map(p => (p.id === msg.message.id ? msg.message : p)); renderPinnedBar(); loadChats(); break; } case "message_deleted": cacheDelete(cachePeer(msg), msg.id); $(`#messages [data-id="${msg.id}"]`)?.remove(); pinnedMsgs = pinnedMsgs.filter(p => p.id !== msg.id); renderPinnedBar(); loadChats(); break; case "message_pinned": { const m = msg.message; cacheUpdate(m); const div = $(`#messages [data-id="${m.id}"]`); if (div) fillMessage(div, m); // обновить значок 📌 в пузыре const inOpen = m.group ? currentGroup?.id === m.group : currentPeer && (m.from === currentPeer.id || m.to === currentPeer.id); if (inOpen) { pinnedMsgs = pinnedMsgs.filter(p => p.id !== m.id); if (m.extra?.pinned) { pinnedMsgs.push(m); pinnedMsgs.sort((a, b) => a.id - b.id); } renderPinnedBar(); } break; } case "reaction": { cacheReaction(cachePeer(msg), msg.id, msg.reactions); const div = $(`#messages [data-id="${msg.id}"]`); if (div) { div._m.reactions = msg.reactions; renderReactions(div, div._m); } break; } case "chat_deleted": cacheDrop(msg.with); // переписка удалена — чистим её кэш if (currentPeer?.id === msg.with) openView("empty"); loadChats(); break; case "group_removed": // вышли из группы либо её удалили cacheDrop("g" + msg.group); if (currentGroup?.id === msg.group) openView("empty"); loadChats(); break; case "group_added": // нас добавили в группу — она появляется в списке чатов loadChats(); break; case "moderation": // мут/кик/бан/назначение — уведомление и обновление состояния toast(msg.text); if (currentGroup?.id === msg.group) refreshGroupHead(); loadChats(); break; case "error": showChatError(msg.error); break; } } function showChatError(text) { const el = $("#chat-error"); el.textContent = text; el.hidden = false; clearTimeout(el._t); el._t = setTimeout(() => (el.hidden = true), 4000); } /* ===== Список чатов ===== */ /** Кружок-аватарка: картинка/гиф (img), видео (video) либо первая буква имени. */ function avaNode(peerId, username, name, kind) { const div = document.createElement("div"); div.className = "ava"; div.textContent = (name || "?")[0].toUpperCase(); if (username) { const url = "/api/avatar/" + encodeURIComponent(username); if (kind === "video") { const v = document.createElement("video"); v.src = url; v.autoplay = v.muted = v.loop = true; v.playsInline = true; v.onerror = () => v.remove(); // нет аватарки или скрыта приватностью div.appendChild(v); } else { // image/gif — гиф анимируется в сам const img = document.createElement("img"); img.src = url; img.onerror = () => img.remove(); div.appendChild(img); } } return div; } async function loadChats() { const { ok, data } = await api("/api/chats"); if (!ok) return; const list = $("#chat-list"); list.innerHTML = ""; if (!data.length) { list.innerHTML = `

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

`; return; } for (const c of data) { const row = document.createElement("button"); const info = document.createElement("div"); info.className = "chat-row-info"; const name = document.createElement("span"); const preview = document.createElement("small"); if (c.group_id) { // групповой чат row.className = "chat-row" + (currentGroup?.id === c.group_id ? " active" : ""); row.appendChild(groupAvaNode(c.group_id, c.group_title)); name.textContent = c.group_title; preview.textContent = !c.kind ? "Группа создана" : (c.from_me ? "Вы: " : c.sender_name ? c.sender_name + ": " : "") + (c.kind === "photo" ? "Фото" : c.kind === "gif" ? "GIF" : c.kind === "call" ? "Видеозвонок" : c.kind === "audio" ? (c.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио") : c.kind === "file" ? (c.extra?.name || "Файл") : c.content); row.onclick = () => openGroup(c.group_id); row.oncontextmenu = e => openGroupRowMenu(e, c.group_id); } else { row.className = "chat-row" + (currentPeer?.id === c.peer_id ? " active" : ""); row.appendChild(avaNode(c.peer_id, c.peer_deleted ? null : c.peer_username, c.peer_name, c.peer_avatar_kind)); name.textContent = c.peer_deleted ? "Удалённый аккаунт" : c.peer_name; preview.textContent = (c.from_me ? "Вы: " : "") + (c.kind === "photo" ? "Фото" : c.kind === "gif" ? "GIF" : c.kind === "call" ? "Видеозвонок" : c.kind === "audio" ? (c.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио") : c.kind === "file" ? (c.extra?.name || "Файл") : c.content); row.onclick = () => openChat(c.peer_id); row.oncontextmenu = e => openChatMenu(e, c.peer_id); // ПКМ — удалить переписку } info.append(name, preview); const time = document.createElement("time"); time.textContent = fmtSmart(c.created_at); row.append(info, time); list.appendChild(row); } } /* ===== Кэш сообщений (localStorage) ===== Открытый ранее чат показывается мгновенно из кэша, затем содержимое заменяется свежей историей с сервера. Кэш синхронизируется с событиями WS: правки, удаления, реакции и закрепления применяются и к кэшу, даже если чат сейчас не открыт. Размер и очистка — в настройках «Чаты». */ const CACHE_LIMIT = 300; // храним не больше N последних сообщений на чат const cacheKey = peerId => `msgCache:${me.id}:${peerId}`; const cacheKeys = () => Object.keys(localStorage).filter(k => k.startsWith(`msgCache:${me.id}:`)); // Ключ чата для сообщения: группа — "g", личный — id собеседника. const cachePeer = m => (m.group ? "g" + m.group : m.from === me.id ? m.to : m.from); function cacheLoad(peerId) { try { return JSON.parse(localStorage.getItem(cacheKey(peerId))); } catch { return null; } } function cacheSave(peerId, data) { data.messages = data.messages.slice(-CACHE_LIMIT); try { localStorage.setItem(cacheKey(peerId), JSON.stringify(data)); } catch { localStorage.removeItem(cacheKey(peerId)); } // хранилище переполнено } /** Новое сообщение: дописываем в существующий кэш (новый создаст история). */ function cacheAppend(m) { const peerId = cachePeer(m); const data = cacheLoad(peerId); if (!data) return; if (!data.messages.some(x => x.id === m.id)) data.messages.push(m); cacheSave(peerId, data); } /** Правка или закрепление: заменяем сообщение по id, обновляем закреплённые. */ function cacheUpdate(m) { const peerId = cachePeer(m); const data = cacheLoad(peerId); if (!data) return; data.messages = data.messages.map(x => (x.id === m.id ? m : x)); data.pinned = (data.pinned || []).filter(p => p.id !== m.id); if (m.extra?.pinned) { data.pinned.push(m); data.pinned.sort((a, b) => a.id - b.id); } cacheSave(peerId, data); } /** Удалённое сообщение пропадает и из кэша. */ function cacheDelete(peerId, id) { const data = cacheLoad(peerId); if (!data) return; data.messages = data.messages.filter(x => x.id !== id); data.pinned = (data.pinned || []).filter(p => p.id !== id); cacheSave(peerId, data); } function cacheReaction(peerId, id, reactions) { const data = cacheLoad(peerId); if (!data) return; for (const list of [data.messages, data.pinned || []]) for (const m of list) if (m.id === id) m.reactions = reactions; cacheSave(peerId, data); } function cacheDrop(peerId) { localStorage.removeItem(cacheKey(peerId)); } function clearMsgCache() { cacheKeys().forEach(k => localStorage.removeItem(k)); } /** Вес кэша в байтах (строки localStorage — UTF-16, 2 байта на символ). */ function msgCacheSize() { let chars = 0; for (const k of cacheKeys()) chars += k.length + (localStorage.getItem(k) || "").length; return chars * 2; } function renderCacheSize() { const n = msgCacheSize(); $("#cache-size").textContent = n ? fmtSize(n) : "пусто"; $("#cache-clear").hidden = !n; } /* ===== Чат ===== */ async function openChat(peerId) { openView("chat"); currentPeer = { id: peerId }; currentGroup = null; peerReadUpto = 0; resetTyping(); clearReply(); clearEdit(); $("#messages").innerHTML = ""; // Чат открывается мгновенно из кэша; ответ "history" заменит его свежим. const cached = cacheLoad(peerId); if (cached) { cached.messages.forEach(appendMessage); scrollMessages(); } pinnedMsgs = cached?.pinned || []; renderPinnedBar(); await refreshChatHead(); ws?.send(JSON.stringify({ type: "history", with: peerId })); clearInterval(statusTimer); statusTimer = setInterval(refreshChatHead, 30000); // статус обновляется каждые 30 с loadChats(); } /** Обновляет шапку чата (имя, статус) и доступность поля ввода. */ async function refreshChatHead() { if (!currentPeer) return; const { ok, data } = await api("/api/peer/" + currentPeer.id); if (!ok) return; currentPeer = data; setName($("#chat-name"), data.deleted ? "Удалённый аккаунт" : data.display_name, data.is_admin); if (!Object.keys(typingState).length) $("#chat-status").textContent = fmtStatus(data); $("#chat-ava").replaceWith(Object.assign( avaNode(data.id, data.username, data.display_name, data.avatar_kind), { id: "chat-ava" })); // Поле ввода заменяется на причину, по которой писать нельзя. const reason = me.banned ? "Ваш аккаунт заблокирован" : data.deleted ? "Аккаунт удалён" : data.banned ? "Аккаунт заблокирован" : data.blocked_me ? "Пользователь заблокировал вас" : data.i_blocked ? "Вы заблокировали пользователя" : (data.is_system && data.username === "reports") ? "Автоматизированный аккаунт" // В @salgram пишет только админ; остальным он шлёт рассылки и уведомления. : (data.is_system && data.username === "salgram" && !me.is_admin) ? "Официальный аккаунт SalGram" : ""; // В чате с ботом @addgif вместо поля ввода — кнопка добавления GIF в библиотеку. const isAddgif = data.is_system && data.username === "addgif"; $("#addgif-bar").hidden = !isAddgif || !!me.banned; $("#channel-bar").hidden = true; // панель подписчика канала — только в канале $("#msg-form").hidden = !!reason || isAddgif; $("#chat-blocked").hidden = !reason; $("#chat-blocked").textContent = reason; $("#chat-delete").hidden = !!me.banned; // в режиме «только чтение» удалять нельзя } function scrollMessages() { const box = $("#messages"); box.scrollTop = box.scrollHeight; } /* ===== Группы ===== */ /** Кружок-аватарка группы: картинка либо первая буква названия. */ function groupAvaNode(gid, title) { const div = document.createElement("div"); div.className = "ava"; div.textContent = (title || "?")[0].toUpperCase(); const img = document.createElement("img"); img.src = "/api/group_avatar/" + gid; img.onerror = () => img.remove(); // аватарки нет — остаётся буква div.appendChild(img); return div; } /** «5 участников» с правильным склонением. */ function fmtMembers(n) { const d = n % 10, h = n % 100; const word = d === 1 && h !== 11 ? "участник" : d >= 2 && d <= 4 && (h < 12 || h > 14) ? "участника" : "участников"; return `${n} ${word}`; } /** «5 подписчиков» с правильным склонением (для каналов). */ function fmtSubscribers(n) { const d = n % 10, h = n % 100; const word = d === 1 && h !== 11 ? "подписчик" : d >= 2 && d <= 4 && (h < 12 || h > 14) ? "подписчика" : "подписчиков"; return `${n} ${word}`; } /** Сводка участников/подписчиков по типу (группа или канал). */ const fmtAudience = g => g.is_channel ? fmtSubscribers(g.members) : fmtMembers(g.members); async function openGroup(gid) { openView("chat"); currentPeer = null; currentGroup = { id: gid }; peerReadUpto = 0; resetTyping(); clearReply(); clearEdit(); $("#messages").innerHTML = ""; // Как и личные чаты, группа открывается мгновенно из кэша. const cached = cacheLoad("g" + gid); if (cached) { cached.messages.forEach(appendMessage); scrollMessages(); } pinnedMsgs = cached?.pinned || []; renderPinnedBar(); await refreshGroupHead(); ws?.send(JSON.stringify({ type: "history", group: gid })); clearInterval(statusTimer); statusTimer = setInterval(refreshGroupHead, 30000); // число участников в шапке loadChats(); } /** Шапка группового чата: название, число участников, доступность ввода. */ async function refreshGroupHead() { if (!currentGroup) return; const { ok, data } = await api("/api/groups/" + currentGroup.id); if (!ok) return; currentGroup = data; $("#chat-name").textContent = data.title; if (!Object.keys(typingState).length) $("#chat-status").textContent = fmtAudience(data); $("#chat-ava").replaceWith(Object.assign( groupAvaNode(data.id, data.title), { id: "chat-ava" })); // В канале публикует только владелец/админ; подписчики читают и комментируют. const canPost = !data.is_channel || data.is_owner || me.is_admin; $("#addgif-bar").hidden = true; // панель бота — только в личном чате с @addgif // Подписчику канала вместо поля ввода — кнопки «Открыть чат» и «Отписаться». const subscriberBar = data.is_channel && data.is_member && !canPost && !me.banned; $("#channel-bar").hidden = !subscriberBar; $("#channel-open-chat").hidden = !data.linked_group_id; // нет обсуждения — нет кнопки $("#channel-open-chat").onclick = () => openComments(data.id); $("#channel-unsub").onclick = () => leaveGroup(data.id, true); $("#msg-form").hidden = !!me.banned || !data.is_member || !canPost || !!data.my_muted; // Текст «недоступно» — только для мута/бана; у подписчика канала вместо него кнопки. const reason = me.banned ? "Ваш аккаунт заблокирован" : data.my_muted ? "Вы не можете писать в этой группе (мут)" : ""; $("#chat-blocked").hidden = !reason; $("#chat-blocked").textContent = reason; const del = $("#chat-delete"); del.hidden = !!me.banned; del.title = data.is_owner ? (data.is_channel ? "Удалить канал" : "Удалить группу") : (data.is_channel ? "Отписаться" : "Выйти из группы"); } async function openGroupProfile(gid) { const { ok, data } = await api("/api/groups/" + gid); if (!ok) return toast(data.error || "Группа не найдена"); showGroupProfile(data); } /** invite — хэш ссылки, по которой пришёл не-участник (вступление идёт через неё). */ function showGroupProfile(g, invite) { openView("group"); const ava = $("#group-ava"); ava.textContent = (g.title || "?")[0].toUpperCase(); // сбрасывает и старую картинку if (g.has_avatar) { const img = document.createElement("img"); img.src = `/api/group_avatar/${g.id}?v=${Date.now()}`; img.onerror = () => img.remove(); ava.appendChild(img); } // Владелец меняет аватарку кликом по кружку. const canEditAva = g.is_owner && !me.banned; ava.classList.toggle("editable", canEditAva); ava.title = canEditAva ? "Изменить аватарку" : ""; ava.onclick = canEditAva ? () => $("#group-avatar-input").click() : null; $("#group-avatar-input").onchange = async e => { const file = e.target.files[0]; e.target.value = ""; if (!file) return; const fd = new FormData(); fd.append("avatar", file); const res = await fetch(`/api/groups/${g.id}/avatar`, { method: "POST", body: fd }); if (!res.ok) { const data = await res.json().catch(() => ({})); return toast(data.error || "Не удалось загрузить аватарку"); } openGroupProfile(g.id); // перерисовать профиль с новой аватаркой loadChats(); // и список чатов }; $("#group-title").textContent = g.title; const kindWord = g.is_channel ? "канал" : "группа"; const pubWord = g.public ? (g.is_channel ? "Публичный" : "Публичная") : (g.is_channel ? "Приватный" : "Приватная"); $("#group-meta").textContent = `${pubWord} ${kindWord}` + (g.username ? ` · @${g.username}` : "") + ` · ${fmtAudience(g)}`; $("#group-open").hidden = !g.is_member; $("#group-open").onclick = () => openGroup(g.id); $("#group-join").hidden = g.is_member || !!me.banned; $("#group-join").textContent = g.is_channel ? "Подписаться" : "Вступить"; $("#group-join").onclick = async () => { const { ok, data } = await api( invite ? "/api/groups/join/" + invite : `/api/groups/${g.id}/join`, {}); if (!ok) return toast(data.error || "Не удалось вступить"); toast(g.is_channel ? `Вы подписались на «${g.title}»` : `Вы вступили в группу «${g.title}»`); openGroup(g.id); loadChats(); }; // Пригласительная ссылка — только участникам (сервер не отдаёт хэш чужим). const inviteRow = $("#group-invite-row"); inviteRow.hidden = !g.invite_hash; if (g.invite_hash) { const link = location.origin + "/#join=" + g.invite_hash; $("#group-invite").textContent = link; $("#group-invite-copy").onclick = async () => { try { await navigator.clipboard.writeText(link); toast("Ссылка скопирована"); } catch { toast(link); } // буфер недоступен — показываем ссылку }; } const sec = $("#group-members-sec"); sec.hidden = !g.member_list; $("#group-members-title").textContent = g.is_channel ? "Подписчики" : "Участники"; // В группе добавить участника может любой участник; в канал подписчиков добавляет // только владелец/админ. В режиме «только чтение» (бан) — нельзя. const addBtn = $("#group-add-member"); addBtn.hidden = !g.is_member || !!me.banned || (g.is_channel && !g.is_owner && !me.is_admin); addBtn.title = g.is_channel ? "Добавить подписчика" : "Добавить участника"; addBtn.onclick = () => openAddMemberDialog(g.id); if (g.member_list) { const ul = $("#group-member-list"); ul.innerHTML = ""; for (const u of g.member_list) { const li = document.createElement("li"); const name = document.createElement("span"); name.textContent = u.display_name; const role = u.is_owner ? " · владелец" : u.is_admin ? " · админ" : ""; const uname = document.createElement("small"); uname.textContent = "@" + u.username + role + (u.muted ? " · мут" : ""); li.append(name, uname); // Кнопка действий модерации: для модераторов, кроме владельца и себя; // админа может модерировать только владелец/глобальный админ. const canActHere = g.can_moderate && !u.is_owner && u.id !== me.id && (!u.is_admin || g.is_owner || me.is_admin) && !me.banned; if (canActHere) { const act = document.createElement("button"); act.className = "icon-btn member-actions"; act.textContent = "⋮"; act.title = "Действия модерации"; act.onclick = e => { e.stopPropagation(); openMemberMenu(e, g, u); }; li.appendChild(act); } li.style.cursor = "pointer"; li.onclick = () => openUserProfile(u.id); ul.appendChild(li); } } renderChannelExtra(g); // комментарии канала: открыть / привязать / отвязать группу renderModeration(g); // настройки модерации (для владельца/админов) $("#group-leave").hidden = !g.is_member || g.is_owner || !!me.banned; $("#group-leave").textContent = g.is_channel ? "Отписаться" : "Выйти из группы"; $("#group-leave").onclick = () => leaveGroup(g.id, g.is_channel); $("#group-delete").hidden = !(g.is_owner || me.is_admin) || !!me.banned; $("#group-delete").textContent = g.is_channel ? "Удалить канал" : "Удалить группу"; $("#group-delete").onclick = () => deleteGroup(g.id); } /** Блок комментариев канала в его профиле: переход к комментариям и (для владельца) * привязка/отвязка группы для комментариев. */ function renderChannelExtra(g) { const box = $("#group-channel-extra"); box.innerHTML = ""; if (!g.is_channel) return; if (g.linked_group_id && g.is_member) { const open = document.createElement("button"); open.className = "primary write-btn"; open.textContent = "Комментарии"; open.onclick = () => openComments(g.id); box.appendChild(open); } if ((g.is_owner || me.is_admin) && !me.banned) { const ctl = document.createElement("button"); ctl.className = "link"; if (g.linked_group_id) { ctl.textContent = `Отвязать комментарии (${g.linked_group_title || "группа"})`; ctl.onclick = () => confirmDialog("Отвязать группу комментариев?", async () => { const { ok, data } = await api(`/api/channels/${g.id}/unlink`, {}); if (!ok) return toast(data.error || "Ошибка"); openGroupProfile(g.id); }); } else { ctl.textContent = "Привязать группу для комментариев"; ctl.onclick = () => linkCommentsDialog(g.id); } box.appendChild(ctl); } } /** Открытие комментариев канала: вступаем в привязанную группу и открываем её чат. */ async function openComments(cid) { const { ok, data } = await api(`/api/channels/${cid}/comments`, {}); if (!ok) return toast(data.error || "Комментарии недоступны"); openGroup(data.id); loadChats(); } /* ===== Окно комментариев (тред под постом канала, как в Telegram) ===== */ let commentThread = null; // { cid, discGid, rootId, replyTo, byId, listEl, replyBar, input } const commentShort = m => m.kind === "text" ? m.content : m.kind === "photo" ? "Фото" : m.kind === "gif" ? "GIF" : m.kind === "audio" ? (m.extra?.voice ? "🎤 Голосовое" : "🎵 Аудио") : m.kind === "file" ? (m.extra?.name || "Файл") : "сообщение"; /** Тело комментария: текст/фото/гиф/файл (как в обычном сообщении). */ function renderCommentBody(div, m) { if (m.kind === "photo") { const img = document.createElement("img"); img.src = "/api/photos/" + m.content; img.loading = "lazy"; img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; div.appendChild(img); } else if (m.kind === "gif") { const img = document.createElement("img"); img.className = "msg-gif"; img.src = "/api/gifs/" + m.content; img.loading = "lazy"; img.onclick = e => { e.stopPropagation(); openPhoto(img.src); }; div.appendChild(img); } else if (m.kind === "file") { renderFile(div, m); } else { const p = document.createElement("p"); renderMentions(p, m.content); div.appendChild(p); } } /** Узел комментария в треде. isRoot — корневой пост сверху. */ function renderCommentNode(m, isRoot) { const div = document.createElement("div"); div.className = "comment" + (isRoot ? " root" : (m.from === me.id ? " mine" : "")); div.dataset.id = m.id; div._m = m; if (m.from_name && !isRoot) { const a = document.createElement("span"); a.className = "msg-author"; a.textContent = m.from_name; a.onclick = e => { e.stopPropagation(); openUserProfile(m.from); }; div.appendChild(a); } // Цитата родителя (ответ на другой комментарий, а не на сам пост). if (!isRoot && m.reply_to && m.reply_to !== commentThread?.rootId) { const parent = commentThread?.byId.get(m.reply_to); const q = document.createElement("div"); q.className = "reply-quote"; q.textContent = parent ? commentShort(parent).slice(0, 80) : "комментарий"; div.appendChild(q); } renderCommentBody(div, m); const time = document.createElement("time"); time.textContent = fmtSmart(m.created_at); div.appendChild(time); if (!isRoot && !me.banned) { const r = document.createElement("button"); r.className = "link comment-reply"; r.textContent = "Ответить"; r.onclick = e => { e.stopPropagation(); setCommentReply(m); }; div.appendChild(r); } return div; } function setCommentReply(m) { if (!commentThread) return; commentThread.replyTo = m.id; commentThread.replyBar.hidden = false; commentThread.replyBar.querySelector("span").textContent = "В ответ: " + commentShort(m).slice(0, 60); commentThread.input.focus(); } function clearCommentReply() { if (!commentThread) return; commentThread.replyTo = commentThread.rootId; commentThread.replyBar.hidden = true; } /** Открыть окно комментариев под постом. cid — канал, rootId — id поста-зеркала. */ async function openCommentsThread(cid, rootId) { // Вступаем в привязанную группу-обсуждение (чтобы читать/писать) и получаем её id. const { ok, data } = await api(`/api/channels/${cid}/comments`, {}); if (!ok) return toast(data.error || "Комментарии недоступны"); closeCommentsThread(); const wrap = document.createElement("div"); wrap.className = "thread-modal"; wrap.innerHTML = `
Комментарии
`; document.body.appendChild(wrap); commentThread = { cid, discGid: data.id, rootId, replyTo: rootId, byId: new Map(), el: wrap, rootEl: wrap.querySelector(".thread-root"), listEl: wrap.querySelector(".thread-list"), replyBar: wrap.querySelector(".thread-reply"), input: wrap.querySelector(".thread-input"), }; wrap.querySelector(".thread-close").onclick = closeCommentsThread; commentThread.replyBar.querySelector("button").onclick = clearCommentReply; wrap.querySelector(".thread-form").addEventListener("submit", e => { e.preventDefault(); const text = commentThread.input.value.trim(); if (!text || ws?.readyState !== 1) return; ws.send(JSON.stringify({ type: "send", group: commentThread.discGid, text, reply_to: commentThread.replyTo })); commentThread.input.value = ""; clearCommentReply(); }); document.addEventListener("keydown", commentThreadKey); // Запрос истории треда (корень + комментарии). ws?.send(JSON.stringify({ type: "history", group: data.id, thread: rootId })); } function commentThreadKey(e) { if (e.key === "Escape") closeCommentsThread(); } function closeCommentsThread() { if (!commentThread) return; document.removeEventListener("keydown", commentThreadKey); commentThread.el.remove(); commentThread = null; } /** История треда с сервера: рендер корневого поста и комментариев. */ function renderCommentHistory(msg) { if (!commentThread || commentThread.rootId !== msg.thread) return; commentThread.byId.clear(); commentThread.rootEl.innerHTML = ""; commentThread.rootEl.appendChild(renderCommentNode(msg.root, true)); commentThread.listEl.innerHTML = ""; for (const m of msg.messages) { commentThread.byId.set(m.id, m); commentThread.listEl.appendChild(renderCommentNode(m, false)); } commentThread.listEl.scrollTop = commentThread.listEl.scrollHeight; } /** Новый комментарий пришёл по WS — добавить в открытый тред. */ function onCommentMessage(m) { if (!commentThread || m.thread_root !== commentThread.rootId) return; if (commentThread.listEl.querySelector(`[data-id="${m.id}"]`)) return; commentThread.byId.set(m.id, m); const atBottom = commentThread.listEl.scrollHeight - commentThread.listEl.scrollTop - commentThread.listEl.clientHeight < 60; commentThread.listEl.appendChild(renderCommentNode(m, false)); if (atBottom || m.from === me.id) commentThread.listEl.scrollTop = commentThread.listEl.scrollHeight; } /** Обновить счётчик «Комментарии N» под постом/дублем в открытом чате. */ function bumpCommentCount(rootId) { for (const div of $("#messages").querySelectorAll(".msg")) { const mm = div._m; const root = mm?.extra?.disc || (mm?.extra?.channel_post ? mm.id : null); if (root === rootId) { mm.comments = (mm.comments || 0) + 1; fillMessage(div, mm); } } } /** Диалог привязки группы комментариев: выбор из своих групп (не каналов). */ async function linkCommentsDialog(cid) { const { ok, data } = await api("/api/me/groups"); if (!ok) return toast("Не удалось загрузить группы"); if (!data.length) return toast("Нет подходящих групп. Создайте обычную группу для комментариев."); dialog("Привязать группу для комментариев", [ { name: "group_id", type: "select", options: data.map(grp => ({ value: String(grp.id), label: grp.title })) }, ], "Привязать", async v => { const r = await api(`/api/channels/${cid}/link`, { group_id: +v.group_id }); if (!r.ok) return r.data.error; openGroupProfile(cid); }); } /* ===== Модерация группы/канала ===== */ /** Кнопка «Настройки модерации» в профиле (для владельца/админов). */ function renderModeration(g) { const box = $("#group-mod-row"); box.innerHTML = ""; if (!g.can_moderate || me.banned) return; const b = document.createElement("button"); b.className = "link"; b.textContent = "Настройки модерации" + (g.moderation?.enabled ? " (включена)" : ""); b.onclick = () => openModerationDialog(g); box.appendChild(b); } const splitLines = s => s.split("\n").map(x => x.trim()).filter(Boolean); /** Диалог настроек модерации: бан-лист, белый список и режим, медленный режим. */ function openModerationDialog(g) { const m = g.moderation || {}; const form = dialog("Модерация", [ { name: "enabled", type: "select", options: [ { value: "1", label: "Модерация включена" }, { value: "0", label: "Модерация выключена" } ] }, { name: "banned_words", type: "textarea", maxlength: 4000, optional: true, placeholder: "Запрещённые слова или фразы — по одной на строку" }, { name: "whitelist_words", type: "textarea", maxlength: 4000, optional: true, placeholder: "Разрешённые слова или фразы — по одной на строку" }, { name: "whitelist_mode", type: "select", options: [ { value: "any", label: "Белый список: хватает одного слова" }, { value: "all", label: "Белый список: нужны все слова" } ] }, { name: "slow_seconds", type: "number", optional: true, placeholder: "Медленный режим (секунды)" }, ], "Сохранить", async v => { const r = await api(`/api/groups/${g.id}/moderation`, { enabled: v.enabled === "1", banned_words: splitLines(v.banned_words), whitelist_words: splitLines(v.whitelist_words), whitelist_mode: v.whitelist_mode, slow_seconds: Math.max(0, +v.slow_seconds || 0), }); if (!r.ok) return r.data.error; toast("Настройки модерации сохранены"); openGroupProfile(g.id); }); // Заполняем текущими значениями. form.elements.enabled.value = m.enabled ? "1" : "0"; form.elements.banned_words.value = (m.banned_words || []).join("\n"); form.elements.whitelist_words.value = (m.whitelist_words || []).join("\n"); form.elements.whitelist_mode.value = m.whitelist_mode || "any"; form.elements.slow_seconds.value = m.slow_seconds || 0; } /** Меню действий над участником: мут, кик, бан, (владелец) назначить админом. */ function openMemberMenu(e, g, u) { const menu = contextMenu(e); menuItem(menu, u.muted ? "Размутить" : "Замутить", false, () => modAction(g.id, u.id, "mute", { muted: !u.muted })); menuItem(menu, "Исключить", false, () => confirmDialog( `Исключить ${u.display_name}?`, () => modAction(g.id, u.id, "kick"))); menuItem(menu, "Заблокировать", true, () => confirmDialog( `Заблокировать ${u.display_name} в этой группе?`, () => modAction(g.id, u.id, "ban"))); if (g.is_owner || me.is_admin) menuItem(menu, u.is_admin ? "Снять администратора" : "Назначить администратором", false, () => modAction(g.id, u.id, "admin", { admin: !u.is_admin })); document.body.appendChild(menu); menu._place(); } async function modAction(gid, uid, action, body) { const { ok, data } = await api(`/api/groups/${gid}/members/${uid}/${action}`, body || {}); if (!ok) return toast(data.error || "Ошибка"); openGroupProfile(gid); // обновить список участников и роли loadChats(); } function leaveGroup(gid, isChannel) { confirmDialog(isChannel ? "Отписаться от канала?" : "Выйти из группы?", async () => { const { ok, data } = await api(`/api/groups/${gid}/leave`, {}); if (!ok) return toast(data.error || "Ошибка"); cacheDrop("g" + gid); if (currentGroup?.id === gid || !$("#view-group").hidden) openView("empty"); loadChats(); }); } function deleteGroup(gid) { confirmDialog("Удалить группу? Все сообщения удалятся у всех участников.", async () => { const { ok, data } = await api(`/api/groups/${gid}/delete`, {}); if (!ok) return toast(data.error || "Ошибка"); cacheDrop("g" + gid); openView("empty"); loadChats(); }); } /** Диалог добавления участника: ввод юзернейма + быстрый выбор из контактов. */ async function openAddMemberDialog(gid) { const wrap = document.createElement("div"); wrap.className = "modal"; const card = document.createElement("div"); card.className = "modal-card dialog"; card.innerHTML = `

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

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

    GIF

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

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

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

    Добавить GIF

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

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

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

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