// ── Фикс поведения виртуальной клавиатуры в WebView (Kotlin-приложение) ── // Проблема: при фокусе на инпуте WebView/браузер пытается прокрутить // документ вверх, чтобы показать поле над клавиатурой — даже при // overflow:hidden на body. В результате шапка чата уезжает за верх // экрана. Решение (как в Telegram Web / WhatsApp Web): // 1) держим реальную высоту приложения равной высоте видимой области // (window.visualViewport.height), а не 100% layout-вьюпорта; // 2) сбрасываем любой авто-скролл документа, который пытается // "подскроллить" страницу к фокусу. (function setupKeyboardViewportFix() { function applyHeight() { var vv = window.visualViewport; var h = vv ? vv.height : window.innerHeight; document.documentElement.style.setProperty('--app-height', h + 'px'); } function resetScroll() { if (window.scrollX !== 0 || window.scrollY !== 0) { window.scrollTo(0, 0); } } applyHeight(); if (window.visualViewport) { window.visualViewport.addEventListener('resize', applyHeight); window.visualViewport.addEventListener('scroll', resetScroll); } window.addEventListener('resize', applyHeight); window.addEventListener('orientationchange', applyHeight); window.addEventListener('scroll', resetScroll, { passive: true }); })(); let ws = null; let wsId = null; let userProfile = null; let connected = false; let chatCache = new Map(); // chatId -> chat info let messageCache = new Map(); // chatId -> [messages], от старых к новым let lastChatsMsg = null; let lastSoundsMsg = null; let currentChatId = null; let autoScroll = true; let pendingAttachments = []; // [{ type, name, mime, size, file, previewUrl, localThumbUrl, ... }, ...] — вложения перед отправкой const MAX_ATTACHMENTS = 10; // ── Локальный оптимистичный рендер своих сообщений + статус доставки ─── // clientId -> { chatId, attachments (исходные локальные объекты вложений) } // Нужно чтобы: 1) сопоставить ответ сервера с уже отрисованным пузырём; // 2) не терять blob-превью картинок/видео, пока не пришло реальное // подтверждение с сервера. let pendingLocalMessages = new Map(); function genLocalId() { const chars = '0123456789abcdef'; let result = ''; for (let i = 0; i < 32; i++) result += chars[Math.floor(Math.random() * 16)]; return result; } // ── Ожидание конкретных ответов сервера по протоколу загрузки вложений ── // (attach_begin_result / attach_chunk_ack / attach_end_result / attach_small_result) const attachWaiters = new Map(); // "type:key" -> resolve const ATTACH_ACK_TIMEOUT_MS = 30000; function waitForAttachMsg(type, key) { const k = type + ":" + key; return new Promise(function (resolve, reject) { const timer = setTimeout(function () { attachWaiters.delete(k); reject(new Error("timeout waiting for " + k)); }, ATTACH_ACK_TIMEOUT_MS); attachWaiters.set(k, function (msg) { clearTimeout(timer); resolve(msg); }); }); } function resolveAttachMsg(type, key, msg) { const k = type + ":" + key; const resolve = attachWaiters.get(k); if (resolve) { attachWaiters.delete(k); resolve(msg); } } const PING_INTERVAL_MS = 15000; const IDLE_TIMEOUT_MS = 3 * 60 * 1000; let globalAudioBuffering = false; // ========================================== // ГЛОБАЛЬНОЕ СОСТОЯНИЕ ВКЛАДКИ // ========================================== const tabId = Math.random().toString(36).substring(2); const channel = new BroadcastChannel('secure_messenger_app_bus'); let isLeader = false; let status = 'offline'; // 'online' | 'away' | 'offline' let isUserActive = true; let visibleTabs = new Set(); let allTabs = new Set(); let awayDebounceTimer = null; let idleTimer = null; let workerTimer = null; // ── Постепенная подгрузка истории сообщений (без удаления) ─── const MSG_PAGE_SIZE = 50; const MSG_LOAD_THRESHOLD = 120; // px от верха для срабатывания подгрузки старых let chatPaging = new Map(); // chatId -> { topOffset, hasMoreOlder, loadingOlder, pendingInitial, newUnseenCount } function getPaging(chatId) { let p = chatPaging.get(chatId); if (!p) { p = { topOffset: 0, hasMoreOlder: true, loadingOlder: false, pendingInitial: false, newUnseenCount: 0, initialLimit: MSG_PAGE_SIZE, // Отдельно от "есть ли что-то в messageCache" — потому что кэш может // оказаться непустым просто из-за того, что пока чат не был открыт, // туда УЖЕ успели прилететь живые сообщения (onMessage пушит в кэш // независимо от того, открыт чат или нет). Раньше openChat() принимал // "кэш непустой" за "история полностью загружена" и НИКОГДА не делал // настоящий get_messages — в итоге при открытии чата, где уже успели // прилететь свежие сообщения, показывались только они, без старой // истории. true выставляется только после реального успешного // get_messages для этого чата (см. onMessages). historyLoaded: false }; chatPaging.set(chatId, p); } return p; } // ── Прогрессивная отметка прочтения (как в Telegram/WhatsApp) ────────── // IntersectionObserver следит за чужими непрочитанными сообщениями внутри // #messages; как только сообщение реально показалось в области просмотра — // шлём mark_read (с небольшим дебаунсом, чтобы не спамить сервер на каждый // кадр скролла — берём максимальный увиденный id за окно ~350мс). let unreadObserver = null; const pendingReadMaxId = new Map(); // chatId -> наибольший увиденный id, ещё не отправленный const markReadTimers = new Map(); // chatId -> таймер debounce function ensureUnreadObserver() { if (unreadObserver) return unreadObserver; unreadObserver = new IntersectionObserver(onUnreadIntersect, { root: document.getElementById("messages"), threshold: 0.6 }); return unreadObserver; } // Форсирует отметку "прочитано" до самого последнего загруженного сообщения // чата — используется когда мы точно знаем, что пользователь увидел низ // целиком (клик по кнопке "к последним"), не дожидаясь IntersectionObserver. function markChatFullyReadNow(chatId) { const list = messageCache.get(chatId); if (!list || !list.length) return; let maxId = 0; for (let i = list.length - 1; i >= 0; i--) { if (typeof list[i].id === "number") { maxId = list[i].id; break; } } if (!maxId) return; clearTimeout(markReadTimers.get(chatId)); pendingReadMaxId.delete(chatId); send("mark_read", { chatId: chatId, messageId: maxId }); } function onUnreadIntersect(entries) { entries.forEach(function (entry) { if (!entry.isIntersecting) return; const el = entry.target; unreadObserver.unobserve(el); const chatId = Number(el.dataset.readChat); const msgId = Number(el.dataset.readMsgid); if (!chatId || !msgId) return; const cur = pendingReadMaxId.get(chatId) || 0; if (msgId > cur) pendingReadMaxId.set(chatId, msgId); clearTimeout(markReadTimers.get(chatId)); markReadTimers.set(chatId, setTimeout(function () { const id = pendingReadMaxId.get(chatId); if (id) { send("mark_read", { chatId: chatId, messageId: id }); pendingReadMaxId.delete(chatId); } }, 200)); }); } // ── Разделитель "Непрочитанные" ───────────────────────────────────────── // Вставляется один раз за сессию просмотра чата (baseline фиксируется в // chat._dividerBaseline при открытии — см. openChat) перед первым чужим // сообщением, которое было непрочитанным на момент открытия. Вызывается на // каждой полной перерисовке (renderMessagesFull), т.к. box.innerHTML // каждый раз пересобирается заново. function insertUnreadDividerIfNeeded(chatId, box) { const chat = chatCache.get(chatId); if (!chat || !chat.unread) { return null; } const baseline = chat._dividerBaseline || 0; let targetEl = null; const candidates = box.querySelectorAll('.msgItem[data-msgid]'); for (const el of candidates) { if (el.classList.contains("mine")) continue; const idNum = Number(el.dataset.msgid); if (!Number.isFinite(idNum)) continue; // временные (ещё не отправленные) id пропускаем if (idNum > baseline) { targetEl = el; break; } } if (!targetEl) return null; let topLevelNode = targetEl; while (topLevelNode.parentElement && topLevelNode.parentElement !== box) { topLevelNode = topLevelNode.parentElement; } const divider = document.createElement("div"); divider.className = "unreadDivider"; const span = document.createElement("span"); span.textContent = "Непрочитанные"; divider.appendChild(span); box.insertBefore(divider, topLevelNode); return divider; } // При открытии чата с непрочитанными — не улетаем сразу в самый низ, а // ставим разделитель примерно на 40% высоты экрана: сверху видно немного // прочитанного "контекста", снизу — начало непрочитанного. function scrollToDividerOrBottom(chatId) { const box = document.getElementById("messages"); const divider = box.querySelector(".unreadDivider"); if (!divider) { box.scrollTop = box.scrollHeight; autoScroll = true; updateFloatingDate(); updateJumpButtonVisibility(); return; } const dividerOffset = divider.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop; const target = dividerOffset - box.clientHeight * 0.4; box.scrollTop = Math.max(0, target); autoScroll = (box.scrollHeight - box.scrollTop - box.clientHeight) < 60; updateFloatingDate(); updateJumpButtonVisibility(); } let sessionToken = "session_token"; let sessionCookie = "session_cookie"; const MOBILE_BREAKPOINT = 768; const ctx = new (window.AudioContext || window.webkitAudioContext)(); const gain = ctx.createGain(); gain.gain.value = 0.8; // громкость gain.connect(ctx.destination); // ── FCM: приём push-токена от нативной (Kotlin) части ───────── // MainActivity.fetchAndSendPushToken() вызывает window.onNativePushToken(token) // через evaluateJavascript каждый раз при onResume(). Отправляем токен на // сервер только когда точно знаем userId (после onProfile), чтобы сервер // сразу привязал его к правильному пользователю. window.__pendingFcmToken = null; window.onNativePushToken = function (token) { window.__pendingFcmToken = token; maybeSendPushToken(); }; function maybeSendPushToken() { if (window.__pendingFcmToken && connected && userProfile && userProfile.id) { send("register_push_token", { token: window.__pendingFcmToken, platform: "android" }); window.__pendingFcmToken = null; } } // ── Открытие чата по тапу на push-уведомление ────────────────── // Вызывается нативной частью (MainActivity) через evaluateJavascript // с chatId из data-payload пуша. Чаты могли ещё не успеть загрузиться — // в этом случае откладываем открытие до прихода onChats(). let pendingNotificationChatId = null; window.openChatFromNotification = function (chatIdStr) { const chatId = Number(chatIdStr); if (!chatId) return; const chat = chatCache.get(chatId); if (chat) { openChat(chat); if (isMobile()) switchTab("chats"); } else { pendingNotificationChatId = chatId; } }; // Функция-помощник для превращения base64 в ArrayBuffer function base64ToArrayBuffer(base64) { const binaryString = atob(base64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; } let bufIncoming, bufOutgoing; // resume по первому жесту document.addEventListener('pointerdown', () => { if (ctx.state === 'suspended') ctx.resume(); }, { once: true }); // воспроизведение function playBuffer(buf) { if (ctx.state === 'suspended') return; if (!buf) return; const src = ctx.createBufferSource(); src.buffer = buf; src.connect(gain); src.start(0); } function isMobile() { return document.body.classList.contains("mobile"); } function applyMobileClass() { const wasMobile = isMobile(); //const nowMobile = window.innerWidth < MOBILE_BREAKPOINT; const nowMobile = window.innerWidth < window.innerHeight && window.innerWidth <= MOBILE_BREAKPOINT; if (wasMobile === nowMobile) return; document.body.classList.toggle("mobile", nowMobile); // При переходе десктоп→мобильный: закрываем чат-слайд если нет активного if (nowMobile) document.getElementById("messagesContainer").classList.toggle("open", currentChatId !== null); else document.getElementById("messagesContainer").classList.remove("open"); } function showLoginUI() { const ov = document.getElementById("authOverlay"); if (ov) { ov.style.display = "flex"; ov.classList.remove("hidden"); } // сброс на форму логина showAuthForm("login"); } function showChatUI() { const ov = document.getElementById("authOverlay"); if (ov) { ov.classList.add("hidden"); setTimeout(function(){ ov.style.display = "none"; }, 200); } switchTab("chats"); } function showAuthForm(form) { document.getElementById("formLogin").style.display = form === "login" ? "" : "none"; document.getElementById("formRegister").style.display = form === "register" ? "" : "none"; // На случай зависшего спиннера от прошлой попытки — сбрасываем при переключении форм setButtonLoading(document.getElementById("btnLogin"), false); setButtonLoading(document.getElementById("btnRegister"), false); } function showWsOverlay(text) { var ov = document.getElementById("wsOverlay"); if (!ov) return; ov.style.display = "flex"; } function hideWsOverlay() { var ov = document.getElementById("wsOverlay"); if (ov) ov.style.display = "none"; } function startWs() { showWsOverlay(); ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/"); ws.onopen = () => { connected = true; hideWsOverlay(); const token = localStorage.getItem(sessionToken); if (token) send("auth", { token: token }); else showLoginUI(); }; ws.onclose = (ev) => { connected = false; wsId = null; showWsOverlay(); stopHeartbeatWorker(); updateStatus(); document.cookie = sessionCookie + "=; path=/; max-age=0"; setTimeout(() => { if (isLeader) startWs(); }, 1000); }; ws.onerror = (e) => { stopHeartbeatWorker(); updateStatus(); }; ws.onmessage = (e) => { handle(e.data); channel.postMessage({ action: 'INCOMING_WS', sender: tabId, payload: e.data }); }; } function send(type, payload) { if (isLeader) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(Object.assign({ type: type }, payload || {}))); } } else { channel.postMessage({ action: 'FORWARD_SEND', sender: tabId, payload: { wsType: type, payload: payload } }); } } function handle(packet) { try{ let msg = JSON.parse(packet); switch (msg.type) { case "auth_result": onAuthResult(msg); break; case "login_result": onLoginResult(msg); break; case "logout_result": onLogoutResult(msg); break; case "sounds_result": onSoundResult(msg); break; case "register_result": onRegisterResult(msg); break; case "chats": onChats(msg); break; case "messages": onMessages(msg); break; case "message": onMessage(msg); break; case "create_chat_result": onCreateChatResult(msg); break; case "join_chat_result": onJoinChatResult(msg); break; case "create_private_chat_result": onCreatePrivateChatResult(msg); break; case "profile": onProfile(msg); break; case "update_name_result": onUpdateNameResult(msg); break; case "update_username_result": onUpdateUsernameResult(msg); break; case "update_avatar_result": onUpdateAvatarResult(msg); break; case "find_user_result": onFindUserResult(msg); break; case "online_changed": onOnlineChanged(msg); break; case "delete_message_result": onDeleteMessageResult(msg); break; case "message_deleted": onMessageDeleted(msg); break; case "delete_chat_result": onDeleteChatResult(msg); break; case "chat_deleted": onChatDeleted(msg); break; case "attach_begin_result": resolveAttachMsg("attach_begin_result", msg.attId, msg); break; case "attach_chunk_ack": resolveAttachMsg("attach_chunk_ack", msg.attId, msg); break; case "attach_end_result": resolveAttachMsg("attach_end_result", msg.attId, msg); break; case "attach_small_result": resolveAttachMsg("attach_small_result", msg.attId, msg); break; case "unread_result": onUnreadResult(msg); break; case "message_read": onMessageRead(msg); break; } } catch (e) { } } function onAuthResult(msg) { if (msg.ok) { if (isLeader) { startHeartbeatWorker(); updateStatus(); } wsId = msg.id; document.cookie = sessionCookie + "=" + msg.cookie + "; path=/; max-age=604800"; showChatUI(); } else { // токен невалиден — удалить и показать логин localStorage.removeItem(sessionToken); document.cookie = sessionCookie + "=; path=/; max-age=0"; showLoginUI(); } } function onLoginResult(msg) { setButtonLoading(document.getElementById("btnLogin"), false); if (!msg.ok) { toastError("Неверный логин или пароль"); return; } if (isLeader) { startHeartbeatWorker(); updateStatus(); } if (msg.token) { localStorage.setItem(sessionToken, msg.token); wsId = msg.id; document.cookie = sessionCookie + "=" + msg.cookie + "; path=/; max-age=604800"; showChatUI(); } } function onRegisterResult(msg) { setButtonLoading(document.getElementById("btnRegister"), false); if (!msg.ok) { toastError("Регистрация не удалась: " + (msg.error || "")); return; } toastSuccess("Аккаунт создан!"); } function onLogoutResult(msg) { if (msg.ok) { toastSuccess("Logged out"); localStorage.removeItem(sessionToken); document.cookie = sessionCookie + "=; path=/; max-age=0"; showLoginUI(); currentChatId = null; userProfile = null; chatCache.clear(); messageCache.clear(); document.getElementById("privateAvatar").textContent = ""; document.getElementById("privateAvatar").className = ""; document.getElementById("chatTitleText").textContent = ""; document.getElementById("chatToken").textContent = ""; document.getElementById('messages').textContent = ""; document.getElementById("chatList").textContent = ""; document.getElementById("avatarDefs").textContent = ""; document.getElementById("profileName").textContent = "—"; document.getElementById("profileUsername").textContent = "—"; document.getElementById("profileAvatar").textContent = "A"; document.getElementById("navAvatar").textContent = "A"; document.getElementById("mobNavAvatar").textContent = "A"; } } async function onSoundResult(msg) { if (!msg) return; lastSoundsMsg = msg; bufIncoming = await ctx.decodeAudioData(base64ToArrayBuffer(msg.recv)); bufOutgoing = await ctx.decodeAudioData(base64ToArrayBuffer(msg.sent)); } function onProfile(msg) { if (!msg) return; userProfile = msg; document.getElementById("profileName").textContent = msg.name; document.getElementById("profileUsername").textContent = msg.username; const key = "user-" + msg.id; const fallback = msg.name || msg.username || "?"; // большая аватарка в профиле const avatarDiv = document.getElementById("profileAvatar"); if (avatarDiv) setAvatarContent(avatarDiv, msg.avatar, key, fallback); // мини-аватарки — рейл и мобильный навбар (ссылаются на тот же ресурс) ["navAvatar", "mobNavAvatar"].forEach(function(id) { const el = document.getElementById(id); if (!el) return; setAvatarContent(el, msg.avatar, key, fallback); }); } function onChats(msg) { if (!msg) return; lastChatsMsg = msg; const chats = msg.items || []; chatCache.clear(); for (let i = 0; i < chats.length; i++) { chatCache.set(chats[i].id, chats[i]); } renderChatListFromCache(); updateNavUnreadBadge(); if (pendingNotificationChatId != null) { const chat = chatCache.get(pendingNotificationChatId); if (chat) { openChat(chat); if (isMobile()) switchTab("chats"); } pendingNotificationChatId = null; } } // ── Слияние партии сообщений с сервера (items) с уже имеющимся кэшем // (existing) по id — используется во всех местах обработки onMessages. // Помимо дедупа решает вторую задачу: сервер теперь присылает в истории // поле status ("read"/"sent") для НАШИХ СОБСТВЕННЫХ сообщений — это то, // что реально знает БД (прочитал ли собеседник), в отличие от статуса, // который раньше жил только в памяти вкладки и обновлялся исключительно // живыми message_read-событиями (поэтому после перезагрузки страницы все // свои сообщения выглядели просто "отправленными", даже если давно // прочитаны). Если сообщение уже было в кэше с более "продвинутым" // статусом (например, отследили live в эту же сессию) — не откатываем. function mergeMessagesById(items, existing) { const byId = new Map(); items.forEach(function (m) { if (userProfile && m.userId === userProfile.id && typeof m._status === "undefined") { m._status = m.status === "read" ? "read" : "sent"; } byId.set(m.id, m); }); existing.forEach(function (m) { if (byId.has(m.id)) { const fromServer = byId.get(m.id); if (m._status && STATUS_RANK[m._status] > STATUS_RANK[fromServer._status || "sent"]) { fromServer._status = m._status; } } else { byId.set(m.id, m); } }); return Array.from(byId.values()).sort(function (a, b) { const ca = a.createdAt || 0, cb = b.createdAt || 0; if (ca !== cb) return ca - cb; const ia = typeof a.id === "number" ? a.id : Infinity; const ib = typeof b.id === "number" ? b.id : Infinity; return ia - ib; }); } function onMessages(msg) { const chatId = msg.chatId; const items = msg.items || []; // от старых к новым внутри пачки const paging = getPaging(chatId); // ── Начальная загрузка (открытие чата первый раз) ───────── if (paging.pendingInitial) { paging.pendingInitial = false; paging.historyLoaded = true; // Мерджим по id, а не слепо заменяем: пока запрос летал туда-обратно, // могли прийти "живые" message-события для этих же (или новых) // сообщений через onMessage — они уже лежат в messageCache. Заодно // применяем статус "прочитано" своих сообщений, который знает сервер // (см. mergeMessagesById) — иначе после перезагрузки страницы все // свои сообщения выглядели бы просто "отправленными". const existing = messageCache.get(chatId) || []; const mergedList = mergeMessagesById(items, existing); messageCache.set(chatId, mergedList); paging.topOffset = items.length; paging.hasMoreOlder = items.length === (paging.initialLimit || MSG_PAGE_SIZE); paging.newUnseenCount = 0; hideEdgeLoader(); if (currentChatId === chatId) { document.getElementById("sendBox").style.display = 'flex'; renderMessagesFull(chatId, null); // Свежезагруженные сообщения могли содержать непрочитанные — // ставим разделитель в поле зрения, а не сразу улетаем вниз. requestAnimationFrame(function () { scrollToDividerOrBottom(chatId); }); } updateJumpButtonVisibility(); return; } // ── Подгрузка более старых сообщений (скролл вверх) ─────── if (paging.loadingOlder) { paging.loadingOlder = false; const list = messageCache.get(chatId) || []; const anchorId = list.length ? list[0].id : null; // текущий самый старый — якорь // Дедуп + статус прочтения своих сообщений — та же логика, что и при // начальной загрузке (см. mergeMessagesById), только тут новые данные // ("более старые" сообщения) всегда идут ПЕРЕД уже загруженными. const mergedOlder = mergeMessagesById(items, list); messageCache.set(chatId, mergedOlder); paging.topOffset += items.length; paging.hasMoreOlder = items.length === MSG_PAGE_SIZE; hideEdgeLoader(); if (currentChatId === chatId) { renderMessagesFull(chatId, anchorId); } updateJumpButtonVisibility(); return; } // ── Фоллбек: полная замена (на всякий случай) ───────────── paging.historyLoaded = true; const existingFallback = messageCache.get(chatId) || []; messageCache.set(chatId, mergeMessagesById(items, existingFallback)); paging.topOffset = items.length; paging.hasMoreOlder = items.length === MSG_PAGE_SIZE; paging.newUnseenCount = 0; if (currentChatId === chatId) { document.getElementById("sendBox").style.display = 'flex'; renderMessagesFull(chatId, null); } updateJumpButtonVisibility(); } // Полная перерисовка #messages из messageCache для chatId. // anchorMsgId — id сообщения, чью позицию на экране нужно сохранить // (используется при подгрузке истории вверх). Если null — форсируем скролл в самый низ. function renderMessagesFull(chatId, anchorMsgId) { const box = document.getElementById("messages"); let anchorOffsetBefore = null; if (anchorMsgId != null) { const anchorEl = box.querySelector('[data-msgid="' + anchorMsgId + '"]'); if (anchorEl) { anchorOffsetBefore = anchorEl.getBoundingClientRect().top - box.getBoundingClientRect().top; } } // box.innerHTML полностью пересобирается — все прежние DOM-узлы, за // которыми следил IntersectionObserver, сейчас умрут; отписываемся, // ниже appendMessageToBox заново подпишет актуальные непрочитанные. if (unreadObserver) unreadObserver.disconnect(); const list = messageCache.get(chatId) || []; box.innerHTML = ""; let prev = null; list.forEach(function (m) { m._noAnim = true; appendMessageToBox(box, m, prev); prev = m; }); // Разделитель считаем ДО измерения "anchorOffsetAfter" — он занимает // место в потоке и должен быть учтён при восстановлении позиции скролла. insertUnreadDividerIfNeeded(chatId, box); if (anchorMsgId != null && anchorOffsetBefore != null) { const anchorElAfter = box.querySelector('[data-msgid="' + anchorMsgId + '"]'); if (anchorElAfter) { const anchorOffsetAfter = anchorElAfter.getBoundingClientRect().top - box.getBoundingClientRect().top; box.scrollTop += (anchorOffsetAfter - anchorOffsetBefore); } } else { box.scrollTop = box.scrollHeight; autoScroll = true; } setInitialFloatingDate(); } function showEdgeLoader() { const el = document.getElementById("msgTopLoader"); if (el) el.classList.add("visible"); } function hideEdgeLoader() { const el = document.getElementById("msgTopLoader"); if (el) el.classList.remove("visible"); } function loadOlderMessages(chatId) { const paging = getPaging(chatId); if (paging.loadingOlder || paging.pendingInitial || !paging.hasMoreOlder) return; paging.loadingOlder = true; showEdgeLoader(); send("get_messages", { chatId: chatId, offset: paging.topOffset, limit: MSG_PAGE_SIZE }); } // Кнопка "к последним сообщениям": Telegram-эффект, вариант 2. // Никакой остановки вообще — это ОДНО непрерывное движение от начала // до конца, просто в середине путь физически подменяется (телепорт), // а сама подмена спрятана внутри прозрачности: контент тает, ускоряясь, // уходит в невидимость (ровно там прыгаем рядом с целью), и появляется // обратно, замедляясь, уже двигаясь к истинному концу истории. // Позиция и прозрачность меняются каждый кадр без единой паузы. function jumpToLatestMessages(chatId) { const box = document.getElementById("messages"); const trueBottom = box.scrollHeight - box.clientHeight; const startPos = box.scrollTop; const distance = trueBottom - startPos; autoScroll = true; updateJumpButtonVisibility(); // Прыжок в самый низ = пользователь только что увидел вообще все // сообщения чата, даже те, что телепорт мог "перепрыгнуть" мимо // IntersectionObserver — отмечаем прочитанным сразу до последнего id. markChatFullyReadNow(chatId); if (distance <= 2) return; // Если и так близко — трюк не нужен, честный короткий плавный скролл if (distance <= box.clientHeight * 0.9) { box.scrollTo({ top: trueBottom, behavior: "smooth" }); return; } let cancelled = false; function cleanup() { box.removeEventListener("wheel", onManualScroll); box.removeEventListener("touchstart", onManualScroll); box.style.opacity = ""; } function onManualScroll() { cancelled = true; cleanup(); } box.addEventListener("wheel", onManualScroll, { passive: true, once: true }); box.addEventListener("touchstart", onManualScroll, { passive: true, once: true }); const duration = 200; const startTime = performance.now(); // Временная шкала (доли от 0 до 1): const TELEPORT_AT = 0.47; // момент физической подмены позиции const FADE_OUT_END = 0.42; // к этому моменту прозрачность уже ~0 const FADE_IN_START = 0.52; // с этого момента снова проявляемся // Дистанции "туда" и "оттуда" — держим в пределах высоты экрана, // чтобы обе половины ощущались одинаково быстрыми независимо от // того, насколько на самом деле далеко исходная позиция от конца const exitDistance = Math.min(distance * 0.5, box.clientHeight * 0.75); const enterDistance = Math.min(distance * 0.5, box.clientHeight * 0.75); let teleported = false; function easeInCubic(t) { return t * t * t; } function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } function opacityAt(t) { if (t <= FADE_OUT_END) { return 1 - easeInCubic(t / FADE_OUT_END); // гаснем, ускоряясь } if (t < FADE_IN_START) { return 0; // невидимая зона перехода } return easeOutCubic((t - FADE_IN_START) / (1 - FADE_IN_START)); // проявляемся, замедляясь } function step(now) { if (cancelled) return; const t = Math.min(1, (now - startTime) / duration); box.style.opacity = String(opacityAt(t)); if (t < TELEPORT_AT) { // Первая половина — уезжаем от исходной позиции, ускоряясь const local = t / TELEPORT_AT; box.scrollTop = startPos + exitDistance * easeInCubic(local); } else { if (!teleported) { teleported = true; // Мгновенный прыжок рядом с целью — скрыт нулевой прозрачностью box.scrollTop = trueBottom - enterDistance; } // Вторая половина — подъезжаем к истинному концу, замедляясь const local = (t - TELEPORT_AT) / (1 - TELEPORT_AT); box.scrollTop = (trueBottom - enterDistance) + enterDistance * easeOutCubic(local); } if (t < 1) { requestAnimationFrame(step); } else { box.scrollTop = trueBottom; cleanup(); } } requestAnimationFrame(step); } function updateJumpButtonVisibility() { const btn = document.getElementById("btnJumpToBottom"); if (!btn) return; if (!currentChatId) { btn.classList.remove("visible"); return; } btn.classList.toggle("visible", !autoScroll); // Бейдж = реальный счётчик непрочитанных этого чата (chat.unread), // синхронизируется с сервером через unread_result — динамически падает // по мере того, как сообщения снизу реально прочитываются (см. // IntersectionObserver/onUnreadIntersect), а не просто "новые за визит". const badge = document.getElementById("jumpBottomBadge"); if (badge) { const chat = chatCache.get(currentChatId); const count = (chat && chat.unread) || 0; if (count > 0) { badge.textContent = count > 99 ? "99+" : String(count); badge.classList.add("visible"); } else { badge.classList.remove("visible"); } } } function onMessage(msg) { const m = msg.message; if (!m) return; const paging = getPaging(m.chatId); // ── Это подтверждение НАШЕГО сообщения, отправленного с этого (или // другой вкладки этого же браузера) клиента — не дублируем пузырь, // а превращаем уже отрисованный "отправляется…" в подтверждённый. if (m.clientId && pendingLocalMessages.has(m.clientId)) { finalizeLocalMessage(m.clientId, m); updateJumpButtonVisibility(); const chat = chatCache.get(m.chatId); if (chat) { chat.lastMessage = truncate(m.text || (m.attachments && m.attachments.length ? "Вложение" : "")); chat.lastAt = m.createdAt || Date.now(); chat.lastMessageId = m.id; chat.lastMessageUserId = m.userId; chat.lastMessageStatus = "sent"; // finalizeLocalMessage уже выставил m._status="sent" renderChatListFromCache(); } if (currentChatId && m.chatId === currentChatId) playBuffer(bufOutgoing); return; } // Историю никогда не удаляем — просто дописываем новое сообщение в конец let list = messageCache.get(m.chatId); if (!list) { list = []; messageCache.set(m.chatId, list); } list.push(m); if (m.chatId === currentChatId) { const box = document.getElementById("messages"); const prev = list.length > 1 ? list[list.length - 2] : null; appendMessageToBox(box, m, prev); scrollMessagesToBottom(); setInitialFloatingDate(); // Счётчик на кнопке "к последним" держится на chat.unread — его // обновит push с сервера (unread_result), который придёт почти // одновременно с этим же сообщением (см. NotifyUnreadCounts). } updateJumpButtonVisibility(); const chat = chatCache.get(m.chatId); if (chat) { chat.lastMessage = truncate(m.text); chat.lastAt = Date.now(); chat.lastMessageId = m.id; chat.lastMessageUserId = m.userId; // Своё сообщение, пришедшее сюда (не через ветку finalizeLocalMessage // выше — например, отправлено с другой вкладки) — статус "прочитано" // выставит live message_read позже, если получатель уже онлайн. chat.lastMessageStatus = (m.userId === userProfile.id) ? "sent" : null; renderChatListFromCache(); } if (m.userId === userProfile.id && (!currentChatId || (currentChatId && m.chatId !== currentChatId))) playBuffer(bufIncoming); } // Статусы своих сообщений идут по возрастанию — апгрейд применяем только // вперёд, чтобы поздно пришедший "read" не откатился обратно на "sent" при // гонке событий. const STATUS_RANK = { sending: 0, failed: 0, sent: 1, read: 2 }; function upgradeMessageStatus(m, newStatus) { const cur = m._status || "sent"; if ((STATUS_RANK[newStatus] || 0) > (STATUS_RANK[cur] || 0)) { m._status = newStatus; updateMessageStatusIcon(m.id, newStatus); } } // Сервер подтвердил: получатель реально долистал/увидел наши сообщения этого // чата вплоть до upToMessageId — красим галочки в синий ("прочитано"). function onMessageRead(msg) { const list = messageCache.get(msg.chatId); if (list) { list.forEach(function (m) { if (m.userId === userProfile.id && typeof m.id === "number" && m.id <= msg.upToMessageId) { upgradeMessageStatus(m, "read"); } }); } // Статус в списке чатов посинеет, только если прочитанное реально // накрывает НАШЕ ПОСЛЕДНЕЕ сообщение (id) — при прогрессивном чтении // message_read может прийти и на промежуточное сообщение, не на самое // последнее, тогда список чатов трогать рано. const chat = chatCache.get(msg.chatId); if (chat && chat.lastMessageUserId === userProfile.id && typeof chat.lastMessageId === "number" && chat.lastMessageId <= msg.upToMessageId && chat.lastMessageStatus !== "read") { chat.lastMessageStatus = "read"; renderChatListFromCache(); } } // Сервер подтвердил новое значение счётчика непрочитанных для чата (после // нашего mark_read, либо потому что кто-то прислал новое сообщение) — // синхронизируем бейджи: в списке чатов, на кнопке "вниз", на рейле. function onUnreadResult(msg) { const chat = chatCache.get(msg.chatId); if (chat) { chat.unread = msg.unread; if (typeof msg.lastReadMessageId === "number") chat.lastReadMessageId = msg.lastReadMessageId; renderChatListFromCache(); } updateNavUnreadBadge(); if (msg.chatId === currentChatId) updateJumpButtonVisibility(); } function updateNavUnreadBadge() { let total = 0; chatCache.forEach(function (c) { total += (c.unread || 0); }); const rail = document.getElementById("chatsBadge"); const mob = document.getElementById("mobChatsBadge"); if (rail) rail.classList.toggle("visible", total > 0); if (mob) mob.classList.toggle("visible", total > 0); } // ── Финализация локально отправленного сообщения: сервер подтвердил, что // сообщение (и все его вложения) реально сохранены. Подменяем временную // запись в кэше на настоящую, но сохраняем blob-превью вложений (previewUrl/ // localThumbUrl), чтобы картинка не мигала/не перезагружалась — они и так // уже показывают корректный контент. function finalizeLocalMessage(clientId, realMsg) { const pending = pendingLocalMessages.get(clientId); pendingLocalMessages.delete(clientId); const chatId = realMsg.chatId; const merged = Object.assign({}, realMsg); merged._status = "sent"; if (pending && pending.attachments && pending.attachments.length && merged.attachments && merged.attachments.length) { merged.attachments = merged.attachments.map(function (a, i) { const local = pending.attachments[i]; if (!local) return a; const out = Object.assign({}, a); if (local.previewUrl) out.previewUrl = local.previewUrl; if (local.localThumbUrl) out.localThumbUrl = local.localThumbUrl; return out; }); } const list = messageCache.get(chatId); if (list) { const idx = list.findIndex(function (mm) { return mm.id === clientId; }); if (idx !== -1) list[idx] = merged; } const el = document.querySelector('[data-msgid="' + clientId + '"]'); if (el) { el.dataset.msgid = merged.id; el.classList.remove("msgFailed"); // Полностью пересобираем содержимое пузыря — теперь у вложений есть // настоящие серверные id (можно скачивать, открывать в вьюере и т.д.) const oldMessage = el.querySelector(".message"); const mine = userProfile && merged.userId === userProfile.id; const newMessage = buildMessageDiv(merged, mine); if (oldMessage) el.replaceChild(newMessage, oldMessage); else el.insertBefore(newMessage, el.querySelector(".bubble-tail")); // Пузырь больше не "неотправленный" — снимаем обработчик повтора отправки el.onclick = null; el.addEventListener("contextmenu", function (e) { e.preventDefault(); openMessageContextMenu(e.clientX, e.clientY, merged, e.target); }); } } // Отметить локальное сообщение как не отправленное — можно повторить кликом function markLocalMessageFailed(clientId) { const pending = pendingLocalMessages.get(clientId); if (pending) { const list = messageCache.get(pending.chatId); if (list) { const m = list.find(function (mm) { return mm.id === clientId; }); if (m) m._status = "failed"; } // Если это сообщение всё ещё последнее в чате — статус в списке // чатов тоже должен показать "не отправлено". const chat = chatCache.get(pending.chatId); if (chat && chat.lastMessageUserId === userProfile.id) { chat.lastMessageStatus = "failed"; renderChatListFromCache(); } } updateMessageStatusIcon(clientId, "failed"); const el = document.querySelector('[data-msgid="' + clientId + '"]'); if (el) { el.classList.add("msgFailed"); el.onclick = function (e) { e.stopPropagation(); retrySendMessage(clientId); }; } toastWarning("Не удалось отправить сообщение"); } function retrySendMessage(clientId) { const pending = pendingLocalMessages.get(clientId); if (!pending) return; const list = messageCache.get(pending.chatId); const m = list ? list.find(function (mm) { return mm.id === clientId; }) : null; if (!m) return; m._status = "sending"; updateMessageStatusIcon(clientId, "sending"); const el = document.querySelector('[data-msgid="' + clientId + '"]'); if (el) { el.classList.remove("msgFailed"); el.onclick = null; } const chat = chatCache.get(pending.chatId); if (chat && chat.lastMessageUserId === userProfile.id) { chat.lastMessageStatus = "sending"; renderChatListFromCache(); } uploadAndSendLocalMessage(clientId, pending.chatId, pending.text, pending.attachments); } // ── Обновление UI прогресса загрузки конкретного вложения (без пересборки // всего пузыря — вызывается очень часто, на каждый чанк). function updateLocalAttachmentProgress(att, ratio) { att.progress = ratio; const pctText = Math.round(ratio * 100) + "%"; document.querySelectorAll('[data-attlocal="' + att.localId + '"]').forEach(function (elOverlay) { const pctEl = elOverlay.classList.contains("attUploadPct") ? elOverlay : elOverlay.querySelector(".attUploadPct"); if (pctEl) pctEl.textContent = pctText; if (elOverlay.classList.contains("attUploadOverlay")) { elOverlay.style.setProperty("--att-progress", ratio); } }); } function onCreateChatResult(msg) { if (!msg.ok) { toastError("Не удалось создать чат"); return; } openChat(Array.from(chatCache.values()).find(x => x.id === msg.id)); } function onJoinChatResult(msg) { if (!msg.ok) { toastError("Не удалось войти в чат"); return; } openChat(Array.from(chatCache.values()).find(x => x.id === msg.id)); } function onCreatePrivateChatResult(msg) { if (!msg.ok) { toastError("Не удалось создать приватный чат"); return; } openChat(Array.from(chatCache.values()).find(x => x.id === msg.id)); } function onUpdateNameResult(msg) { if (!msg.ok) { toastError("Имя не изменёно: " + (msg.error || "")); return; } toastSuccess("Имя обновлено"); } function onUpdateUsernameResult(msg) { if (!msg.ok) { toastError("Ник не изменён: " + (msg.error || "")); return; } toastSuccess("Ник обновлён"); } function onUpdateAvatarResult(msg) { if (!msg.ok) { toastError("Аватар не изменён"); return; } toastSuccess("Аватар обновлён"); } let pendingPrivateByUsername = null; function onFindUserResult(msg) { if (!pendingPrivateByUsername) return; if (!msg.ok) { toastWarning("Пользователь не найден"); pendingPrivateByUsername = null; return; } const userId = msg.id; pendingPrivateByUsername = null; send("create_private_chat", { userId: userId }); } function onOnlineChanged(msg) { const chat = chatCache.get(msg.chatId); if (!chat) return; const chatElement = document.querySelector('[data-chat-id="' + msg.chatId + '"]'); if (!chatElement) return; const hasClass = chatElement.classList.contains('chatItem'); if (!hasClass) return; chatElement.classList.toggle('online', msg.online); if (msg.online){ chat.online = true; }else{ chat.online = false; chat.lastSeen = msg.time; } chatCache.set(msg.chatId, chat); if (msg.chatId !== currentChatId) return; const state = document.getElementById("chatState") if (!state) return; state.classList.toggle('online', msg.online); if (msg.online) { state.textContent = "оnline"; }else{ state.textContent = formatRelativeTime(chat.lastSeen); } } // ── Удаление сообщений ───────────────────────────────────────── function onDeleteMessageResult(msg) { if (!msg.ok) { toastWarning("Не удалось удалить сообщение"); return; } removeMessageFromUi(msg.chatId, msg.messageId); toastInfo("Сообщение удалено"); } // Приходит всем участникам чата (в т.ч. тому, кто удалил — безопасно, // removeMessageFromUi просто ничего не найдёт во второй раз) function onMessageDeleted(msg) { removeMessageFromUi(msg.chatId, msg.messageId); } function removeMessageFromUi(chatId, messageId) { const list = messageCache.get(chatId); if (list) { const idx = list.findIndex(function (m) { return m.id === messageId; }); if (idx !== -1) list.splice(idx, 1); } if (currentChatId === chatId) { renderMessagesFull(chatId, null); } const chat = chatCache.get(chatId); if (chat) { const remaining = messageCache.get(chatId) || []; if (remaining.length) { const last = remaining[remaining.length - 1]; chat.lastMessage = last.text; chat.lastAt = last.createdAt; } else { chat.lastMessage = ""; } renderChatListFromCache(); } } // ── Удаление чатов ────────────────────────────────────────────── function onDeleteChatResult(msg) { if (!msg.ok) { toastWarning("Не удалось удалить чат"); return; } removeChatFromUi(msg.chatId); toastInfo("Чат удалён"); } function onChatDeleted(msg) { removeChatFromUi(msg.chatId); } function removeChatFromUi(chatId) { chatCache.delete(chatId); messageCache.delete(chatId); chatPaging.delete(chatId); renderChatListFromCache(); if (currentChatId === chatId) { currentChatId = null; document.getElementById("sendBox").style.display = "none"; document.getElementById("messages").innerHTML = ""; document.getElementById("chatTitleText").textContent = ""; document.getElementById("chatToken").textContent = ""; const av = document.getElementById("privateAvatar"); if (av) av.classList.remove("shown"); if (isMobile()) { document.getElementById("messagesContainer").classList.remove("open"); } } } function requestDeleteMessage(chatId, messageId) { send("delete_message", { chatId: chatId, messageId: messageId }); } function requestDeleteChat(chatId) { send("delete_chat", { chatId: chatId }); } // ── Копирование текста / изображения ──────────────────────────── async function copyTextToClipboard(text) { try { await navigator.clipboard.writeText(text || ""); toastInfo("Текст скопирован"); } catch (e) { toastWarning("Не удалось скопировать текст"); } } async function copyImageToClipboard(src) { try { const resp = await fetch(src); const blob = await resp.blob(); await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); toastInfo("Изображение скопировано"); } catch (e) { toastWarning("Не удалось скопировать изображение"); } } // ── Универсальное контекстное меню ────────────────────────────── function closeContextMenu() { const menu = document.getElementById("contextMenu"); if (menu) menu.style.display = "none"; const backdrop = document.getElementById("contextMenuBackdrop"); if (backdrop) backdrop.remove(); document.removeEventListener("scroll", closeContextMenu, true); } function showContextMenu(x, y, items) { closeContextMenu(); const menu = document.getElementById("contextMenu"); if (!menu) return; menu.innerHTML = ""; items.forEach(function (item) { const btn = document.createElement("button"); btn.className = "context-menu-item" + (item.danger ? " danger" : ""); btn.textContent = item.label; btn.onclick = function (e) { e.stopPropagation(); closeContextMenu(); item.onClick(); }; menu.appendChild(btn); }); const backdrop = document.createElement("div"); backdrop.id = "contextMenuBackdrop"; backdrop.className = "context-menu-backdrop"; backdrop.onclick = closeContextMenu; backdrop.oncontextmenu = function (e) { e.preventDefault(); closeContextMenu(); }; document.body.appendChild(backdrop); menu.style.display = "flex"; menu.style.left = "-9999px"; menu.style.top = "-9999px"; requestAnimationFrame(function () { const rect = menu.getBoundingClientRect(); let left = x, top = y; if (left + rect.width > window.innerWidth - 8) left = window.innerWidth - rect.width - 8; if (top + rect.height > window.innerHeight - 8) top = window.innerHeight - rect.height - 8; menu.style.left = Math.max(8, left) + "px"; menu.style.top = Math.max(8, top) + "px"; }); document.addEventListener("scroll", closeContextMenu, true); } // Меню для сообщения: копирование (текст/картинка под курсором) + удаление своих function openMessageContextMenu(x, y, m, clickTarget) { const mine = userProfile && m.userId === userProfile.id; const imgEl = clickTarget && clickTarget.closest ? clickTarget.closest("img") : null; const items = []; if (imgEl) { items.push({ label: "Копировать изображение", onClick: function () { copyImageToClipboard(imgEl.src); } }); } else { items.push({ label: "Копировать текст", onClick: function () { copyTextToClipboard(m.text || ""); } }); } if (mine) { items.push({ label: "Удалить сообщение", danger: true, onClick: function () { requestDeleteMessage(m.chatId, m.id); } }); } showContextMenu(x, y, items); } // Меню для чата в списке function openChatContextMenu(x, y, chat) { showContextMenu(x, y, [ { label: "Удалить чат", danger: true, onClick: function () { requestDeleteChat(chat.id); } } ]); } function usernameColorIndex(name) { let hash = 0; for (let i = 0; i < name.length; i++) { hash = (hash * 31 + name.charCodeAt(i)) | 0; } return Math.abs(hash) % 6; } // ── Toast-уведомления (замена alert) ───────────────────────── const TOAST_ICONS = { info: '', success: '', warning: '', error: '' }; const TOAST_CLOSE_ICON = ''; const TOAST_LIFETIME = 2000; function showToast(message, type) { type = TOAST_ICONS[type] ? type : "info"; const container = document.getElementById("toastContainer"); if (!container) return; // Обёртка нужна для плавного схлопывания места при удалении тоста — // сам .toast анимирует только transform/opacity, а .toast-wrap высоту. const wrap = document.createElement("div"); wrap.className = "toast-wrap"; const el = document.createElement("div"); el.className = "toast " + type; const icon = document.createElement("div"); icon.className = "toast-icon"; icon.innerHTML = TOAST_ICONS[type]; const body = document.createElement("div"); body.className = "toast-body"; body.textContent = message; const closeBtn = document.createElement("button"); closeBtn.className = "toast-close"; closeBtn.innerHTML = TOAST_CLOSE_ICON; closeBtn.setAttribute("aria-label", "Закрыть"); el.appendChild(icon); el.appendChild(body); el.appendChild(closeBtn); wrap.appendChild(el); container.appendChild(wrap); // Появление на следующем кадре — чтобы transition сработал requestAnimationFrame(function () { requestAnimationFrame(function () { el.classList.add("show"); }); }); let dismissed = false; function dismiss() { if (dismissed) return; dismissed = true; // 1) Сам тост уезжает вверх (тень и bounce не обрезаны — overflow:visible) el.classList.remove("show"); el.classList.add("leaving"); // 2) Одновременно схлопывается его место — нижние тосты плавно // поднимаются вместо мгновенного скачка. // .collapsing включает overflow:hidden только на время схлопывания. wrap.classList.add("collapsing"); wrap.classList.add("collapsed"); wrap.addEventListener("transitionend", function handler(e) { if (e.propertyName !== "max-height") return; wrap.removeEventListener("transitionend", handler); wrap.remove(); }); // Фоллбек если transitionend не сработал setTimeout(function () { if (wrap.parentNode) wrap.remove(); }, 450); } const timer = setTimeout(dismiss, TOAST_LIFETIME); closeBtn.onclick = function () { clearTimeout(timer); dismiss(); }; return dismiss; } function toastInfo(msg) { return showToast(msg, "info"); } function toastSuccess(msg) { return showToast(msg, "success"); } function toastWarning(msg) { return showToast(msg, "warning"); } function toastError(msg) { return showToast(msg, "error"); } // ── Спиннер загрузки на кнопках (логин/регистрация) ─────────── const btnLoadingTimers = new Map(); function setButtonLoading(btn, loading) { if (!btn) return; // Сбрасываем предыдущий защитный таймер, если он был const prevTimer = btnLoadingTimers.get(btn); if (prevTimer) { clearTimeout(prevTimer); btnLoadingTimers.delete(btn); } if (loading) { btn.classList.add("btn-loading"); // Защита от вечного спиннера, если сервер не ответит (обрыв связи и т.п.) const timer = setTimeout(function () { btn.classList.remove("btn-loading"); btnLoadingTimers.delete(btn); }, 10000); btnLoadingTimers.set(btn, timer); } else { btn.classList.remove("btn-loading"); } } // ── Централизованное хранилище аватаров ────────────────────── // Каждый уникальный base64 хранится один раз в // как SVG , все места использования — это лёгкие , // которые не дублируют сам base64 в DOM. const SVG_NS = "http://www.w3.org/2000/svg"; function registerAvatarDef(key, base64) { if (!key || !base64) return null; const defs = document.getElementById("avatarDefs"); if (!defs) return null; let img = defs.querySelector("#" + CSS.escape(key)); if (!img) { img = document.createElementNS(SVG_NS, "image"); img.setAttribute("id", key); img.setAttribute("width", "100"); img.setAttribute("height", "100"); img.setAttribute("preserveAspectRatio", "xMidYMid slice"); defs.appendChild(img); } // Обновляем href только если изменился — избегаем лишней перезаписи/декодирования if (img.getAttribute("href") !== "avatar/" + base64) { img.setAttribute("href", "avatar/" + base64); } return key; } // Создаёт function createAvatarUseElement(key, extraClass) { const svg = document.createElementNS(SVG_NS, "svg"); svg.setAttribute("class", "avatarUse" + (extraClass ? " " + extraClass : "")); svg.setAttribute("viewBox", "0 0 100 100"); const use = document.createElementNS(SVG_NS, "use"); use.setAttribute("href", "#" + key); svg.appendChild(use); return svg; } // Универсальный рендер аватара внутрь контейнера: либо на общий ресурс, // либо текстовый инициал-фоллбек function setAvatarContent(container, base64, key, fallbackChar) { container.innerHTML = ""; if (base64 && registerAvatarDef(key, base64)) { container.appendChild(createAvatarUseElement(key)); } else { container.textContent = (fallbackChar || "?").toString().charAt(0).toUpperCase(); } } function sortChats() { const arr = Array.from(chatCache.values()); arr.sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0)); chatCache = new Map(arr.map(c => [c.id, c])); } function openChat(chat) { currentChatId = chat.id; document.getElementById("chatTitleHeader").style.display = 'flex'; // Базовая линия для разделителя "Непрочитанные" — фиксируем именно на // момент открытия чата, чтобы разделитель не "убегал" вслед за живым // обновлением lastReadMessageId по мере того, как читаются сообщения. chat._dividerBaseline = chat.lastReadMessageId || 0; // сразу обновляем активный элемент в списке без полного ре-рендера document.querySelectorAll(".chatItem").forEach(function(el) { el.classList.toggle("active", Number(el.dataset.chatId) === Number(chat.id)); }); document.getElementById("chatTitleText").textContent = chat.title; // Аватарка в заголовке — для всех чатов, ключ общий со списком чатов const av = document.getElementById("privateAvatar"); av.classList.add("shown"); setAvatarContent(av, chat.avatar, "chat-" + chat.id, chat.title); const tkn = document.getElementById("chatToken") const state = document.getElementById("chatState") if (chat.isPrivate) { tkn.classList.remove("can-copy"); tkn.removeAttribute('data-copy-text'); tkn.textContent = ""; state.classList.toggle("online", chat.online); if (chat.online) { state.textContent = "оnline"; }else{ state.textContent = formatRelativeTime(chat.lastSeen); } }else{ state.classList.remove("online"); state.textContent = ""; tkn.classList.add("can-copy"); tkn.textContent = "Токен: " + chat.token; tkn.dataset.copyText = chat.token; } if (isMobile()) { document.getElementById("messagesContainer").classList.add("open"); } const paging = getPaging(chat.id); const cached = messageCache.get(chat.id); // ВАЖНО: доверяем кэшу только если история для этого чата реально была // загружена запросом get_messages (paging.historyLoaded), а не просто // потому что там что-то есть — там могли оказаться только что прилетевшие // живые сообщения (см. комментарий в getPaging), и тогда старая история // вообще никогда бы не подгрузилась. if (paging.historyLoaded && cached && cached.length) { document.getElementById("sendBox").style.display = 'flex'; renderMessagesFull(chat.id, null); // Если в кэше есть непрочитанные — покажем разделитель по центру, // а не сразу утащим пользователя в самый низ. requestAnimationFrame — // чтобы применить это ПОСЛЕ того, как браузер точно посчитал layout // (особенно важно на мобильном, где контейнер чата ещё едет по CSS-transition). requestAnimationFrame(function () { scrollToDividerOrBottom(chat.id); }); } else if (!paging.pendingInitial) { paging.pendingInitial = true; // Если в чате много непрочитанного — грузим сразу с запасом "старого" // контекста сверху, чтобы разделитель не оказался в самом верху экрана // (иначе получится "прочитанного не видно совсем"). const initialLimit = Math.min(400, Math.max(MSG_PAGE_SIZE, (chat.unread || 0) + 30)); paging.initialLimit = initialLimit; // ВАЖНО: НЕ рендерим "превью" из того, что уже случайно есть в кэше // (живые сообщения, прилетевшие до открытия) — они почти всегда самые // свежие в чате, короткий список тривиально помещается на экран целиком, // и IntersectionObserver тут же считает их все увиденными → mark_read // с максимальным id среди них → раз это id САМОГО свежего сообщения // чата, отметка прочтения задним числом перескакивает на "прочитано // всё", хотя реальная (старая) история ещё даже не загружена и уж тем // более не видна. Просто ждём настоящий ответ с полной историей — // индикатор загрузки (showEdgeLoader) уже сообщает, что идёт загрузка. document.getElementById('messages').textContent = ""; showEdgeLoader(); send("get_messages", { chatId: chat.id, offset: 0, limit: initialLimit }); } updateJumpButtonVisibility(); } function buildAvatar(m, mine) { const cls = "msgAvatar " + (mine ? "mine" : "theirs"); if (m.avatar) { const key = "user-" + m.userId; registerAvatarDef(key, m.avatar); const svg = createAvatarUseElement(key, cls); if (m.username) { const title = document.createElementNS(SVG_NS, "title"); title.textContent = m.username; svg.appendChild(title); } return svg; } const div = document.createElement("div"); div.className = "msgAvatar msgAvatarEmpty " + (mine ? "mine" : "theirs"); div.title = m.username || ""; div.textContent = (m.username || "?").charAt(0).toUpperCase(); return div; } // Возвращает активную группу для данного userId, либо null function getActiveGroup(box, userId) { const groups = box.querySelectorAll(".msgGroup[data-uid='" + userId + "']"); if (!groups.length) return null; const last = groups[groups.length - 1]; // Группа считается активной только если она последний .msgGroup в box // (т.е. после неё нет групп от других пользователей) const allGroups = box.querySelectorAll(".msgGroup"); if (allGroups[allGroups.length - 1] === last) return last; return null; } // Приблизительно оценивает доступную ширину содержимого пузыря сообщения // (в px) в момент рендера — используется, чтобы посчитать итоговые // пиксельные размеры картинки/превью видео БЕЗ использования CSS aspect-ratio // или min(), т.к. их поддержка (особенно в WebView) может быть ненадёжной. // Не обязана быть пиксельно точной — небольшая погрешность в несколько px // не создаёт заметного скачка, а max-width/max-height в CSS всё равно // подрежут итоговый размер как страховка. function estimateBubbleContentWidth() { const messagesBox = document.getElementById("messages"); const containerWidth = (messagesBox && messagesBox.clientWidth) || 320; const bubbleMaxWidth = Math.min(containerWidth * 0.72, 480); // грубо вычитаем паддинги пузыря/сообщения и запас на аватар/gap return Math.max(120, bubbleMaxWidth - 40); } // Резервирует место под одиночную (single) картинку/превью видео ДО того, // как байты успели догрузиться — иначе высота пузыря равна нулю, чат // скроллится вниз, а затем картинка догружается и низ "убегает" ещё ниже. // // Специально НЕ используется CSS aspect-ratio/min() — их поддержка не везде // одинаково надёжна (особенно в WebView), а инлайновые пиксельные width/height, // посчитанные прямо в JS, гарантированно работают в любом браузере без // исключений и не зависят от того, успела ли картинка загрузиться. function applySingleMediaSizing(imgEl, att, isSingle, fallbackRatio) { if (!isSingle) return; const w = att.width, h = att.height; if (w && h) { const availWidth = estimateBubbleContentWidth(); const ratio = w / h; let boxWidth = Math.min(availWidth, 320 * ratio); // не выше, чем нужно для max-height 320 boxWidth = Math.max(1, Math.round(boxWidth)); // ВАЖНО: высоту НЕ фиксируем в px. estimateBubbleContentWidth() — это // прикидка, а не точный замер реального контейнера; если на экране // ширина в итоге отличается (например CSS max-width:100% где-то // подрежёт наш расчёт), то при фиксированной высоте пропорции box'а // разъезжаются с реальным соотношением сторон картинки — она выглядит // сплющенной. aspect-ratio решает это раз и навсегда: какую бы // сторону ни обрезал max-width/max-height, браузер пересчитывает // вторую по этому же соотношению — коробка остаётся пропорциональной // независимо от того, насколько точным был наш JS-расчёт. imgEl.style.width = boxWidth + "px"; imgEl.style.height = "auto"; imgEl.style.aspectRatio = w + " / " + h; } else { // Старое сообщение без сохранённых размеров (или превью до того, как // размеры вообще стали известны) — раньше тут задавался только // aspect-ratio БЕЗ ширины: inline-block обёртка (.msgAttachmentMediaWrap) // сайзится по контенту, и без явной ширины на не загрузившейся ещё // картинке схлопывалась в 0px (та самая "нулевая" галерея до // прогрузки). Явная width:100% резервирует место сразу, aspect-ratio // держит пропорцию до и после реальной загрузки байт. imgEl.style.width = "100%"; imgEl.style.height = "auto"; imgEl.style.aspectRatio = fallbackRatio || "4 / 3"; } } // ── Разрешение источника вложения: пока идёт локальная отправка — берём // blob-превью (мгновенно, без сети), после подтверждения сервером — обычный // URL /attachment/id/wsId. previewUrl/localThumbUrl переживают финализацию // сообщения (см. finalizeLocalMessage), поэтому картинка не мигает и не // перезагружается повторно. function attachmentImageSrc(att) { if (att.previewUrl) return att.previewUrl; if (att.data) return "attachment/" + att.data + "/" + wsId; return ""; } function attachmentVideoThumbSrc(att) { if (att.localThumbUrl) return att.localThumbUrl; if (att.thumb) return "attachment/" + att.thumb + "/" + wsId; return ""; } function attachmentFileHref(att) { return att.data ? ("attachment/" + att.data + "/" + wsId) : null; } function isAttachmentUploading(att) { return att.status === "uploading" || (!att.data && att.file); } // Круглый оверлей с процентом — рисуется поверх картинки/видео, пока файл // ещё грузится на сервер кусками (см. uploadFileChunked/updateLocalAttachmentProgress) function buildAttachmentProgressOverlay(att) { const overlay = document.createElement("div"); overlay.className = "attUploadOverlay"; if (att.localId) overlay.dataset.attlocal = att.localId; const ring = document.createElement("div"); ring.className = "attUploadRing"; overlay.appendChild(ring); const pct = document.createElement("div"); pct.className = "attUploadPct"; pct.textContent = Math.round((att.progress || 0) * 100) + "%"; overlay.appendChild(pct); return overlay; } // Строка-заглушка для файла/аудио, пока оно грузится (превью недоступно — // показываем имя/иконку и процент вместо ссылки на скачивание) function buildUploadingAttachmentRow(att) { const row = document.createElement("div"); row.className = "msgAttachmentFile msgAttachmentUploading"; if (att.localId) row.dataset.attlocal = att.localId; const icon = document.createElement("div"); icon.className = "msgAttachmentFileIcon"; icon.innerHTML = att.type === "audio" ? audioIconSvg() : fileIconSvg(); row.appendChild(icon); const info = document.createElement("div"); info.className = "msgAttachmentFileInfo"; const nameEl = document.createElement("div"); nameEl.className = "msgAttachmentFileName"; nameEl.textContent = att.name || "Файл"; const sizeEl = document.createElement("div"); sizeEl.className = "msgAttachmentFileSize attUploadPct"; sizeEl.textContent = Math.round((att.progress || 0) * 100) + "%"; info.appendChild(nameEl); info.appendChild(sizeEl); row.appendChild(info); return row; } // Иконка статуса своего сообщения: часы (отправляется) → одна галочка // (отправлено, сервер подтвердил) → две галочки (доставлено получателю). // Часы (крутятся, accent) — отправляется. Те же часы, но неподвижные и // красные — ошибка отправки. Одна галочка (accent) — отправлено/доставлено // (для получателя это одно и то же, пока он реально не прочитал). Две // галочки (accent) — прочитано. function setStatusIconContent(svgEl, status) { let href; if (status === "sending" || status === "failed") href = "#message-status-sending"; else if (status === "read") href = "#message-status-read"; else href = "#message-status-sent"; // sent svgEl.classList.remove("failed", "msgStatusSending"); if (status === "failed") svgEl.classList.add("failed"); else if (status === "sending") svgEl.classList.add("msgStatusSending"); svgEl.innerHTML = ''; svgEl.setAttribute("title", status === "failed" ? "Не отправлено — нажмите, чтобы повторить" : status === "sending" ? "Отправляется…" : status === "read" ? "Прочитано" : "Отправлено"); } function updateMessageStatusIcon(msgId, status) { const el = document.querySelector('[data-msgid="' + msgId + '"]'); if (!el) return; const svgEl = el.querySelector(".msgStatusIcon"); if (svgEl) setStatusIconContent(svgEl, status); } // Та же иконка статуса, что и в пузыре сообщения, но для строки чата в списке // (используется, только когда последним в чате писали мы сами — см. // renderChatListFromCache). Строится как HTML-строка (не DOM-нода), т.к. // вставляется прямо в шаблон innerHTML строки чата. function chatListStatusIconHtml(status) { let href; if (status === "sending" || status === "failed") href = "#message-status-sending"; else if (status === "read") href = "#message-status-read"; else href = "#message-status-sent"; let cls = "msgStatusIcon chatListStatusIcon"; if (status === "failed") cls += " failed"; else if (status === "sending") cls += " msgStatusSending"; return ''; } // Собирает содержимое пузыря (вложения + текст + время + статус) — // используется и при первичном рендере, и при "финализации" (замене // локального превью на подтверждённые сервером данные), см. finalizeLocalMessage. function buildMessageDiv(m, mine) { const message = document.createElement("div"); message.className = "message"; message.setAttribute("dir", "auto"); // Вложения — рендерим перед текстом, как в Telegram. // Поддержка обратной совместимости: старые сообщения могли хранить // одиночное вложение в поле m.attachment вместо массива m.attachments. let attachments = m.attachments; if (!attachments && m.attachment) attachments = [m.attachment]; if (attachments && !Array.isArray(attachments)) attachments = [attachments]; if (attachments && attachments.length) { // Картинки и видео листаются вместе в едином медиа-вьюере const media = attachments.filter(function (a) { return a.type === "image" || a.type === "video"; }); const audios = attachments.filter(function (a) { return a.type === "audio"; }); const files = attachments.filter(function (a) { return a.type !== "image" && a.type !== "video" && a.type !== "audio"; }); if (media.length) { const gallery = document.createElement("div"); gallery.className = "msgAttachmentGallery" + (media.length === 1 ? " single" : ""); media.forEach(function (att, idx) { const uploading = isAttachmentUploading(att); if (att.type === "image") { const img = document.createElement("img"); img.className = "msgAttachmentImage"; img.src = attachmentImageSrc(att); img.alt = att.name || "изображение"; applySingleMediaSizing(img, att, media.length === 1, "4 / 3"); if (!uploading) img.onclick = function () { openMediaViewer(media, idx); }; if (uploading) { const wrap = document.createElement("div"); wrap.className = "msgAttachmentMediaWrap"; wrap.appendChild(img); wrap.appendChild(buildAttachmentProgressOverlay(att)); gallery.appendChild(wrap); } else { gallery.appendChild(img); } } else { const cell = document.createElement("div"); cell.className = "msgAttachmentVideoCell" + (att.round ? " msgVideoRound" : ""); if (!uploading) cell.onclick = function () { openMediaViewer(media, idx); }; const thumbImg = document.createElement("img"); thumbImg.className = "msgAttachmentImage msgAttachmentVideoThumb"; thumbImg.src = attachmentVideoThumbSrc(att); thumbImg.alt = att.name || "видео"; if (att.round) { // Круглые видео-сообщения — фиксированный размер, задаётся CSS (.msgVideoRound) thumbImg.style.width = "100%"; thumbImg.style.height = "100%"; } else { applySingleMediaSizing(thumbImg, att, media.length === 1, "16 / 9"); } cell.appendChild(thumbImg); if (!uploading) { const playBadge = document.createElement("div"); playBadge.className = "msgAttachmentPlayBadge"; playBadge.innerHTML = playIconSvg(); cell.appendChild(playBadge); } else { cell.appendChild(buildAttachmentProgressOverlay(att)); } if (att.duration) { const dur = document.createElement("div"); dur.className = "msgAttachmentDuration"; dur.textContent = formatDuration(att.duration); cell.appendChild(dur); } gallery.appendChild(cell); } }); message.appendChild(gallery); } audios.forEach(function (att, i) { if (isAttachmentUploading(att)) { message.appendChild(buildUploadingAttachmentRow(att)); return; } const audioKey = "audio_" + m.id + "_" + i; message.appendChild(buildAudioPlayer(att, audioKey)); }); files.forEach(function (att) { if (isAttachmentUploading(att)) { message.appendChild(buildUploadingAttachmentRow(att)); return; } const link = document.createElement("a"); link.className = "msgAttachmentFile"; link.href = attachmentFileHref(att); link.download = att.name || "file"; const icon = document.createElement("div"); icon.className = "msgAttachmentFileIcon"; icon.innerHTML = fileIconSvg(); link.appendChild(icon); const info = document.createElement("div"); info.className = "msgAttachmentFileInfo"; const nameEl = document.createElement("div"); nameEl.className = "msgAttachmentFileName"; nameEl.textContent = att.name || "Файл"; const sizeEl = document.createElement("div"); sizeEl.className = "msgAttachmentFileSize"; sizeEl.textContent = formatFileSize(att.size || 0); info.appendChild(nameEl); info.appendChild(sizeEl); link.appendChild(info); message.appendChild(link); }); } if (m.text) { const text = document.createElement("div"); text.textContent = m.text; message.appendChild(text); } const time = document.createElement("div"); time.className = "msgTime"; time.textContent = formatTime(m.createdAt); message.appendChild(time); if (mine) { const statusIcon = document.createElementNS(SVG_NS, "svg"); statusIcon.setAttribute("class", "msgStatusIcon"); statusIcon.setAttribute("viewBox", "0 0 16 16"); statusIcon.setAttribute("width", "14"); statusIcon.setAttribute("height", "14"); setStatusIconContent(statusIcon, m._status || "sent"); time.appendChild(statusIcon); } return message; } function appendMessageToBox(box, m, prevMsg) { appendDateHeaderIfNeeded(box, prevMsg, m); const chat = chatCache.get(m.chatId); const mine = userProfile && m.userId === userProfile.id; const isPrivate = chat && chat.isPrivate; const showAvatars = !isPrivate; const sameChain = prevMsg && prevMsg.userId === m.userId && getLocalDayStart(prevMsg.createdAt) === getLocalDayStart(m.createdAt); // ─────────────────────────────────────────────────────────────── // 1) Обработка предыдущего пузырька // ─────────────────────────────────────────────────────────────── let prevEl = null; if (prevMsg) { prevEl = box.querySelector(`[data-msgid="${prevMsg.id}"]`); } if (prevEl) { const was = [...prevEl.classList].find(c => c.startsWith("chain-")); if (sameChain) { // цепочка продолжается if (was === "chain-once") { prevEl.classList.replace("chain-once", "chain-start"); } else if (was === "chain-start") { prevEl.classList.replace("chain-start", "chain-mid"); } else if (was === "chain-end") { prevEl.classList.replace("chain-end", "chain-mid"); } // chain-mid остаётся chain-mid } else { // цепочка прерывается if (was === "chain-mid") { prevEl.classList.replace("chain-mid", "chain-end"); } else if (was === "chain-start") { prevEl.classList.replace("chain-start", "chain-once"); } // chain-once остаётся chain-once } } // ─────────────────────────────────────────────────────────────── // 2) Создаём новый пузырёк // ─────────────────────────────────────────────────────────────── const el = document.createElement("div"); el.dataset.msgid = m.id; let chainClass; if (!prevEl) { // первое сообщение в чате chainClass = "chain-once"; } else if (sameChain) { // цепочка продолжается chainClass = "chain-end"; // временно, потом станет mid } else { // новая группа chainClass = "chain-once"; // первое сообщение в группе — одиночное, пока не придёт следующее } el.className = "msgItem " + chainClass + (mine ? " mine" : " theirs"); // ── Настоящая прочитанность: если это чужое сообщение и оно ещё не // отмечено прочитанным (id больше нашей текущей отметки в чате) — // начинаем следить за его видимостью. Как только оно реально попадёт // в область просмотра — пошлём mark_read (см. onUnreadIntersect), сервер // сдвинет отметку и пересчитает счётчики (см. onUnreadResult). if (!mine && typeof m.id === "number") { const chatForRead = chatCache.get(m.chatId); const readWatermark = (chatForRead && chatForRead.lastReadMessageId) || 0; if (m.id > readWatermark) { el.dataset.readChat = m.chatId; el.dataset.readMsgid = m.id; ensureUnreadObserver().observe(el); } } // Имя только у начала цепочки if (!mine && showAvatars && (chainClass === "chain-start" || chainClass === "chain-once")) { const name = document.createElement("div"); const colorIdx = usernameColorIndex(m.username || ""); name.className = "msgUsername un-color-" + colorIdx; name.textContent = m.username; el.appendChild(name); } const message = buildMessageDiv(m, mine); el.appendChild(message); if (chainClass === "chain-end" || chainClass === "chain-once") { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 11 20"); svg.setAttribute("class", "bubble-tail"); svg.setAttribute("width", "11"); svg.setAttribute("height", "20"); svg.innerHTML = ''; el.appendChild(svg); } // ─────────────────────────────────────────────────────────────── // 3) Группы для чужих сообщений // ─────────────────────────────────────────────────────────────── let group = null; if (showAvatars) { if (sameChain) { group = getActiveGroup(box, m.userId); } if (!group) { group = document.createElement("div"); group.className = "msgGroup"; group.dataset.uid = m.userId; const av = buildAvatar(m, mine); group.appendChild(av); const bubble_col = document.createElement("div"); bubble_col.className = "msgBubbleCol"; group.appendChild(bubble_col); box.appendChild(group); } const col = group.querySelector(".msgBubbleCol"); col.appendChild(el); } else { const row = document.createElement("div"); row.className = "msgRow"; row.appendChild(el); box.appendChild(row); } // ─────────────────────────────────────────────────────────────── // 4) Анимации // ─────────────────────────────────────────────────────────────── if (m._noAnim) el.style.animation = "none"; if (mine && !m._noAnim) el.classList.add("send-anim"); // ─────────────────────────────────────────────────────────────── // 5) Контекстное меню: ПКМ на ПК, простой тап на мобильном // Сообщение со статусом "не отправлено" — клик повторяет отправку // вместо открытия меню. // ─────────────────────────────────────────────────────────────── if (mine && m._status === "failed") { el.classList.add("msgFailed"); el.addEventListener("click", function (e) { e.stopPropagation(); retrySendMessage(m.id); }); } else { el.addEventListener("contextmenu", function (e) { e.preventDefault(); openMessageContextMenu(e.clientX, e.clientY, m, e.target); }); el.addEventListener("click", function (e) { if (!isMobile()) return; // на ПК меню только по ПКМ e.stopPropagation(); openMessageContextMenu(e.clientX, e.clientY, m, e.target); }); } } function scrollMessagesToBottom() { if (!autoScroll) return; const box = document.getElementById("messages"); box.scrollTop = box.scrollHeight; } function renderChatListFromCache() { const list = document.getElementById("chatList"); list.innerHTML = ""; const chats = Array.from(chatCache.values()).sort((a, b) => (b.lastAt || 0) - (a.lastAt || 0)); chats.forEach(function (c) { const el = document.createElement("div"); el.className = "chatItem" + (c.id === currentChatId ? " active" : "") + (c.online ? " online" : ""); el.dataset.chatId = c.id; let avatarHtml = ""; if (c.avatar) { registerAvatarDef("chat-" + c.id, c.avatar); avatarHtml = ``; } else { const initials = escapeHtml((c.title || "?").trim().charAt(0).toUpperCase()); avatarHtml = `${initials}`; } const unreadCount = c.unread || 0; const unreadHtml = unreadCount > 0 ? `${unreadCount}` : ""; // Иконка статуса последнего сообщения — только если последним в чате // писали МЫ САМИ; для чужого последнего сообщения иконки нет вообще. const statusHtml = (userProfile && c.lastMessageUserId === userProfile.id) ? chatListStatusIconHtml(c.lastMessageStatus || "sent") : ""; el.innerHTML = ` ${avatarHtml} ${escapeHtml(c.title)} ${statusHtml}${formatChatTime(c.lastAt)} ${escapeHtml(c.lastMessage || "")} ${unreadHtml} `; el.onclick = function () { openChat(c); }; // ПКМ на ПК — контекстное меню чата el.oncontextmenu = function (e) { e.preventDefault(); openChatContextMenu(e.clientX, e.clientY, c); }; // Долгое удержание на мобильном — то же меню (function () { let lpTimer = null; let lpFired = false; el.addEventListener("touchstart", function (e) { lpFired = false; const touch = e.touches[0]; lpTimer = setTimeout(function () { lpFired = true; lpTimer = null; openChatContextMenu(touch.clientX, touch.clientY, c); }, 500); }, { passive: true }); el.addEventListener("touchmove", function () { if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } }, { passive: true }); el.addEventListener("touchend", function (e) { if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } if (lpFired) { e.preventDefault(); } // не даём сработать обычному click после долгого тапа }); })(); list.appendChild(el); }); } function escapeHtml(s) { if (s == null) return ""; return s.toString() .replace(/&/g, "&") .replace(//g, ">"); } function formatDateHeader(utcMillis) { const now = new Date(); const d = new Date(Number(utcMillis)); const isToday = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); const yesterday = new Date(); yesterday.setDate(now.getDate() - 1); const isYesterday = d.getFullYear() === yesterday.getFullYear() && d.getMonth() === yesterday.getMonth() && d.getDate() === yesterday.getDate(); if (isToday) return "Сегодня"; if (isYesterday) return "Вчера"; const months = [ "января","февраля","марта","апреля","мая","июня", "июля","августа","сентября","октября","ноября","декабря" ]; const day = d.getDate(); const month = months[d.getMonth()]; if (d.getFullYear() === now.getFullYear()) { return day + " " + month; } return day + " " + month + " " + d.getFullYear(); } function formatChatTime(ts) { if (!ts) return ''; // 1. Автоперевод из секунд в миллисекунды, если нужно let timestamp = Number(ts); if (timestamp < 1e11) { timestamp *= 1000; } const now = new Date(); const targetDate = new Date(timestamp); if (isNaN(targetDate.getTime())) return ''; // Начало вчерашнего дня (00:00:00) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterdayStart = new Date(todayStart); yesterdayStart.setDate(yesterdayStart.getDate() - 1); // Вспомогательное форматирование ЧЧ:ММ const getTimeString = (date) => { const h = date.getHours().toString().padStart(2, '0'); const m = date.getMinutes().toString().padStart(2, '0'); return `${h}:${m}`; }; // Сокращённые названия месяцев const getShortMonthName = (date) => { const months = [ 'янв.', 'февр.', 'мар.', 'апр.', 'мая', 'июн.', 'июл.', 'авг.', 'сент.', 'окт.', 'нояб.', 'дек.' ]; return months[date.getMonth()]; }; // ЛОГИКА ОТОБРАЖЕНИЯ // 1. Сегодня или вчера -> только время (ЧЧ:ММ) if (targetDate >= yesterdayStart) { return getTimeString(targetDate); } // 2. В этом году (но раньше вчера) -> "12 авг." if (targetDate.getFullYear() === now.getFullYear()) { return `${targetDate.getDate()} ${getShortMonthName(targetDate)}`; } // 3. Прошлые года -> "12.08.2024" (или "12.08.24") const day = targetDate.getDate().toString().padStart(2, '0'); const month = (targetDate.getMonth() + 1).toString().padStart(2, '0'); const year = targetDate.getFullYear(); return `${day}.${month}.${year}`; } function formatTime(utcMillis) { const d = new Date(Number(utcMillis)); return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function appendDateHeaderIfNeeded(box, prevMsg, msg) { const prevDay = prevMsg ? getLocalDayStart(prevMsg.createdAt) : null; const curDay = getLocalDayStart(msg.createdAt); if (prevDay !== curDay) { const h = document.createElement("div"); h.className = "dateHeader"; const span = document.createElement("span"); span.textContent = formatDateHeader(msg.createdAt); h.appendChild(span); box.appendChild(h); } } function getLocalDayStart(utcMillis) { const d = new Date(Number(utcMillis)); return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); } function formatRelativeTime(ts) { if (!ts) return ''; // 1. Автоматически переводим секунды в миллисекунды, если timestamp в секундах let timestamp = Number(ts); if (timestamp < 1e11) { timestamp *= 1000; } const now = new Date(); const targetDate = new Date(timestamp); // Проверка на валидность даты if (isNaN(targetDate.getTime())) return ''; // 2. Нормализуем даты до начала дня const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()); const todayStart = startOfDay(now); const yesterdayStart = new Date(todayStart); yesterdayStart.setDate(yesterdayStart.getDate() - 1); const dayBeforeYesterdayStart = new Date(todayStart); dayBeforeYesterdayStart.setDate(dayBeforeYesterdayStart.getDate() - 2); // Форматирование времени HH:MM const getTimeString = (date) => { const h = date.getHours().toString().padStart(2, '0'); const m = date.getMinutes().toString().padStart(2, '0'); return `${h}:${m}`; }; // Название месяца const getMonthName = (date) => { const months = [ 'января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря' ]; return months[date.getMonth()]; }; const timeStr = getTimeString(targetDate); // 3. Сравниваем по календарным границам if (targetDate >= todayStart) { return `сегодня в ${timeStr}`; } else if (targetDate >= yesterdayStart) { return `вчера в ${timeStr}`; } else if (targetDate >= dayBeforeYesterdayStart) { return `позавчера в ${timeStr}`; } else { // В пределах текущего года if (targetDate.getFullYear() === now.getFullYear()) { return `${targetDate.getDate()} ${getMonthName(targetDate)} в ${timeStr}`; } else { // Другой год -> ДД.ММ.ГГГГ в ЧЧ:ММ const day = targetDate.getDate().toString().padStart(2, '0'); const month = (targetDate.getMonth() + 1).toString().padStart(2, '0'); const year = targetDate.getFullYear(); return `${day}.${month}.${year} в ${timeStr}`; } } } // ── Floating date ────────────────────────────────────────── // Два span чередуются: activeSlot = 0 или 1. // При смене даты — текущий уходит, следующий въезжает. function setInitialFloatingDate() { requestAnimationFrame(function() { document.querySelectorAll("#messages .dateHeader").forEach(function(h) { h.style.visibility = ""; h.style.transform = ""; }); updateFloatingDate(); }); } function updateFloatingDate() { var chip = document.getElementById("floatingDate"); var label = document.getElementById("floatingDateA"); var msgBox = document.getElementById("messages"); if (!chip || !label || !msgBox) return; var headers = Array.from(document.querySelectorAll("#messages .dateHeader")); if (!headers.length) { chip.style.visibility = "hidden"; return; } // Сбрасываем трансформы заголовков для честного измерения headers.forEach(function(h) { h.style.transform = ""; h.style.visibility = ""; }); var msgRect = msgBox.getBoundingClientRect(); var labelH = label.offsetHeight || 26; // X — отступ чипа от верха области сообщений (CSS: #floatingDate top:8px) // Меряем напрямую от wrap т.к. chip абсолютный внутри wrap var wrapRect = msgBox.parentElement.getBoundingClientRect(); var X = chip.offsetTop + 8; // offsetTop чипа (0) + CSS span top:8px // Активный: bottom заголовка ушёл выше X относительно wrap var activeIdx = -1; for (var i = 0; i < headers.length; i++) { var bot = headers[i].getBoundingClientRect().bottom - wrapRect.top - X * 2; if (bot < X) activeIdx = i; } if (activeIdx < 0) { chip.style.visibility = "hidden"; chip.style.transform = ""; return; } headers[activeIdx].style.visibility = "hidden"; var sp = headers[activeIdx].querySelector("span"); var text = sp ? sp.textContent : headers[activeIdx].textContent; if (label.textContent !== text) label.textContent = text; chip.style.visibility = "visible"; var next = headers[activeIdx + 1]; if (!next) { chip.style.transform = ""; return; } // nextTop — натуральная позиция следующего (трансформы сброшены выше) var nextTop = next.getBoundingClientRect().top - wrapRect.top; // Зона push: от pushStart до X // pushStart = X + labelH + X = 2X+labelH (расстояние X между низом чипа и верхом следующего) var pushStart = 2 * X + labelH; if (nextTop > pushStart) { // Вне зоны — чип стоит chip.style.transform = ""; return; } // Прогресс: 0 в начале, 1 когда nextTop==X (следующий на месте чипа) var progress = (pushStart - nextTop) / (pushStart - X); progress = Math.min(Math.max(progress, 0), 1); // Чип едет вверх на (labelH + X) пикселей суммарно var shift = -progress * (labelH + X); chip.style.transform = "translateY(" + shift + "px)"; // Следующий не двигаем — он остаётся на своём месте в потоке // Когда progress==1, чип ушёл за край и следующий становится активным } async function resizeAndCompressImage(base64, maxSize = 128) { return new Promise(resolve => { const img = new Image(); img.onload = () => { // Вычисляем масштаб const scale = maxSize / Math.max(img.width, img.height); const w = img.width * scale; const h = img.height * scale; // Создаём canvas const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, w, h); // Сжатие JPEG (качество 0.8) const compressed = canvas.toDataURL("image/jpeg", 0.8); resolve(compressed); }; img.src = base64; }); } // Возвращает натуральные размеры картинки — используются, чтобы зарезервировать // место под неё в вёрстке ДО загрузки байтов (см. appendMessageToBox), иначе // пузырь имеет нулевую высоту, чат скроллится вниз, а потом картинка // догружается и "убегает" вниз ещё дальше. function getImageDimensions(dataUrl) { return new Promise(function (resolve) { const img = new Image(); img.onload = function () { resolve({ width: img.naturalWidth, height: img.naturalHeight }); }; img.onerror = function () { resolve({ width: 0, height: 0 }); }; img.src = dataUrl; }); } // ── Медиа-вложения: сжатие, форматирование размера ─────────── async function compressImageForSend(dataUrl, maxDim) { return new Promise(function (resolve) { const img = new Image(); img.onload = function () { const longest = Math.max(img.width, img.height); if (longest <= maxDim) { resolve(dataUrl); // уже достаточно маленькое — не трогаем return; } const scale = maxDim / longest; const w = Math.round(img.width * scale); const h = Math.round(img.height * scale); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d"); ctx.drawImage(img, 0, 0, w, h); resolve(canvas.toDataURL("image/jpeg", 0.85)); }; img.onerror = function () { resolve(dataUrl); }; img.src = dataUrl; }); } function estimateDataUrlBytes(dataUrl) { const idx = dataUrl.indexOf(","); const b64 = idx >= 0 ? dataUrl.slice(idx + 1) : dataUrl; return Math.ceil(b64.length * 3 / 4); } function formatFileSize(bytes) { if (bytes < 1024) return bytes + " Б"; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " КБ"; return (bytes / (1024 * 1024)).toFixed(1) + " МБ"; } // Лимит подобран под серверный предел SQLiteBlob (Int32-смещение внутри // blob'а) — см. MaxAttachmentBytes в ChatServer.cs. Файл шлётся кусками, так // что "тяжести" самого файла в браузере это больше не касается — лимит тут // чисто дисковый/протокольный, как максимум файла в Telegram (~2 ГБ). const MAX_ATTACHMENT_BYTES = 1900 * 1024 * 1024; // ~1.9 ГБ // Размер одного куска при загрузке вложения на сервер (см. uploadFileChunked). // 512 КБ — в памяти в любой момент лежит максимум один такой кусок + его // base64-представление (~700 КБ), независимо от размера всего файла. const ATTACH_CHUNK_SIZE = 512 * 1024; function fileIconSvg() { return ''; } function videoIconSvg() { return ''; } function audioIconSvg() { return ''; } function playIconSvg() { return ''; } function pauseIconSvg() { return ''; } function formatDuration(sec) { sec = Math.max(0, Math.round(sec || 0)); const m = Math.floor(sec / 60); const s = sec % 60; return m + ":" + (s < 10 ? "0" : "") + s; } // Захватывает кадр из видео как превью-постер + узнаёт длительность/размеры function generateVideoThumbnail(dataUrl) { return new Promise(function (resolve) { const video = document.createElement("video"); video.preload = "metadata"; video.muted = true; video.playsInline = true; video.src = dataUrl; video.onloadedmetadata = function () { try { video.currentTime = Math.min(0.5, (video.duration || 1) / 2); } catch (e) { resolve(null); } }; video.onseeked = function () { try { const canvas = document.createElement("canvas"); canvas.width = video.videoWidth || 320; canvas.height = video.videoHeight || 180; const c2d = canvas.getContext("2d"); c2d.drawImage(video, 0, 0, canvas.width, canvas.height); resolve({ thumb: canvas.toDataURL("image/jpeg", 0.72), duration: video.duration || 0, width: video.videoWidth, height: video.videoHeight }); } catch (e) { resolve(null); } }; video.onerror = function () { resolve(null); }; }); } // Декодирует аудио через Web Audio API и строит реальный waveform (пики амплитуды) async function generateWaveformPeaks(file, barCount) { try { const arrayBuffer = await file.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0)); const channelData = audioBuffer.getChannelData(0); const blockSize = Math.max(1, Math.floor(channelData.length / barCount)); const peaks = []; for (let i = 0; i < barCount; i++) { let sum = 0; const start = i * blockSize; for (let j = 0; j < blockSize; j++) { const v = channelData[start + j]; if (v !== undefined) sum += Math.abs(v); } peaks.push(sum / blockSize); } const max = Math.max.apply(null, peaks) || 1; return { peaks: peaks.map(function (p) { return Math.max(0.08, p / max); }), duration: audioBuffer.duration }; } catch (e) { // Не удалось декодировать (редкий формат и т.п.) — плоский фоллбек-waveform const flat = []; for (let i = 0; i < barCount; i++) flat.push(0.35 + Math.random() * 0.25); return { peaks: flat, duration: 0 }; } } // ── Единый глобальный аудио-плеер: играет только ОДИН трек за раз ── const globalAudioEl = new Audio(); let globalAudioKey = null; // ключ текущего проигрываемого вложения (msgId_idx) const GLOBAL_AUDIO_SPEEDS = [1, 1.25, 1.5, 2, 0.75]; let globalAudioSpeedIdx = 0; // Реестр смонтированных инлайн-плееров — чтобы синхронизировать их // waveform/кнопку с состоянием единого Audio-элемента const audioPlayerRegistry = new Map(); // key -> { bars, btn, timeEl, duration } function setInlinePlayerPlayingVisual(key, playing) { const entry = audioPlayerRegistry.get(key); if (!entry) return; entry.btn.classList.toggle("playing", playing); entry.btn.innerHTML = playing ? pauseIconSvg() : playIconSvg(); } function resetInlinePlayerVisual(key) { const entry = audioPlayerRegistry.get(key); if (!entry) return; entry.btn.classList.remove("playing"); entry.btn.innerHTML = playIconSvg(); entry.bars.forEach(function (b) { b.classList.remove("played"); }); entry.timeEl.textContent = formatDuration(entry.duration || 0); } function updateInlinePlayerProgress(key) { const entry = audioPlayerRegistry.get(key); if (!entry || !globalAudioEl.duration) return; const ratio = globalAudioEl.currentTime / globalAudioEl.duration; const activeCount = Math.round(ratio * entry.bars.length); entry.bars.forEach(function (bar, i) { bar.classList.toggle("played", i < activeCount); }); entry.timeEl.textContent = formatDuration(globalAudioEl.currentTime); } function showGlobalAudioBar(title) { const bar = document.getElementById("globalAudioBar"); if (bar) bar.style.display = "flex"; const titleEl = document.getElementById("gaTitle"); if (titleEl) titleEl.textContent = title || "Аудио"; } function hideGlobalAudioBar() { const bar = document.getElementById("globalAudioBar"); if (bar) bar.style.display = "none"; } function updateGlobalAudioBarProgress() { const fill = document.getElementById("gaProgressFill"); const buffered = document.getElementById("gaProgressBuffered"); const timeEl = document.getElementById("gaTime"); if (!fill || !timeEl) return; if (globalAudioEl.duration) { fill.style.width = (globalAudioEl.currentTime / globalAudioEl.duration * 100) + "%"; if (buffered) buffered.style.width = getBufferedPercent(globalAudioEl) + "%"; } timeEl.textContent = formatDuration(globalAudioEl.currentTime); } function spinnerIconSvg() { return ''; } function getBufferedPercent(mediaEl) { if (!mediaEl.duration) return 0; const ranges = mediaEl.buffered; for (let i = 0; i < ranges.length; i++) { if (ranges.start(i) <= mediaEl.currentTime && mediaEl.currentTime <= ranges.end(i)) { return Math.min(100, (ranges.end(i) / mediaEl.duration) * 100); } } return 0; // текущая позиция вообще не в буфере } function setGlobalAudioBuffering(isBuffering) { globalAudioBuffering = isBuffering; const btn = document.getElementById("gaBtnPlay"); if (btn) { btn.innerHTML = isBuffering ? spinnerIconSvg() : (globalAudioEl.paused ? playIconSvg() : pauseIconSvg()); } if (globalAudioKey) { const entry = audioPlayerRegistry.get(globalAudioKey); if (entry) { if (isBuffering) { entry.btn.innerHTML = spinnerIconSvg(); } else { entry.btn.innerHTML = globalAudioEl.paused ? playIconSvg() : pauseIconSvg(); entry.btn.classList.toggle("playing", !globalAudioEl.paused); } } } } function playGlobalAudio(key, src, title, duration) { if (globalAudioKey !== key) { if (globalAudioKey) resetInlinePlayerVisual(globalAudioKey); globalAudioEl.src = src; globalAudioEl.playbackRate = GLOBAL_AUDIO_SPEEDS[globalAudioSpeedIdx]; globalAudioKey = key; } globalAudioEl.play(); showGlobalAudioBar(title); } function toggleGlobalAudioForKey(key, src, title) { if (globalAudioKey === key && !globalAudioEl.paused) { globalAudioEl.pause(); } else { playGlobalAudio(key, src, title); } } function stopGlobalAudio() { if (globalAudioKey) resetInlinePlayerVisual(globalAudioKey); globalAudioEl.pause(); globalAudioEl.currentTime = 0; globalAudioEl.removeAttribute("src"); globalAudioKey = null; hideGlobalAudioBar(); } globalAudioEl.addEventListener("timeupdate", function () { updateGlobalAudioBarProgress(); if (globalAudioKey) updateInlinePlayerProgress(globalAudioKey); }); globalAudioEl.addEventListener("progress", function () { updateGlobalAudioBarProgress(); }); globalAudioEl.addEventListener("waiting", function () { setGlobalAudioBuffering(true); }); globalAudioEl.addEventListener("stalled", function () { setGlobalAudioBuffering(true); }); globalAudioEl.addEventListener("canplay", function () { setGlobalAudioBuffering(false); }); globalAudioEl.addEventListener("playing", function () { setGlobalAudioBuffering(false); }); globalAudioEl.addEventListener("play", function () { if (globalAudioBuffering) return; // иконку сменит canplay/playing const btn = document.getElementById("gaBtnPlay"); if (btn) btn.innerHTML = pauseIconSvg(); if (globalAudioKey) setInlinePlayerPlayingVisual(globalAudioKey, true); }); globalAudioEl.addEventListener("pause", function () { if (globalAudioBuffering) return; const btn = document.getElementById("gaBtnPlay"); if (btn) btn.innerHTML = playIconSvg(); if (globalAudioKey) setInlinePlayerPlayingVisual(globalAudioKey, false); }); globalAudioEl.addEventListener("ended", function () { if (globalAudioKey) resetInlinePlayerVisual(globalAudioKey); const btn = document.getElementById("gaBtnPlay"); if (btn) btn.innerHTML = playIconSvg(); globalAudioEl.currentTime = 0; updateGlobalAudioBarProgress(); }); // ── Инлайн-плеер аудио-сообщений (кнопка + waveform + время) ── // key — уникальный идентификатор конкретного вложения (для синхронизации // с единым глобальным Audio-плеером под шапкой чата) function buildAudioPlayer(att, key) { const wrap = document.createElement("div"); wrap.className = "msgAudioPlayer"; const btn = document.createElement("button"); btn.className = "msgAudioPlayBtn"; btn.type = "button"; btn.innerHTML = playIconSvg(); wrap.appendChild(btn); const body = document.createElement("div"); body.className = "msgAudioBody"; const waveWrap = document.createElement("div"); waveWrap.className = "msgAudioWave"; const peaks = (att.waveform && att.waveform.length) ? att.waveform : new Array(42).fill(0.35); const bars = peaks.map(function (p) { const bar = document.createElement("div"); bar.className = "msgAudioWaveBar"; bar.style.height = Math.round(8 + p * 22) + "px"; waveWrap.appendChild(bar); return bar; }); body.appendChild(waveWrap); const timeEl = document.createElement("div"); timeEl.className = "msgAudioTime"; timeEl.textContent = formatDuration(att.duration || 0); body.appendChild(timeEl); wrap.appendChild(body); registerAudioPlayerEntry(key, { bars: bars, btn: btn, timeEl: timeEl, duration: att.duration || 0 }); // Если этот трек уже играет (например, сообщение перерисовалось) — сразу // отражаем актуальное состояние вместо начального "не играет" if (globalAudioKey === key) { setInlinePlayerPlayingVisual(key, !globalAudioEl.paused); updateInlinePlayerProgress(key); } btn.onclick = function () { toggleGlobalAudioForKey(key, "attachment/" + att.data + "/" + wsId, att.name || "Аудио"); }; function seekFromEvent(e) { if (globalAudioKey !== key || !globalAudioEl.duration) return; const rect = waveWrap.getBoundingClientRect(); const clientX = e.touches ? e.touches[0].clientX : e.clientX; const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); globalAudioEl.currentTime = ratio * globalAudioEl.duration; updateInlinePlayerProgress(key); } waveWrap.addEventListener("mousedown", function (e) { if (globalAudioKey !== key) { toggleGlobalAudioForKey(key, "attachment/" + att.data + "/" + wsId, att.name || "Аудио"); } seekFromEvent(e); function onMove(ev) { seekFromEvent(ev); } function onUp() { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); } document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }); waveWrap.addEventListener("touchstart", function (e) { seekFromEvent(e); }, { passive: true }); return wrap; } function registerAudioPlayerEntry(key, entry) { audioPlayerRegistry.set(key, entry); } function renderAttachmentPreviews() { const bar = document.getElementById("attachPreview"); updateSendButtonIcon(); if (!pendingAttachments.length) { bar.style.display = "none"; bar.innerHTML = ""; return; } bar.style.display = "flex"; bar.innerHTML = ""; pendingAttachments.forEach(function (att, idx) { const chip = document.createElement("div"); chip.className = "attach-chip"; const thumb = document.createElement("div"); thumb.className = "attach-chip-thumb"; if (att.type === "image" && att.previewUrl) { thumb.style.backgroundImage = "url('" + att.previewUrl + "')"; } else if (att.type === "video") { thumb.style.backgroundImage = "url('" + (att.localThumbUrl || "") + "')"; thumb.innerHTML = '' + playIconSvg() + ''; } else if (att.type === "audio") { thumb.innerHTML = audioIconSvg(); } else { thumb.innerHTML = fileIconSvg(); } chip.appendChild(thumb); const removeBtn = document.createElement("button"); removeBtn.className = "attach-chip-remove"; removeBtn.type = "button"; removeBtn.title = "Убрать"; removeBtn.innerHTML = ''; removeBtn.onclick = function () { const removed = pendingAttachments.splice(idx, 1)[0]; if (removed && removed.previewUrl) URL.revokeObjectURL(removed.previewUrl); renderAttachmentPreviews(); }; chip.appendChild(removeBtn); if (att.type !== "image" && att.type !== "video") { const nameTag = document.createElement("div"); nameTag.className = "attach-chip-name"; nameTag.textContent = att.name || "Файл"; chip.appendChild(nameTag); } bar.appendChild(chip); }); if (pendingAttachments.length >= MAX_ATTACHMENTS) { const cap = document.createElement("div"); cap.className = "attach-chip-cap"; cap.textContent = MAX_ATTACHMENTS + "/" + MAX_ATTACHMENTS; bar.appendChild(cap); } } // ── Постановка файла в очередь на отправку ────────────────────────────── // ВАЖНО про память: раньше файл целиком читался через FileReader.readAsDataURL, // то есть весь файл + его base64-копия (в ~1.33 раза больше) одновременно // лежали в памяти вкладки — на видео 200+ МБ это и валило вкладку в OOM // (особенно в WebView с урезанным heap). Теперь дата-URL целиком нигде не // строится: для превью используется URL.createObjectURL(file) (нативная // ссылка на файл без копирования байтов в JS-память), а сама отправка на // сервер идёт кусками по 512 КБ через chunked-протокол (см. uploadFileChunked) — // в памяти в любой момент времени лежит максимум один такой кусок. async function stageFileForSending(file) { if (!file) return; if (pendingAttachments.length >= MAX_ATTACHMENTS) { toastWarning("Максимум " + MAX_ATTACHMENTS + " вложений в одно сообщение"); return; } const isImage = file.type.startsWith("image/"); const isVideo = file.type.startsWith("video/"); const isAudio = file.type.startsWith("audio/"); if (file.size > MAX_ATTACHMENT_BYTES) { toastWarning("Файл слишком большой (максимум " + formatFileSize(MAX_ATTACHMENT_BYTES) + ")"); return; } let extra = {}; let uploadBlob = file; // то, что реально уйдёт на сервер по кускам let previewUrl = null; // blob-URL картинки для мгновенного превью let localThumbUrl = null; // маленький dataURL-постер для видео try { if (isImage) { const objectUrl = URL.createObjectURL(file); // Сжимаем крупные фото перед отправкой (как по умолчанию делает // Telegram) — работаем через blob-URL, не через FileReader, чтобы // не тащить в память сразу оригинал и его base64-копию. const compressedDataUrl = await compressImageForSend(objectUrl, 1600); uploadBlob = await (await fetch(compressedDataUrl)).blob(); URL.revokeObjectURL(objectUrl); previewUrl = URL.createObjectURL(uploadBlob); const dims = await getImageDimensions(previewUrl); if (dims.width && dims.height) extra = { width: dims.width, height: dims.height }; } else if (isVideo) { const objectUrl = URL.createObjectURL(file); const info = await generateVideoThumbnail(objectUrl); URL.revokeObjectURL(objectUrl); if (info) { extra = { duration: info.duration, width: info.width, height: info.height }; localThumbUrl = info.thumb; } } else if (isAudio) { // Waveform декодируется через Web Audio из ArrayBuffer — для // аудио файлов разумного размера это не проблема; для очень // больших пропускаем (флэт-заглушка внутри generateWaveformPeaks). const waveformResult = file.size <= 30 * 1024 * 1024 ? await generateWaveformPeaks(file, 42) : null; extra = { waveform: waveformResult ? waveformResult.peaks : [], duration: waveformResult ? waveformResult.duration : 0 }; } } catch (e) { console.error("stageFileForSending preview error", e); } if (pendingAttachments.length >= MAX_ATTACHMENTS) { toastWarning("Максимум " + MAX_ATTACHMENTS + " вложений в одно сообщение"); if (previewUrl) URL.revokeObjectURL(previewUrl); return; } const att = Object.assign({ localId: genLocalId(), type: isImage ? "image" : isVideo ? "video" : isAudio ? "audio" : "file", name: file.name, mime: uploadBlob.type || file.type || "application/octet-stream", size: uploadBlob.size, file: uploadBlob, previewUrl: previewUrl, localThumbUrl: localThumbUrl, progress: 0, status: "pending" }, extra); pendingAttachments.push(att); renderAttachmentPreviews(); document.getElementById("msg").focus(); } // Ставит в очередь сразу несколько файлов (из диалога выбора или drag&drop), // последовательно — чтобы не превысить лимит и корректно считать оставшиеся места async function stageMultipleFiles(fileList) { const files = Array.from(fileList || []); for (const file of files) { if (pendingAttachments.length >= MAX_ATTACHMENTS) { toastWarning("Максимум " + MAX_ATTACHMENTS + " вложений в одно сообщение"); break; } await stageFileForSending(file); } } // ── Чанкованная загрузка вложений ─────────────────────────────────────── // Кодируем в base64 и шлём по одному куску за раз (Blob.slice → маленький // кусок в памяти, не весь файл), дожидаясь ack сервера перед следующим — // это одновременно и backpressure, и источник реального % прогресса. function blobToBase64(blob) { return new Promise(function (resolve, reject) { const reader = new FileReader(); reader.onload = function () { resolve(String(reader.result).split(",")[1] || ""); }; reader.onerror = reject; reader.readAsDataURL(blob); }); } async function uploadFileChunked(att, chatId, onProgress) { const attId = genLocalId(); const blob = att.file; const total = blob.size; send("attach_begin", { attId: attId, chatId: chatId, mime: att.mime, size: total, name: att.name }); const begin = await waitForAttachMsg("attach_begin_result", attId); if (!begin || !begin.ok) throw new Error("attach_begin failed"); let offset = 0; while (offset < total) { const end = Math.min(offset + ATTACH_CHUNK_SIZE, total); const chunkBlob = blob.slice(offset, end); const base64 = await blobToBase64(chunkBlob); send("attach_chunk", { attId: attId, offset: offset, data: base64 }); const ack = await waitForAttachMsg("attach_chunk_ack", attId); if (!ack || !ack.ok) throw new Error("attach_chunk failed"); offset = end; if (onProgress) onProgress(offset / total); } send("attach_end", { attId: attId }); const endResult = await waitForAttachMsg("attach_end_result", attId); if (!endResult || !endResult.ok) throw new Error("attach_end failed"); return attId; } // Маленькое вложение (превью видео) — одним куском, без прогресса async function uploadSmallDataUrl(dataUrl, chatId, mime) { const attId = genLocalId(); const base64 = dataUrl.split(",")[1] || ""; send("attach_small", { attId: attId, chatId: chatId, mime: mime || "image/jpeg", data: base64 }); const result = await waitForAttachMsg("attach_small_result", attId); if (!result || !result.ok) throw new Error("attach_small failed"); return attId; } // ── Оркестрация: грузим все вложения сообщения (с живым прогрессом в уже // нарисованном пузыре), потом шлём send_message с готовыми id вложений. // clientId уже присутствует в отрисованном локально сообщении — сервер // вернёт его же в широковещательном "message", и finalizeLocalMessage // подменит "отправляется" на "отправлено". async function uploadAndSendLocalMessage(clientId, chatId, text, attachments) { try { const uploaded = []; for (const att of attachments) { // При повторной попытке (retrySendMessage) уже успешно загруженные // вложения не грузим заново — используем ранее полученные id. let attId = att.data || null; let thumbAttId = att.thumb || null; if (att.file && !attId) { att.status = "uploading"; attId = await uploadFileChunked(att, chatId, function (ratio) { updateLocalAttachmentProgress(att, ratio); }); att.status = "done"; att.data = attId; } if (att.localThumbUrl && !thumbAttId) { thumbAttId = await uploadSmallDataUrl(att.localThumbUrl, chatId, "image/jpeg"); att.thumb = thumbAttId; } uploaded.push({ type: att.type, name: att.name, mime: att.mime, size: att.size, width: att.width, height: att.height, duration: att.duration, waveform: att.waveform, round: att.round || undefined, data: attId, thumb: thumbAttId }); } send("send_message", { chatId: chatId, text: text, clientId: clientId, attachments: uploaded.length ? uploaded : undefined }); // Страховка: сервер не шлёт отдельный ack на send_message — подтверждением // служит широковещательный "message" с тем же clientId. Если за разумное // время он не пришёл (отвалился сокет, сервер отклонил запрос и т.п.) — // не оставляем часы крутиться вечно, помечаем как неотправленное. setTimeout(function () { if (pendingLocalMessages.has(clientId)) markLocalMessageFailed(clientId); }, 45000); } catch (e) { console.error("uploadAndSendLocalMessage error", e); markLocalMessageFailed(clientId); } } // ── Пикер эмодзи ────────────────────────────────────────────── const EMOJI_CATEGORIES = [ { icon: "😀", items: "😀 😁 😂 🤣 😊 😇 🙂 🙃 😉 😌 😍 🥰 😘 😗 😙 😚 😋 😛 😝 😜 🤪 🤨 🧐 🤓 😎 🥳 😏 😒 😞 😔 😟 😕 🙁 😣 😖 😫 😩 🥺 😢 😭 😤 😠 😡 🤬 🤯 😳 🥵 🥶 😱 😨 😰 😥 😓 🤗 🤔 🤭 🤫 🤥 😶 😐 😑 😬 🙄 😯 😦 😧 😮 😲 🥱 😴 🤤 😪 😵 🤐 🥴 🤢 🤮 🤧 😷 🤒 🤕 🤡 👺 👹 👻 💀 👽 👾 🤖 😈 👿 💩 👍 👎 👌 🤌 🤏 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 👇 ☝️ 👋 🤚 🖐️ ✋ 🖖 👏 🙌 🤝 🙏 ✍️ 💅 🤳 💪 👄 💋 👅 👂 👃 🧠 👁️ 👀".split(" ") }, { icon: "🌿", items: "🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐻❄️ 🐨 🐯 🦁 🐮 🐷 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🦆 🦅 🦉 🦇 🐺 🐗 🐴 🦄 🐝 🐛 🦋 🐌 🐞 🐜 🦟 🦗 🕷️ 🕸️ 🐢 🐍 🦎 🦖 🦕 🐙 🦑 🦐 🦞 🦀 🐡 🐠 🐟 🐬 🐳 🐋 🦈 🐊 🐆 🦓 🦍 🦧 🦣 🐘 🦛 🦏 🐪 🐫 🦒 🦘 🐃 🐂 🐄 🐎 🐖 🐑 🐐 🦌 🐕 🐩 🐈 🐓 🦃 🦤 🦚 🦜 🦢 🦩 🦨 🦡 🦫 🦦 🦥 🐁 🐀 🐿️ 🦔 🌵 🎄 🌲 🌳 🌴 🪵 🌱 🌿 ☘️ 🍀 🎍 🪴 🎋 🍃 🍂 🍁 🍄 🐚 🪨 🌍 🌎 🌏 🪐 💫 ⭐ 🌟 ✨ ⚡ ☄️ 🔥 🌈 ☀️ 🌤️ ⛅ 🌥️ ☁️ 🌦️ 🌧️ ⛈️ 🌩️ 🌨️ ❄️ ☃️ ⛄ 🌬️ 💨 🌪️ 🌫️ 🌊 💧 💦 ☔ 🌙 🌛 🌜 🌚 🌝 🌕 🌖 🌗 🌘 🌑 🌒 🌓 🌔".split(" ") }, { icon: "🍔", items: "🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🥦 🥬 🥒 🌶️ 🫑 🌽 🥕 🧄 🧅 🥔 🍠 🥐 🥯 🍞 🥖 🥨 🧀 🥚 🍳 🧈 🥞 🧇 🥓 🥩 🍗 🍖 🌭 🍔 🍟 🍕 🫓 🥪 🥙 🧆 🌮 🌯 🫔 🥗 🥘 🫕 🥫 🍝 🍜 🍲 🍛 🍣 🍱 🥟 🦪 🍤 🍙 🍚 🍘 🍥 🥠 🥮 🍢 🍡 🍧 🍨 🍦 🥧 🧁 🍰 🎂 🍮 🍭 🍬 🍫 🍿 🧂 🥜 🍯 🥛 🍼 ☕ 🍵 🧃 🥤 🧋 🍶 🍺 🍻 🥂 🍷 🥃 🍸 🍹 🧉 🍾".split(" ") }, { icon: "⚽", items: "⚽ 🏀 🏈 ⚾ 🥎 🎾 🏐 🏉 🥏 🎱 🪀 🏓 🏸 🏒 🏑 🥍 🏏 🥅 ⛳ 🪁 🏹 🎣 🤿 🥊 🥋 🎽 🛹 🛷 ⛸️ 🥌 🎿 ⛷️ 🏂 🪂 🏋️ 🤼 🤸 ⛹️ 🤺 🤾 🏌️ 🏇 🧘 🏄 🏊 🤽 🚣 🧗 🚵 🚴 🏆 🥇 🥈 🥉 🏅 🎖️ 🏵️ 🎗️ 🎯 🪃 🎮 🎲 🧩 🃏 🀄 🎴 🎳".split(" ") }, { icon: "🚗", items: "🚗 🚕 🚙 🚌 🚎 🏎️ 🚓 🚑 🚒 🚐 🛻 🚚 🚛 🚜 🦯 🦽 🦼 🛴 🚲 🛵 🏍️ 🛺 🚨 🚔 🚍 🚘 🚖 🚡 🚠 🚟 🚃 🚋 🚞 🚝 🚄 🚅 🚈 🚂 🚆 🚇 🚊 🚉 ✈️ 🛫 🛬 🛩️ 💺 🛰️ 🚀 🛸 🚁 🛶 ⛵ 🚤 🛥️ 🛳️ ⛴️ 🚢 ⚓ 🪝 ⛽ 🚧 🚦 🚥 🚏 🗺️ 🗿 🎡 🎢 🎠".split(" ") }, { icon: "❤️", items: "❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓ ⛎ 💑 💏 💌 💒 💍 🌹 🥰".split(" ") }, { icon: "🏁", items: "🇦🇩 🇦🇪 🇦🇫 🇦🇬 🇦🇮 🇦🇱 🇦🇲 🇦🇴 🇦🇷 🇦🇹 🇦🇺 🇦🇿 🇧🇦 🇧🇧 🇧🇩 🇧🇪 🇧🇫 🇧🇬 🇧🇭 🇧🇮 🇧🇯 🇧🇳 🇧🇴 🇧🇷 🇧🇸 🇧🇹 🇧🇼 🇧🇾 🇧🇿 🇨🇦 🇨🇩 🇨🇫 🇨🇬 🇨🇭 🇨🇮 🇨🇱 🇨🇲 🇨🇳 🇨🇴 🇨🇷 🇨🇺 🇨🇻 🇨🇾 🇨🇿 🇩🇪 🇩🇯 🇩🇰 🇩🇲 🇩🇴 🇩🇿 🇪🇨 🇪🇪 🇪🇬 🇪🇷 🇪🇸 🇪🇹 🇫🇮 🇫🇯 🇫🇲 🇫🇷 🇬🇦 🇬🇧 🇬🇩 🇬🇪 🇬🇭 🇬🇮 🇬🇱 🇬🇲 🇬🇳 🇬🇶 🇬🇷 🇬🇹 🇬🇼 🇬🇾 🇭🇰 🇭🇳 🇭🇷 🇭🇹 🇭🇺 🇮🇩 🇮🇪 🇮🇱 🇮🇳 🇮🇶 🇮🇷 🇮🇸 🇮🇹 🇯🇲 🇯🇴 🇯🇵 🇰🇪 🇰🇬 🇰🇭 🇰🇮 🇰🇲 🇰🇳 🇰🇵 🇰🇷 🇰🇼 🇰🇾 🇰🇿 🇱🇦 🇱🇧 🇱🇨 🇱🇮 🇱🇰 🇱🇷 🇱🇸 🇱🇹 🇱🇺 🇱🇻 🇱🇾 🇲🇦 🇲🇨 🇲🇩 🇲🇪 🇲🇬 🇲🇭 🇲🇰 🇲🇱 🇲🇲 🇲🇳 🇲🇷 🇲🇹 🇲🇺 🇲🇻 🇲🇼 🇲🇽 🇲🇾 🇲🇿 🇳🇦 🇳🇪 🇳🇬 🇳🇮 🇳🇱 🇳🇴 🇳🇵 🇳🇷 🇳🇿 🇴🇲 🇵🇦 🇵🇪 🇵🇬 🇵🇭 🇵🇰 🇵🇱 🇵🇸 🇵🇹 🇵🇼 🇵🇾 🇶🇦 🇷🇴 🇷🇸 🇷🇺 🇷🇼 🇸🇦 🇸🇧 🇸🇨 🇸🇩 🇸🇪 🇸🇬 🇸🇮 🇸🇰 🇸🇱 🇸🇲 🇸🇳 🇸🇴 🇸🇷 🇸🇸 🇸🇹 🇸🇻 🇸🇾 🇸🇿 🇹🇩 🇹🇬 🇹🇭 🇹🇯 🇹🇱 🇹🇲 🇹🇳 🇹🇴 🇹🇷 🇹🇹 🇹🇻 🇹🇼 🇹🇿 🇺🇦 🇺🇬 🇺🇸 🇺🇾 🇺🇿 🇻🇦 🇻🇨 🇻🇪 🇻🇳 🇻🇺 🇼🇸 🇾🇪 🇿🇦 🇿🇲 🇿🇼".split(" ") } ]; let emojiPickerBuilt = false; function buildEmojiPicker() { if (emojiPickerBuilt) return; const picker = document.getElementById("emojiPicker"); const tabs = document.createElement("div"); tabs.className = "emoji-picker-tabs"; const grid = document.createElement("div"); grid.className = "emoji-picker-grid"; function renderCategory(idx) { grid.innerHTML = ""; EMOJI_CATEGORIES[idx].items.forEach(function (emo) { const btn = document.createElement("button"); btn.className = "emoji-picker-item"; btn.type = "button"; btn.textContent = emo; btn.onclick = function () { insertEmoji(emo); }; grid.appendChild(btn); }); } EMOJI_CATEGORIES.forEach(function (cat, idx) { const tabBtn = document.createElement("button"); tabBtn.className = "emoji-picker-tab" + (idx === 0 ? " active" : ""); tabBtn.type = "button"; tabBtn.textContent = cat.icon; tabBtn.onclick = function () { tabs.querySelectorAll(".emoji-picker-tab").forEach(function (t) { t.classList.remove("active"); }); tabBtn.classList.add("active"); renderCategory(idx); }; tabs.appendChild(tabBtn); }); picker.appendChild(tabs); picker.appendChild(grid); renderCategory(0); emojiPickerBuilt = true; } function insertEmoji(emoji) { const input = document.getElementById("msg"); const start = input.selectionStart || input.value.length; const end = input.selectionEnd || input.value.length; input.value = input.value.slice(0, start) + emoji + input.value.slice(end); const pos = start + emoji.length; input.setSelectionRange(pos, pos); } function toggleEmojiPicker(show) { const picker = document.getElementById("emojiPicker"); if (show === undefined) show = picker.style.display === "none"; if (show) { buildEmojiPicker(); picker.style.display = "flex"; } else { picker.style.display = "none"; } } function toggleAttachMenu(show) { const menu = document.getElementById("attachMenu"); if (show === undefined) show = menu.style.display === "none"; menu.style.display = show ? "flex" : "none"; } // ── Медиа-вьюер: изображения + видео, листание, скачивание ─── let mediaViewerItems = []; let mediaViewerIndex = 0; let mediaViewerVideoCleanup = null; function openMediaViewer(items, index) { mediaViewerItems = items || []; mediaViewerIndex = index || 0; if (!mediaViewerItems.length) return; document.getElementById("mediaViewer").style.display = "flex"; renderMediaViewerItem(); } function closeMediaViewer() { if (mediaViewerVideoCleanup) { mediaViewerVideoCleanup(); mediaViewerVideoCleanup = null; } document.getElementById("mediaViewer").style.display = "none"; document.getElementById("mediaViewerContent").innerHTML = ""; mediaViewerItems = []; } function navigateMediaViewer(delta) { if (mediaViewerItems.length < 2) return; mediaViewerIndex = (mediaViewerIndex + delta + mediaViewerItems.length) % mediaViewerItems.length; renderMediaViewerItem(); } function renderMediaViewerItem() { if (mediaViewerVideoCleanup) { mediaViewerVideoCleanup(); mediaViewerVideoCleanup = null; } const content = document.getElementById("mediaViewerContent"); content.innerHTML = ""; const att = mediaViewerItems[mediaViewerIndex]; if (!att) return; if (att.type === "image") { const img = document.createElement("img"); img.className = "media-viewer-image"; img.src = "attachment/" + att.data + "/" + wsId; content.appendChild(img); } else if (att.type === "video") { content.appendChild(buildMediaViewerVideoPlayer(att, function (cleanup) { mediaViewerVideoCleanup = cleanup; })); } // Счётчик "2 / 5" const counter = document.getElementById("mediaViewerCounter"); if (mediaViewerItems.length > 1) { counter.style.display = "block"; counter.textContent = (mediaViewerIndex + 1) + " / " + mediaViewerItems.length; } else { counter.style.display = "none"; } // Стрелки — показываем только когда есть что листать const showArrows = mediaViewerItems.length > 1; document.getElementById("mediaViewerPrev").classList.toggle("visible", showArrows); document.getElementById("mediaViewerNext").classList.toggle("visible", showArrows); } function downloadCurrentMediaItem() { const att = mediaViewerItems[mediaViewerIndex]; if (!att) return; const link = document.createElement("a"); link.href = "attachment/" + att.data + "/" + wsId; link.download = att.name || ("media." + (att.type === "video" ? "mp4" : "jpg")); document.body.appendChild(link); link.click(); link.remove(); } // Кастомный видео-плеер для вьюера: play/pause, перемотка, время function buildMediaViewerVideoPlayer(att, registerCleanup) { const wrap = document.createElement("div"); wrap.className = "media-viewer-video-wrap"; const video = document.createElement("video"); video.className = "media-viewer-video"; video.src = "attachment/" + att.data + "/" + wsId; video.playsInline = true; if (att.thumb) video.poster = "attachment/" + att.thumb + "/" + wsId; wrap.appendChild(video); const controls = document.createElement("div"); controls.className = "video-player-controls"; const playBtn = document.createElement("button"); playBtn.className = "video-player-play"; playBtn.type = "button"; playBtn.innerHTML = playIconSvg(); controls.appendChild(playBtn); const seekWrap = document.createElement("div"); seekWrap.className = "video-player-seek"; const seekBuffered = document.createElement("div"); seekBuffered.className = "video-player-seek-buffered"; seekWrap.appendChild(seekBuffered); const seekFill = document.createElement("div"); seekFill.className = "video-player-seek-fill"; seekWrap.appendChild(seekFill); controls.appendChild(seekWrap); const timeEl = document.createElement("div"); timeEl.className = "video-player-time"; timeEl.textContent = "0:00 / " + formatDuration(att.duration || 0); controls.appendChild(timeEl); const speedBtn = document.createElement("button"); speedBtn.className = "video-player-speed"; speedBtn.type = "button"; let speedIdx = GLOBAL_AUDIO_SPEEDS.indexOf(1); speedBtn.textContent = "1x"; controls.appendChild(speedBtn); const volWrap = document.createElement("div"); volWrap.className = "video-player-volume"; volWrap.innerHTML = ''; const volSlider = document.createElement("input"); volSlider.type = "range"; volSlider.min = "0"; volSlider.max = "1"; volSlider.step = "0.05"; volSlider.value = String(video.volume != null ? video.volume : 1); volWrap.appendChild(volSlider); controls.appendChild(volWrap); wrap.appendChild(controls); // Большая play-кнопка по центру, пока видео не запущено const bigPlay = document.createElement("button"); bigPlay.className = "video-player-big-play"; bigPlay.type = "button"; bigPlay.innerHTML = playIconSvg(); wrap.appendChild(bigPlay); const bufferingOverlay = document.createElement("div"); bufferingOverlay.className = "video-player-buffering"; bufferingOverlay.innerHTML = ''; wrap.appendChild(bufferingOverlay); function togglePlay() { if (video.paused) video.play(); else video.pause(); } playBtn.onclick = togglePlay; bigPlay.onclick = togglePlay; video.onclick = togglePlay; function onPlay() { playBtn.innerHTML = pauseIconSvg(); bigPlay.classList.add("hidden"); } function onPause() { playBtn.innerHTML = playIconSvg(); bigPlay.classList.remove("hidden"); } function onTimeUpdate() { if (!video.duration) return; seekFill.style.width = (video.currentTime / video.duration * 100) + "%"; timeEl.textContent = formatDuration(video.currentTime) + " / " + formatDuration(video.duration); } function onProgress() { if (!video.duration) return; seekBuffered.style.width = getBufferedPercent(video) + "%"; } function onWaiting() { bufferingOverlay.classList.add("visible"); } function onCanPlay() { bufferingOverlay.classList.remove("visible"); } function onPlaying() { bufferingOverlay.classList.remove("visible"); } function seekFromEvent(e) { if (!video.duration) return; const rect = seekWrap.getBoundingClientRect(); const clientX = e.touches ? e.touches[0].clientX : e.clientX; const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); video.currentTime = ratio * video.duration; } seekWrap.addEventListener("mousedown", function (e) { seekFromEvent(e); function onMove(ev) { seekFromEvent(ev); } function onUp() { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); } document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }); video.addEventListener("play", onPlay); video.addEventListener("pause", onPause); video.addEventListener("timeupdate", onTimeUpdate); video.addEventListener("progress", onProgress); video.addEventListener("waiting", onWaiting); video.addEventListener("canplay", onCanPlay); video.addEventListener("playing", onPlaying); speedBtn.onclick = function () { speedIdx = (speedIdx + 1) % GLOBAL_AUDIO_SPEEDS.length; const speed = GLOBAL_AUDIO_SPEEDS[speedIdx]; video.playbackRate = speed; speedBtn.textContent = speed + "x"; }; volSlider.oninput = function () { video.volume = parseFloat(volSlider.value); }; registerCleanup(function () { video.pause(); video.removeEventListener("progress", onProgress); video.removeEventListener("waiting", onWaiting); video.removeEventListener("canplay", onCanPlay); video.removeEventListener("playing", onPlaying); video.src = ""; }); bufferingOverlay.classList.add("visible"); video.play().catch(function () { // автоплей заблокирован браузером (например, звук+без жеста) — // остаёмся на паузе, пользователь запустит большой play вручную bufferingOverlay.classList.remove("visible"); }); return wrap; } // ── Единая кнопка отправки/записи + запись голосовых и видео-сообщений ── // Есть что отправить (текст/вложения) — кнопка это стрелка "отправить". // Пусто — кнопка это микрофон/камера: короткий тап переключает режим // (голосовое ↔ видео), долгое удержание запускает запись. Дальше уже жест // "как в Telegram": отпустили — отправка, провели влево — отмена, провели // вверх — блокировка (можно отпустить палец, трэш/отправка становятся // обычными кнопками). Если запись идёт дольше минуты — текущий сегмент // молча дозаписывается и уходит отдельным сообщением, а запись продолжается // новым сегментом на том же медиапотоке (см. VOICE_MAX_SEGMENT_MS) — без // остановки для пользователя. Если запись была явно остановлена уже ПОСЛЕ // блокировки (кнопкой-галочкой, а не отпусканием) — перед отправкой // показываем редактор с обрезкой и предпрослушиванием готового ролика. const VOICE_MAX_SEGMENT_MS = 60000; const REC_CANCEL_THRESHOLD = 80; // px влево — отмена const REC_LOCK_THRESHOLD = 60; // px вверх — блокировка const REC_HOLD_DELAY_MS = 220; // если отпустили раньше — это тап (переключение режима), не запись const REC_TRIM_MIN_GAP = 0.4; // сек — минимальная длина выделения при обрезке const SEND_ICON_SVG = ''; const MIC_ICON_SVG = ''; const VIDEO_MODE_ICON_SVG = ''; let currentRecordMode = "audio"; // 'audio' | 'video' — что выберет удержание кнопки прямо сейчас let recActive = false; // общая "сессия" записи (может состоять из нескольких сегментов) let recManualStop = false; // true = пользователь сам остановил (не авто-обрезка по таймеру) let recCancelled = false; let recLocked = false; let recPendingEdit = false; // true = после остановки нужно открыть редактор (обрезка/прослушивание), а не сразу слать let recType = null; // 'audio' | 'video' let recStream = null; let recRecorder = null; let recChunks = []; let recMime = null; let recChatId = null; let recSegmentStartedAt = 0; let recTotalStartedAt = 0; let recTimerInterval = null; let recSegmentTimer = null; let recHeldBtn = null; // Жест удержания на кнопке let sendBtnPointerId = null; let sendBtnDownX = 0, sendBtnDownY = 0; let sendBtnHoldTimer = null; let sendBtnHoldFired = false; // true как только удержание переросло в реальную запись // Редактор (обрезка + предпрослушивание) готовой записи let recEditorBlob = null; let recEditorKind = null; let recEditorChatId = null; let recEditorDuration = 0; let recEditorTrimStart = 0; let recEditorTrimEnd = 0; let recEditorMediaEl = null; let recEditorObjectUrl = null; let recEditorDragHandle = null; // 'start' | 'end' во время перетаскивания function hasSendableContent() { const textBox = document.getElementById("msg"); return (textBox && textBox.value.trim().length > 0) || pendingAttachments.length > 0; } function pickRecorderMime(kind) { if (!window.MediaRecorder || !MediaRecorder.isTypeSupported) return ""; const candidates = kind === "video" ? ["video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"] : ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus"]; for (const c of candidates) { if (MediaRecorder.isTypeSupported(c)) return c; } return ""; } function computeWaveformPeaksFromBuffer(audioBuffer, barCount) { const channelData = audioBuffer.getChannelData(0); const blockSize = Math.max(1, Math.floor(channelData.length / barCount)); const peaks = []; for (let i = 0; i < barCount; i++) { let sum = 0; const start = i * blockSize; for (let j = 0; j < blockSize; j++) { const v = channelData[start + j]; if (v !== undefined) sum += Math.abs(v); } peaks.push(sum / blockSize); } const max = Math.max.apply(null, peaks) || 1; return peaks.map(function (p) { return Math.max(0.08, p / max); }); } // ── Единая кнопка: тап переключает режим, удержание запускает запись ──── function bindSendButtonGestures(btn) { if (!btn) return; btn.addEventListener("pointerdown", function (e) { if (e.button != null && e.button !== 0) return; // только основная кнопка мыши if (recActive) return; if (hasSendableContent()) { return; // обычная кнопка "отправить" — пусть сработает нативный click } e.preventDefault(); sendBtnPointerId = e.pointerId; sendBtnDownX = e.clientX; sendBtnDownY = e.clientY; sendBtnHoldFired = false; try { btn.setPointerCapture(e.pointerId); } catch (err) {} clearTimeout(sendBtnHoldTimer); sendBtnHoldTimer = setTimeout(function () { if (sendBtnPointerId == null) return; // уже отпустили раньше срабатывания таймера sendBtnHoldFired = true; beginRecordingGesture(currentRecordMode, btn); }, REC_HOLD_DELAY_MS); }); btn.addEventListener("pointermove", function (e) { if (e.pointerId !== sendBtnPointerId || !sendBtnHoldFired) return; if (!recActive || recLocked) return; const dx = e.clientX - sendBtnDownX; const dy = e.clientY - sendBtnDownY; updateRecSlideVisual(dx, dy); if (-dy > REC_LOCK_THRESHOLD) { lockRecording(); return; } if (-dx > REC_CANCEL_THRESHOLD) { try { btn.releasePointerCapture(e.pointerId); } catch (err) {} sendBtnPointerId = null; cancelRecording(); } }); function onEnd(e) { if (e.pointerId !== sendBtnPointerId) return; clearTimeout(sendBtnHoldTimer); const wasHold = sendBtnHoldFired; sendBtnPointerId = null; if (!wasHold) { // Короткий тап без записи — переключаем режим голосовое ↔ видео toggleRecordMode(); return; } if (recActive && !recLocked) stopRecordingAndSend(); } btn.addEventListener("pointerup", onEnd); btn.addEventListener("pointercancel", function (e) { if (e.pointerId !== sendBtnPointerId) return; clearTimeout(sendBtnHoldTimer); sendBtnPointerId = null; if (sendBtnHoldFired && recActive && !recLocked) cancelRecording(); }); btn.addEventListener("click", function () { if (hasSendableContent()) sendCurrentMessage(); }); } function toggleRecordMode() { currentRecordMode = currentRecordMode === "audio" ? "video" : "audio"; updateSendButtonIcon(); } function updateRecSlideVisual(dx, dy) { const hint = document.getElementById("recSlideHint"); if (hint) { const clampedDx = Math.min(0, Math.max(-REC_CANCEL_THRESHOLD, dx)); const ratio = Math.min(1, -clampedDx / REC_CANCEL_THRESHOLD); hint.style.transform = "translateX(" + clampedDx + "px)"; hint.style.opacity = String(1 - ratio * 0.7); } const lockHint = document.getElementById("recLockHint"); if (lockHint) { const clampedDy = Math.min(0, Math.max(-REC_LOCK_THRESHOLD, dy)); lockHint.style.transform = "translateY(" + clampedDy + "px)"; } } function lockRecording() { if (recLocked || !recActive) return; recLocked = true; if (recHeldBtn && sendBtnPointerId != null) { try { recHeldBtn.releasePointerCapture(sendBtnPointerId); } catch (err) {} } const lockHint = document.getElementById("recLockHint"); if (lockHint) { lockHint.classList.add("locked"); lockHint.innerHTML = ''; } if (recHeldBtn) recHeldBtn.style.display = "none"; document.getElementById("recSlideHint").style.display = "none"; document.getElementById("recLockedActions").style.display = "flex"; } // ── Старт/остановка/отмена сессии записи ───────────────────────────────── async function beginRecordingGesture(kind, btnEl) { if (!currentChatId) { toastWarning("Сначала откройте чат"); return; } if (recActive) return; if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder) { toastError("Запись не поддерживается этим браузером"); return; } recActive = true; recCancelled = false; recManualStop = false; recLocked = false; recPendingEdit = false; recType = kind; recChatId = currentChatId; showRecUI(kind, btnEl); const constraints = kind === "video" ? { audio: true, video: { width: { ideal: 480 }, height: { ideal: 480 }, aspectRatio: 1, facingMode: "user" } } : { audio: true }; let stream; try { stream = await navigator.mediaDevices.getUserMedia(constraints); } catch (e) { toastError("Нет доступа к " + (kind === "video" ? "камере" : "микрофону")); recActive = false; hideRecUI(); return; } if (recCancelled || recManualStop) { // Пользователь успел отменить/отпустить, пока браузер спрашивал разрешение stream.getTracks().forEach(function (t) { t.stop(); }); recActive = false; hideRecUI(); return; } recStream = stream; recTotalStartedAt = Date.now(); if (kind === "video") { const preview = document.getElementById("videoRecordPreview"); const overlay = document.getElementById("videoRecordOverlay"); if (preview) preview.srcObject = stream; if (overlay) overlay.style.display = "flex"; } startRecordingTimerUI(); startRecordSegment(); } function startRecordSegment() { recChunks = []; recMime = pickRecorderMime(recType); try { recRecorder = recMime ? new MediaRecorder(recStream, { mimeType: recMime }) : new MediaRecorder(recStream); } catch (e) { try { recRecorder = new MediaRecorder(recStream); } catch (e2) { toastError("Не удалось начать запись"); recCancelled = true; finishRecordingCleanup(); return; } } recSegmentStartedAt = Date.now(); recRecorder.ondataavailable = function (e) { if (e.data && e.data.size > 0) recChunks.push(e.data); }; recRecorder.onstop = function () { const blob = new Blob(recChunks, { type: recMime || (recType === "video" ? "video/webm" : "audio/webm") }); const durationSec = Math.max(0.3, (Date.now() - recSegmentStartedAt) / 1000); const wasCancelled = recCancelled; const wasManual = recManualStop; const wasEdit = recPendingEdit; const kind = recType; const chatId = recChatId; recChunks = []; recPendingEdit = false; if (wasEdit && !wasCancelled && blob.size > 0) { // Остановлено явной кнопкой после блокировки — не шлём сразу, // открываем редактор с обрезкой прямо там, где был live-превью. recActive = false; recLocked = false; clearTimeout(recSegmentTimer); stopRecordingTimerUI(); if (recStream) { recStream.getTracks().forEach(function (t) { t.stop(); }); recStream = null; } hideRecUI(); recRecorder = null; recType = null; recChatId = null; recHeldBtn = null; openRecordingEditor(blob, kind, durationSec, chatId); return; } if (!wasCancelled && blob.size > 0) { sendRecordedSegment(blob, kind, durationSec, chatId).catch(function (err) { console.error("sendRecordedSegment error", err); }); } if (recActive && !wasCancelled && !wasManual) { // Обрезка по таймауту (истекла минута) — запись продолжается новым сегментом startRecordSegment(); } else { finishRecordingCleanup(); } }; recRecorder.start(); clearTimeout(recSegmentTimer); recSegmentTimer = setTimeout(function () { if (recRecorder && recRecorder.state === "recording") recRecorder.stop(); }, VOICE_MAX_SEGMENT_MS); } function stopRecordingAndSend() { if (!recActive) return; recManualStop = true; if (recRecorder && recRecorder.state === "recording") recRecorder.stop(); else if (!recRecorder) finishRecordingCleanup(); // ещё ждём getUserMedia — нечего отправлять } // Остановка ПОСЛЕ блокировки жестом — ведёт не сразу в отправку, а в редактор // (обрезка + предпрослушивание), см. ветку wasEdit в recRecorder.onstop выше. function stopRecordingForEditing() { if (!recActive) return; recManualStop = true; recPendingEdit = true; if (recRecorder && recRecorder.state === "recording") recRecorder.stop(); else if (!recRecorder) finishRecordingCleanup(); } function cancelRecording() { if (!recActive) return; recCancelled = true; recManualStop = true; if (recRecorder && recRecorder.state === "recording") recRecorder.stop(); else finishRecordingCleanup(); // ещё ждём getUserMedia либо уже остановлен } function finishRecordingCleanup() { recActive = false; recLocked = false; sendBtnPointerId = null; clearTimeout(recSegmentTimer); stopRecordingTimerUI(); if (recStream) { recStream.getTracks().forEach(function (t) { t.stop(); }); recStream = null; } hideRecUI(); recRecorder = null; recType = null; recChatId = null; recHeldBtn = null; } function startRecordingTimerUI() { const el = document.getElementById("recordingTime"); function tick() { const sec = Math.floor((Date.now() - recTotalStartedAt) / 1000); const m = Math.floor(sec / 60), s = sec % 60; if (el) el.textContent = m + ":" + (s < 10 ? "0" : "") + s; } tick(); clearInterval(recTimerInterval); recTimerInterval = setInterval(tick, 250); } function stopRecordingTimerUI() { clearInterval(recTimerInterval); recTimerInterval = null; } // ── UI: показать/спрятать полосу записи, спрятать соседние кнопки ──────── function showRecUI(kind, btnEl) { recHeldBtn = btnEl; document.getElementById("btnAttach").style.display = "none"; document.getElementById("msg").style.display = "none"; document.getElementById("btnEmoji").style.display = "none"; if (btnEl) btnEl.style.display = ""; // единственная кнопка — она же и есть зажатая const bar = document.getElementById("recSlideBar"); if (bar) bar.style.display = "flex"; const hint = document.getElementById("recSlideHint"); if (hint) { hint.style.display = "flex"; hint.style.opacity = "1"; hint.style.transform = ""; } document.getElementById("recLockedActions").style.display = "none"; const lockHint = document.getElementById("recLockHint"); if (lockHint) { lockHint.style.display = "flex"; lockHint.style.transform = ""; lockHint.classList.remove("locked"); lockHint.innerHTML = ''; } } function hideRecUI() { document.getElementById("btnAttach").style.display = ""; document.getElementById("msg").style.display = ""; document.getElementById("btnEmoji").style.display = ""; updateSendButtonIcon(); // восстановит нужную иконку (отправить/микрофон/камера) const bar = document.getElementById("recSlideBar"); if (bar) bar.style.display = "none"; document.getElementById("recLockedActions").style.display = "none"; const lockHint = document.getElementById("recLockHint"); if (lockHint) lockHint.style.display = "none"; const overlay = document.getElementById("videoRecordOverlay"); if (overlay) overlay.style.display = "none"; const preview = document.getElementById("videoRecordPreview"); if (preview) preview.srcObject = null; } // Есть текст/вложения — рисуем стрелку отправки. Пусто — микрофон или камеру, // в зависимости от текущего выбранного тапами режима записи. function updateSendButtonIcon() { if (recActive) return; // во время записи иконкой управляет showRecUI/hideRecUI const btn = document.getElementById("btnSend"); if (!btn) return; if (hasSendableContent()) { btn.innerHTML = SEND_ICON_SVG; btn.title = "Отправить"; } else if (currentRecordMode === "video") { btn.innerHTML = VIDEO_MODE_ICON_SVG; btn.title = "Видео-сообщение — тап переключает на голосовое, удержание записывает"; } else { btn.innerHTML = MIC_ICON_SVG; btn.title = "Голосовое сообщение — тап переключает на видео, удержание записывает"; } } // ── Редактор: обрезка + предпрослушивание готовой (locked) записи ─────── async function openRecordingEditor(blob, kind, durationSec, chatId) { recEditorBlob = blob; recEditorKind = kind; recEditorChatId = chatId; recEditorDuration = durationSec; recEditorTrimStart = 0; recEditorTrimEnd = durationSec; recEditorObjectUrl = URL.createObjectURL(blob); const overlay = document.getElementById("recEditorOverlay"); const videoBox = document.getElementById("recEditorVideo"); const audioBox = document.getElementById("recEditorAudio"); if (kind === "video") { videoBox.style.display = "block"; audioBox.style.display = "none"; const videoEl = document.getElementById("recEditorVideoEl"); videoEl.muted = true; // не мешаем себе слушать через колонки поверх записанного звука videoEl.src = recEditorObjectUrl; videoEl.currentTime = 0; recEditorMediaEl = videoEl; } else { videoBox.style.display = "none"; audioBox.style.display = "flex"; const audioEl = new Audio(recEditorObjectUrl); recEditorMediaEl = audioEl; let peaks; try { const arrBuf = await blob.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrBuf.slice(0)); peaks = computeWaveformPeaksFromBuffer(audioBuffer, 48); recEditorDuration = audioBuffer.duration || durationSec; recEditorTrimEnd = recEditorDuration; } catch (e) { peaks = new Array(48).fill(0.35); } buildEditorWaveform(peaks); } attachEditorMediaEvents(); updateEditorPlayIcons(false); updateTrimLabels(); overlay.style.display = "flex"; requestAnimationFrame(positionTrimHandles); // после display:flex — чтобы clientWidth посчитался верно } function buildEditorWaveform(peaks) { const wave = document.getElementById("recEditorWave"); wave.innerHTML = ""; peaks.forEach(function (p) { const bar = document.createElement("div"); bar.className = "rec-editor-wave-bar"; bar.style.height = Math.round(6 + p * 24) + "px"; wave.appendChild(bar); }); updateEditorWaveformSelection(); } function updateEditorWaveformSelection() { const wave = document.getElementById("recEditorWave"); if (!wave) return; const bars = wave.querySelectorAll(".rec-editor-wave-bar"); const n = bars.length; if (!n) return; const startIdx = Math.round(timeToTrimRatio(recEditorTrimStart) * n); const endIdx = Math.round(timeToTrimRatio(recEditorTrimEnd) * n); bars.forEach(function (b, i) { b.classList.toggle("selected", i >= startIdx && i < endIdx); }); } function timeToTrimRatio(t) { return recEditorDuration > 0 ? (t / recEditorDuration) : 0; } function positionTrimHandles() { const track = document.getElementById("recTrimTrack"); const selected = document.getElementById("recTrimSelected"); const hs = document.getElementById("recTrimHandleStart"); const he = document.getElementById("recTrimHandleEnd"); if (!track) return; const w = track.clientWidth || 1; const handleW = 14; const startPx = timeToTrimRatio(recEditorTrimStart) * w; const endPx = timeToTrimRatio(recEditorTrimEnd) * w; selected.style.left = startPx + "px"; selected.style.width = Math.max(0, endPx - startPx) + "px"; hs.style.left = Math.max(0, startPx - handleW / 2) + "px"; he.style.left = Math.max(0, endPx - handleW / 2) + "px"; } function updateTrimLabels() { const startLabel = document.getElementById("recTrimStartLabel"); const durLabel = document.getElementById("recTrimDurLabel"); if (startLabel) startLabel.textContent = formatDuration(recEditorTrimStart); if (durLabel) durLabel.textContent = formatDuration(Math.max(0, recEditorTrimEnd - recEditorTrimStart)); } function bindTrimHandle(el, which) { if (!el) return; el.addEventListener("pointerdown", function (e) { e.preventDefault(); e.stopPropagation(); try { el.setPointerCapture(e.pointerId); } catch (err) {} recEditorDragHandle = which; function onMove(ev) { if (recEditorDragHandle !== which) return; const track = document.getElementById("recTrimTrack"); const rect = track.getBoundingClientRect(); const ratio = Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width)); const t = ratio * recEditorDuration; if (which === "start") { recEditorTrimStart = Math.max(0, Math.min(t, recEditorTrimEnd - REC_TRIM_MIN_GAP)); } else { recEditorTrimEnd = Math.min(recEditorDuration, Math.max(t, recEditorTrimStart + REC_TRIM_MIN_GAP)); } positionTrimHandles(); updateTrimLabels(); updateEditorWaveformSelection(); } function onUp() { recEditorDragHandle = null; el.removeEventListener("pointermove", onMove); el.removeEventListener("pointerup", onUp); el.removeEventListener("pointercancel", onUp); } el.addEventListener("pointermove", onMove); el.addEventListener("pointerup", onUp); el.addEventListener("pointercancel", onUp); }); } function attachEditorMediaEvents() { if (!recEditorMediaEl) return; recEditorMediaEl.addEventListener("timeupdate", onEditorTimeUpdate); recEditorMediaEl.addEventListener("play", function () { updateEditorPlayIcons(true); }); recEditorMediaEl.addEventListener("pause", function () { updateEditorPlayIcons(false); }); } function onEditorTimeUpdate() { if (!recEditorMediaEl) return; if (recEditorMediaEl.currentTime >= recEditorTrimEnd) { recEditorMediaEl.pause(); recEditorMediaEl.currentTime = recEditorTrimStart; } } function updateEditorPlayIcons(playing) { const icon = playing ? pauseIconSvg() : playIconSvg(); const vBtn = document.getElementById("recEditorPlayBtn"); const aBtn = document.getElementById("recEditorAudioPlayBtn"); if (vBtn) vBtn.innerHTML = icon; if (aBtn) aBtn.innerHTML = icon; } function toggleEditorPreviewPlay() { if (!recEditorMediaEl) return; if (recEditorMediaEl.paused) { if (recEditorMediaEl.currentTime < recEditorTrimStart || recEditorMediaEl.currentTime >= recEditorTrimEnd) { recEditorMediaEl.currentTime = recEditorTrimStart; } recEditorMediaEl.play().catch(function () {}); } else { recEditorMediaEl.pause(); } } // Настоящая обрезка контейнера webm: проигрываем исходный blob от startSec до // endSec через скрытый /, ловим его через captureStream() и // заново пишем куски новым MediaRecorder'ом — просто "нарезать" байты webm // нельзя, а так получаем корректный самостоятельный файл. muted=true не // глушит захватываемый поток (только вывод на колонки), зато обходит // автоплей-политику браузера без лишнего пользовательского жеста. function trimMediaBlob(blob, kind, mime, startSec, endSec) { return new Promise(function (resolve, reject) { const el = document.createElement(kind === "video" ? "video" : "audio"); el.muted = true; el.playsInline = true; el.src = URL.createObjectURL(blob); el.addEventListener("error", function () { reject(new Error("media load error")); }); el.addEventListener("loadedmetadata", function onMeta() { el.removeEventListener("loadedmetadata", onMeta); let stream = null; try { stream = el.captureStream ? el.captureStream() : (el.mozCaptureStream ? el.mozCaptureStream() : null); } catch (e) { stream = null; } if (!stream) { reject(new Error("captureStream not supported")); return; } const recMimeType = pickRecorderMime(kind); let recorder; try { recorder = recMimeType ? new MediaRecorder(stream, { mimeType: recMimeType }) : new MediaRecorder(stream); } catch (e) { try { recorder = new MediaRecorder(stream); } catch (e2) { reject(e2); return; } } const chunks = []; recorder.ondataavailable = function (e) { if (e.data && e.data.size > 0) chunks.push(e.data); }; recorder.onstop = function () { URL.revokeObjectURL(el.src); resolve(new Blob(chunks, { type: recMimeType || mime })); }; recorder.onerror = function (e) { reject(e.error || new Error("recorder error")); }; function seekTo(t) { return new Promise(function (res) { function onSeeked() { el.removeEventListener("seeked", onSeeked); res(); } el.addEventListener("seeked", onSeeked); el.currentTime = t; }); } seekTo(Math.max(0, startSec)).then(function () { recorder.start(); el.play().catch(function () {}); const durMs = Math.max(50, (endSec - startSec) * 1000); setTimeout(function () { try { el.pause(); } catch (e) {} if (recorder.state === "recording") recorder.stop(); }, durMs); }).catch(reject); }); }); } async function confirmRecordingEdit() { if (!recEditorBlob) return; const blob = recEditorBlob, kind = recEditorKind, chatId = recEditorChatId; const start = recEditorTrimStart, end = recEditorTrimEnd, fullDuration = recEditorDuration; const mime = blob.type; closeRecordingEditor(); // сразу прячем редактор — дальше обрезка/отправка идут в фоне let finalBlob = blob; let finalDuration = Math.max(0.3, end - start); const needsTrim = start > 0.05 || end < fullDuration - 0.05; if (needsTrim) { try { finalBlob = await trimMediaBlob(blob, kind, mime, start, end); } catch (e) { console.error("trimMediaBlob error", e); toastWarning("Обрезка не поддерживается этим браузером — отправлено целиком"); finalBlob = blob; finalDuration = fullDuration; } } await sendRecordedSegment(finalBlob, kind, finalDuration, chatId); } function discardRecordingEdit() { closeRecordingEditor(); } function closeRecordingEditor() { if (recEditorMediaEl) { try { recEditorMediaEl.pause(); } catch (e) {} recEditorMediaEl.removeEventListener("timeupdate", onEditorTimeUpdate); recEditorMediaEl = null; } if (recEditorObjectUrl) { URL.revokeObjectURL(recEditorObjectUrl); recEditorObjectUrl = null; } const videoEl = document.getElementById("recEditorVideoEl"); if (videoEl) { videoEl.pause(); videoEl.removeAttribute("src"); videoEl.load(); } const overlay = document.getElementById("recEditorOverlay"); if (overlay) overlay.style.display = "none"; recEditorBlob = null; recEditorKind = null; recEditorChatId = null; } // Строит и отправляет одно готовое сообщение (голосовое или видео-кружок) из // записанного Blob — переиспользует тот же кусочный аплоад-протокол // (uploadFileChunked/uploadSmallDataUrl), что и обычные файловые вложения. async function sendRecordedSegment(blob, kind, durationSec, chatId) { if (!chatId || !userProfile) return; let extra = { duration: durationSec }; let localThumbUrl = null; if (kind === "audio") { try { const arrBuf = await blob.arrayBuffer(); const audioBuffer = await ctx.decodeAudioData(arrBuf.slice(0)); extra.waveform = computeWaveformPeaksFromBuffer(audioBuffer, 42); extra.duration = audioBuffer.duration || durationSec; } catch (e) { extra.waveform = []; } } else { // Видео-кружок: круглое превью-постер + реальные размеры кадра. // Длительность из video.duration у только что записанных webm иногда // ненадёжна (Infinity/NaN) — доверяем ей только если она конечна, // иначе используем время, замеренное самим MediaRecorder'ом/редактором. const objectUrl = URL.createObjectURL(blob); const info = await generateVideoThumbnail(objectUrl); URL.revokeObjectURL(objectUrl); if (info) { localThumbUrl = info.thumb; extra.width = info.width; extra.height = info.height; if (info.duration && isFinite(info.duration)) extra.duration = info.duration; } extra.round = true; } const att = Object.assign({ localId: genLocalId(), type: kind, name: (kind === "video" ? "video_message_" : "voice_message_") + Date.now() + ".webm", mime: blob.type || (kind === "video" ? "video/webm" : "audio/webm"), size: blob.size, file: blob, previewUrl: null, localThumbUrl: localThumbUrl, progress: 0, status: "pending" }, extra); const clientId = genLocalId(); const localMsg = { id: clientId, chatId: chatId, userId: userProfile.id, username: userProfile.name, avatar: userProfile.avatar, text: "", attachments: [att], createdAt: Date.now(), _status: "sending" }; let list = messageCache.get(chatId); if (!list) { list = []; messageCache.set(chatId, list); } list.push(localMsg); pendingLocalMessages.set(clientId, { chatId: chatId, text: "", attachments: [att] }); if (chatId === currentChatId) { const box = document.getElementById("messages"); const prev = list.length > 1 ? list[list.length - 2] : null; appendMessageToBox(box, localMsg, prev); scrollMessagesToBottom(); setInitialFloatingDate(); } updateJumpButtonVisibility(); const chat = chatCache.get(chatId); if (chat) { chat.lastMessage = kind === "video" ? "Видеосообщение" : "Голосовое сообщение"; chat.lastAt = Date.now(); chat.lastMessageUserId = userProfile.id; chat.lastMessageStatus = "sending"; renderChatListFromCache(); } await uploadAndSendLocalMessage(clientId, chatId, "", [att]); } // UI bindings function switchTab(tab) { // Подсветка — и рейл и мобильный навбар document.querySelectorAll(".nav-btn[data-tab], .mob-nav-btn[data-tab]").forEach(function(b) { b.classList.toggle("active", b.dataset.tab === tab); }); // Показ нужной панели var panels = { chats: "panelChats", contacts: "panelContacts", settings: "panelSettings", profile: "panelProfile" }; Object.keys(panels).forEach(function(key) { var el = document.getElementById(panels[key]); if (el) el.style.display = key === tab ? "flex" : "none"; }); } function truncate(text, limit = 100) { if (text.length <= limit) { return text; } return text.slice(0, limit) + '…'; } // ========================================== // ЛОГИКА РАСЧЕТА И ОБНОВЛЕНИЯ СТАТУСА // ========================================== function calculateStatus() { if (!ws || ws.readyState !== WebSocket.OPEN) return 'offline'; if (visibleTabs.size === 0 || !isUserActive) return 'away'; return 'online'; } function updateStatus() { if (!isLeader) return; const newStatus = calculateStatus(); if (status !== newStatus) { status = newStatus; send("status", { active: status === "online" }); broadcastStatus(); } } function broadcastStatus() { channel.postMessage({ action: 'STATUS_UPDATED', sender: tabId, payload: { status: status } }); } // ========================================== // ВЫБОРЫ ЛИДЕРА И WEBSOCKET // ========================================== function initLeaderElection() { if ('locks' in navigator) { navigator.locks.request('secure_messanger_leader_lock', async () => { becomeLeader(); await new Promise(() => {}); // Удерживаем замок }); channel.postMessage({ action: 'NEW_TAB_OPENED', sender: tabId }); } else { becomeLeader(); } window.addEventListener('beforeunload', () => { if (isLeader) { stopHeartbeatWorker(); } else { channel.postMessage({ action: 'TAB_CLOSED', sender: tabId }); } }); } function becomeLeader() { if (isLeader) return; isLeader = true; allTabs.add(tabId); handleTabVisibility(tabId, !document.hidden); startWs(); } // ========================================== // HEARTBEAT WORKER (Без замедления в фоне) // ========================================== function startHeartbeatWorker() { stopHeartbeatWorker(); const blobCode = ` let timer = null; onmessage = function(e) { if (e.data.action === 'start') { timer = setInterval(() => postMessage('tick'), e.data.interval); } else if (e.data.action === 'stop') { clearInterval(timer); } }; `; workerTimer = new Worker(URL.createObjectURL(new Blob([blobCode], { type: 'application/javascript' }))); workerTimer.onmessage = () => { if (isLeader) { send('status', { active: status === "online" }); } }; workerTimer.postMessage({ action: 'start', interval: PING_INTERVAL_MS }); } function stopHeartbeatWorker() { if (workerTimer) { workerTimer.postMessage({ action: 'stop' }); workerTimer.terminate(); workerTimer = null; } } // ========================================== // МЕЖВКЛАДОЧНАЯ ШИНА (BroadcastChannel) // ========================================== function initChannelListeners() { channel.onmessage = (event) => { const { action, sender, payload } = event.data; if (sender === tabId) return; switch (action) { case 'STATUS_UPDATED': if (!isLeader && payload) { if (payload.status === "offline") showWsOverlay(); else hideWsOverlay(); } break; case 'FORWARD_SEND': if (isLeader && payload) send(payload.wsType, payload.payload); break; case 'TAB_OUTGOING': { if (!isLeader && payload) { wsId = payload.wsId; onChats(payload.chats); onProfile(payload.profile); onSoundResult(payload.sounds); } } break; case 'INCOMING_WS': handle(payload); break; case 'TAB_VISIBILITY': if (isLeader && payload) handleTabVisibility(sender, payload.isVisible); break; case 'TAB_ACTIVITY': if (isLeader) handleUserActivityLocally(); break; case 'NEW_TAB_OPENED': if (isLeader && !allTabs.has(sender)) { allTabs.add(sender); channel.postMessage( { action: 'TAB_OUTGOING', sender: tabId, payload: { wsId: wsId, chats: lastChatsMsg, profile: userProfile, sounds: lastSoundsMsg } }); } break; case 'TAB_CLOSED': if (isLeader) { allTabs.delete(sender); handleTabVisibility(sender, false); } break; } }; } // ========================================== // АКТИВНОСТЬ И ВИДИМОСТЬ // ========================================== function initActivityTracker() { const events = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart']; events.forEach(e => window.addEventListener(e, registerActivity, { passive: true })); document.addEventListener('visibilitychange', reportVisibility); reportVisibility(); resetIdleTimer(); } function reportVisibility() { const isVisible = !document.hidden; if (isLeader) { handleTabVisibility(tabId, isVisible); } else { channel.postMessage({ action: 'TAB_VISIBILITY', sender: tabId, payload: { isVisible: isVisible } }); } } function handleTabVisibility(id, isVisible) { if (isVisible) { visibleTabs.add(id); } else { visibleTabs.delete(id); } if (isLeader && isVisible && id !== tabId) { broadcastStatus(); } if (visibleTabs.size > 0) { clearTimeout(awayDebounceTimer); handleUserActivityLocally(); } else { clearTimeout(awayDebounceTimer); awayDebounceTimer = setTimeout(() => { if (visibleTabs.size === 0) { isUserActive = false; updateStatus(); } }, 1000); } } function registerActivity() { if (isLeader) { handleUserActivityLocally(); } else { channel.postMessage({ action: 'TAB_ACTIVITY', sender: tabId }); } } function handleUserActivityLocally() { if (!isUserActive) isUserActive = true; updateStatus(); resetIdleTimer(); } function resetIdleTimer() { clearTimeout(idleTimer); idleTimer = setTimeout(() => { isUserActive = false; updateStatus(); }, IDLE_TIMEOUT_MS); } window.addEventListener("load", function () { // Определяем мобильный/десктоп сразу при загрузке applyMobileClass(); window.addEventListener("resize", applyMobileClass); initChannelListeners(); initLeaderElection(); initActivityTracker(); // Если токен уже есть — сразу прячем auth-оверлей до первого ответа сервера, // чтобы не было мигания формы логина при обновлении страницы if (localStorage.getItem(sessionToken)) { const ov = document.getElementById("authOverlay"); if (ov) { ov.style.display = "none"; } } // ── Переключение login ↔ register ──────────────────────── document.getElementById("toRegister").onclick = function() { showAuthForm("register"); }; document.getElementById("toLogin").onclick = function() { showAuthForm("login"); }; // Enter в полях логина/регистрации ["login","password"].forEach(function(id) { document.getElementById(id).addEventListener("keydown", function(e) { if (e.key === "Enter") { e.preventDefault(); document.getElementById("btnLogin").click(); } }); }); ["regLogin","regName","regPassword1","regPassword2"].forEach(function(id) { document.getElementById(id).addEventListener("keydown", function(e) { if (e.key === "Enter") { e.preventDefault(); document.getElementById("btnRegister").click(); } }); }); document.getElementById("msg").addEventListener("keydown", function (e) { if (e.key === "Enter") { if (e.shiftKey) return; e.preventDefault(); document.getElementById("btnSend").click(); } }); // Вставка изображений/файлов из буфера обмена (Ctrl+V) document.getElementById("msg").addEventListener("paste", function (e) { const items = e.clipboardData && e.clipboardData.items; if (!items || !items.length) return; const files = []; for (let i = 0; i < items.length; i++) { if (items[i].kind === "file") { const f = items[i].getAsFile(); if (f) files.push(f); } } if (!files.length) return; // обычный текстовый paste — не трогаем e.preventDefault(); stageMultipleFiles(files); }); // Вставка из буфера обмена где угодно на экране открытого чата // (если фокус не в другом текстовом поле — там сработает локальный обработчик выше) document.addEventListener("paste", function (e) { if (!currentChatId) return; const msgInput = document.getElementById("msg"); const active = document.activeElement; if (active === msgInput) return; // уже обработано локальным листенером if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) return; const items = e.clipboardData && e.clipboardData.items; if (!items || !items.length) return; const files = []; for (let i = 0; i < items.length; i++) { if (items[i].kind === "file") { const f = items[i].getAsFile(); if (f) files.push(f); } } if (!files.length) return; e.preventDefault(); stageMultipleFiles(files); }); document.getElementById("btnLogin").onclick = function () { const login = document.getElementById("login").value; const password = document.getElementById("password").value; if (!login || !password) { toastWarning("Заполните все поля"); return; } setButtonLoading(this, true); send("login", { login: login, password: password }); document.getElementById("login").value = ""; document.getElementById("password").value = ""; }; document.getElementById("btnRegister").onclick = function () { const login = document.getElementById("regLogin").value; const name = document.getElementById("regName").value; const password1 = document.getElementById("regPassword1").value; const password2 = document.getElementById("regPassword2").value; if (!login || !name || !password1 || !password2) { toastWarning("Заполните все поля"); return; } if (password1 !== password2) { toastWarning("Пароли не совпадают"); return; } setButtonLoading(this, true); send("register", { login: login, name: name, password: password1 }); document.getElementById("regLogin").value = ""; document.getElementById("regName").value = ""; document.getElementById("regPassword1").value = ""; document.getElementById("regPassword2").value = ""; }; // ── Nav rail (desktop) ─────────────────────────────────── document.querySelectorAll(".nav-btn[data-tab]").forEach(function(btn) { btn.onclick = function() { switchTab(btn.dataset.tab); }; }); // ── Mobile bottom nav ───────────────────────────────────── document.querySelectorAll(".mob-nav-btn[data-tab]").forEach(function(btn) { btn.onclick = function() { switchTab(btn.dataset.tab); }; }); // ── New chat button (в заголовке панели чатов) ─────────── document.getElementById("btnNewChat").onclick = function() { document.getElementById("newChatModal").style.display = "flex"; }; document.getElementById("btnCloseModal").onclick = function() { document.getElementById("newChatModal").style.display = "none"; }; document.getElementById("newChatModal").onclick = function(e) { if (e.target === this) this.style.display = "none"; }; // ── Modal: create/join ──────────────────────────────────── document.getElementById("modalBtnPrivate").onclick = function() { const uname = document.getElementById("modalPrivateUsername").value.trim(); if (!uname) { toastWarning("Введите ник"); return; } pendingPrivateByUsername = uname; send("find_user", { username: uname }); document.getElementById("newChatModal").style.display = "none"; document.getElementById("modalPrivateUsername").value = ""; }; document.getElementById("modalBtnCreate").onclick = function() { const title = document.getElementById("modalChatTitle").value.trim(); if (!title) return; send("create_chat", { title: title }); document.getElementById("modalChatTitle").value = ""; document.getElementById("newChatModal").style.display = "none"; }; document.getElementById("modalBtnJoin").onclick = function() { const token = document.getElementById("modalJoinToken").value.trim(); if (!token) return; send("join_chat", { token: token }); document.getElementById("modalJoinToken").value = ""; document.getElementById("newChatModal").style.display = "none"; }; // ── Panel: contacts (fallback кнопки) ──────────────────── document.getElementById("btnCreateChat").onclick = function () { const title = document.getElementById("chatTitle").value; if (!title) return; send("create_chat", { title: title }); document.getElementById("chatTitle").value = ""; }; document.getElementById("btnJoin").onclick = function () { const token = document.getElementById("joinToken").value; if (!token) return; send("join_chat", { token: token }); document.getElementById("joinToken").value = ""; }; function sendCurrentMessage() { const textBox = document.getElementById("msg"); const text = textBox.value; if (!text && !pendingAttachments.length) return; const chatId = currentChatId; if (!chatId) return; const clientId = genLocalId(); const attachments = pendingAttachments; pendingAttachments = []; renderAttachmentPreviews(); textBox.value = ""; textBox.focus(); updateSendButtonIcon(); // ── Локальный оптимистичный рендер: пузырь появляется в чате СРАЗУ, // ещё до того, как сервер вообще увидел сообщение — со статусом // "отправляется" (часики) и живым прогрессом загрузки вложений. // Как только сервер подтвердит (см. onMessage/finalizeLocalMessage), // статус сменится на одну галочку, а после доставки получателю — на две. const localMsg = { id: clientId, chatId: chatId, userId: userProfile.id, username: userProfile.name, avatar: userProfile.avatar, text: text, attachments: attachments, createdAt: Date.now(), _status: "sending" }; let list = messageCache.get(chatId); if (!list) { list = []; messageCache.set(chatId, list); } list.push(localMsg); pendingLocalMessages.set(clientId, { chatId: chatId, text: text, attachments: attachments }); if (chatId === currentChatId) { const box = document.getElementById("messages"); const prev = list.length > 1 ? list[list.length - 2] : null; appendMessageToBox(box, localMsg, prev); scrollMessagesToBottom(); setInitialFloatingDate(); } updateJumpButtonVisibility(); const chat = chatCache.get(chatId); if (chat) { chat.lastMessage = truncate(text || (attachments.length ? "Вложение" : "")); chat.lastAt = Date.now(); chat.lastMessageUserId = userProfile.id; chat.lastMessageStatus = "sending"; renderChatListFromCache(); } uploadAndSendLocalMessage(clientId, chatId, text, attachments); } window.sendCurrentMessage = sendCurrentMessage; // доступна из bindSendButtonGestures document.getElementById("btnAttach").onclick = function (e) { e.stopPropagation(); toggleAttachMenu(); }; document.querySelectorAll(".attach-menu-item").forEach(function (item) { item.onclick = function () { const input = document.getElementById("attachFileInput"); input.accept = item.dataset.accept || ""; toggleAttachMenu(false); input.click(); }; }); document.addEventListener("click", function (e) { const menu = document.getElementById("attachMenu"); if (menu.style.display === "none") return; if (menu.contains(e.target) || e.target.closest("#btnAttach")) return; toggleAttachMenu(false); }); document.getElementById("attachFileInput").onchange = function (e) { stageMultipleFiles(e.target.files); e.target.value = ""; }; // ── Единая кнопка отправки/записи (тап переключает режим, удержание — // запись; дальше жест свайп-влево/вверх), плюс редактор обрезки ── bindSendButtonGestures(document.getElementById("btnSend")); document.getElementById("btnCancelRecording").onclick = function () { cancelRecording(); }; document.getElementById("btnSendRecording").onclick = function () { stopRecordingForEditing(); }; document.getElementById("msg").addEventListener("input", updateSendButtonIcon); updateSendButtonIcon(); bindTrimHandle(document.getElementById("recTrimHandleStart"), "start"); bindTrimHandle(document.getElementById("recTrimHandleEnd"), "end"); document.getElementById("recEditorPlayBtn").onclick = toggleEditorPreviewPlay; document.getElementById("recEditorAudioPlayBtn").onclick = toggleEditorPreviewPlay; document.getElementById("btnConfirmRecordingEdit").onclick = confirmRecordingEdit; document.getElementById("btnDiscardRecordingEdit").onclick = discardRecordingEdit; window.addEventListener("resize", function () { const overlay = document.getElementById("recEditorOverlay"); if (overlay && overlay.style.display !== "none") positionTrimHandles(); }); document.getElementById("btnEmoji").onclick = function (e) { e.stopPropagation(); toggleEmojiPicker(); }; document.addEventListener("click", function (e) { const picker = document.getElementById("emojiPicker"); if (picker.style.display === "none") return; if (picker.contains(e.target) || e.target.closest("#btnEmoji")) return; toggleEmojiPicker(false); }); document.getElementById("gaBtnPlay").onclick = function () { if (globalAudioEl.paused) globalAudioEl.play(); else globalAudioEl.pause(); }; document.getElementById("gaBtnClose").onclick = function () { stopGlobalAudio(); }; document.getElementById("gaBtnSpeed").onclick = function () { globalAudioSpeedIdx = (globalAudioSpeedIdx + 1) % GLOBAL_AUDIO_SPEEDS.length; const speed = GLOBAL_AUDIO_SPEEDS[globalAudioSpeedIdx]; globalAudioEl.playbackRate = speed; this.textContent = speed + "x"; }; document.getElementById("gaVolumeSlider").oninput = function () { globalAudioEl.volume = parseFloat(this.value); }; (function () { const seekBar = document.getElementById("gaSeek"); function seekFromEvent(e) { if (!globalAudioEl.duration) return; const rect = seekBar.getBoundingClientRect(); const clientX = e.touches ? e.touches[0].clientX : e.clientX; const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); globalAudioEl.currentTime = ratio * globalAudioEl.duration; updateGlobalAudioBarProgress(); } seekBar.addEventListener("mousedown", function (e) { seekFromEvent(e); function onMove(ev) { seekFromEvent(ev); } function onUp() { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); } document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }); seekBar.addEventListener("touchstart", function (e) { seekFromEvent(e); }, { passive: true }); })(); document.getElementById("mediaViewerClose").onclick = closeMediaViewer; document.getElementById("mediaViewer").onclick = function (e) { if (e.target === this) closeMediaViewer(); }; document.getElementById("mediaViewerPrev").onclick = function (e) { e.stopPropagation(); navigateMediaViewer(-1); }; document.getElementById("mediaViewerNext").onclick = function (e) { e.stopPropagation(); navigateMediaViewer(1); }; document.getElementById("mediaViewerDownload").onclick = function (e) { e.stopPropagation(); downloadCurrentMediaItem(); }; document.addEventListener("keydown", function (e) { const viewerOpen = document.getElementById("mediaViewer").style.display !== "none"; if (e.key === "Escape") { closeMediaViewer(); toggleEmojiPicker(false); toggleAttachMenu(false); } else if (viewerOpen && e.key === "ArrowLeft") { navigateMediaViewer(-1); } else if (viewerOpen && e.key === "ArrowRight") { navigateMediaViewer(1); } }); // Свайп для листания на мобильных (function () { const viewer = document.getElementById("mediaViewer"); let touchStartX = 0; let touchStartY = 0; viewer.addEventListener("touchstart", function (e) { if (!e.touches || !e.touches.length) return; touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; }, { passive: true }); viewer.addEventListener("touchend", function (e) { if (!e.changedTouches || !e.changedTouches.length) return; const dx = e.changedTouches[0].clientX - touchStartX; const dy = e.changedTouches[0].clientY - touchStartY; if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) { navigateMediaViewer(dx > 0 ? -1 : 1); } }, { passive: true }); })(); document.getElementById("backButton").onclick = function () { document.getElementById("messagesContainer").classList.remove("open"); document.getElementById("mobileNav").style.display = ""; currentChatId = null; document.querySelectorAll(".chatItem").forEach(function(el) { el.classList.remove("active"); }); }; const msgBox = document.getElementById("messages"); let _lastScrollTop = 0; msgBox.addEventListener("scroll", function () { const scrollingDown = msgBox.scrollTop > _lastScrollTop; _lastScrollTop = msgBox.scrollTop; updateFloatingDate(); const nearBottom = msgBox.scrollHeight - msgBox.scrollTop - msgBox.clientHeight < 60; autoScroll = nearBottom; if (currentChatId) { const paging = getPaging(currentChatId); if (msgBox.scrollTop < MSG_LOAD_THRESHOLD && paging.hasMoreOlder && !paging.loadingOlder) { loadOlderMessages(currentChatId); } } updateJumpButtonVisibility(); }); document.getElementById("btnJumpToBottom").onclick = function () { if (currentChatId) jumpToLatestMessages(currentChatId); }; document.getElementById("btnChangeName").onclick = function () { const newName = document.getElementById("newName"); if (!newName.value) { toastWarning("Введите имя"); return; } send("update_name", { name: newName.value }); newName.value = ""; }; document.getElementById("btnChangeUsername").onclick = function () { const newUsername = document.getElementById("newUsername"); send("update_username", { username: newUsername.value }); newUsername.value = ""; }; document.getElementById("btnChangeAvatarUrl").onclick = function () { const url = document.getElementById("avatarUrl"); if (!url.value) { toastWarning("Введите URL"); return; } send("update_avatar_url", { url: url.value }); url.value = ""; }; document.getElementById("btnLogout").onclick = function () { send("logout"); showWsOverlay(); }; document.getElementById("avatarFile").onchange = async function () { const fileInput = document.getElementById("avatarFile"); const file = fileInput.files[0]; if (!file) { toastWarning("Выберите файл"); return; } // Проверка MIME if (!file.type.startsWith("image/")) { toastWarning("Можно загружать только изображения"); return; } const reader = new FileReader(); reader.onload = async function () { const resized = await resizeAndCompressImage(reader.result); send("update_avatar_base64", { base64: resized }); }; reader.readAsDataURL(file); }; document.getElementById("btnPrivateByUsername").onclick = function () { const uname = document.getElementById("privateUsername").value; if (!uname) { toastWarning("Введите ник"); return; } pendingPrivateByUsername = uname; uname.textContent = ""; send("find_user", { username: uname }); }; document.querySelectorAll('.can-copy').forEach(el => { el.addEventListener('click', async () => { const text = el.dataset.copyText || el.textContent.trim(); if (!text) return; try { await navigator.clipboard.writeText(text); toastSuccess("Скопировано!"); } catch (err) { toastError('Не удалось скопировать:', err); } }); }); });