(function () { const root = document.getElementById("f7support-app"); if (!root) return; const username = root.dataset.username; const serverAddress = root.dataset.serverAddress; const apiBase = root.dataset.supportApiBase; const supportWsBaseOverride = (root.dataset.supportWsBase || "").trim(); /** Включается в NC: `occ config:app:set f7support client_read_receipts --value=1` после выката API `POST .../read`. */ const clientReadReceiptsEnabled = root.dataset.clientReadReceipts === "1"; const ticketsPollMsRaw = parseInt( String(root.dataset.ticketsPollMs || root.dataset.messagesPollMs || "3000").trim(), 10 ); const ticketsPollMsParsed = Number.isFinite(ticketsPollMsRaw) && ticketsPollMsRaw >= 0 ? ticketsPollMsRaw : 3000; /** Интервал опроса GET /api/client/tickets (мс). По умолчанию 3000 (3 с), если атрибут не задан. 0 — выключить. Иначе 3000…120000. */ const ticketsPollIntervalMs = ticketsPollMsParsed === 0 ? 0 : Math.min(120000, Math.max(3000, ticketsPollMsParsed)); const RASTER_IMAGE_EXT = new Set([ "jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "heic", "heif", "svg", ]); const RASTER_IMAGE_MIME = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff", "image/x-tiff", "image/heic", "image/heif", "image/svg+xml", ]); function isRasterImageAtt(a) { const m = (a.mime_type || "").toLowerCase().split(";")[0].trim(); if (RASTER_IMAGE_MIME.has(m)) return true; return RASTER_IMAGE_EXT.has(fileExt(a.filename)); } function fileExt(filename) { const parts = String(filename || "").split("."); return parts.length > 1 ? parts.pop().toLowerCase() : ""; } function f7sAppImgUrl(name) { const path = `/apps/f7support/img/${name}`; if (typeof OC !== "undefined" && OC.generateUrl) { return OC.generateUrl(path); } return path; } const FILE_ICON = { pdf: "file-pdf.svg", doc: "file-doc-docx.svg", txt: "file-txt.svg", sheet: "file-sheet.svg", presentation: "file-presentation.svg", code: "file-code.svg", fonts: "file-fonts.svg", application: "file-application.svg", default: "file-default.svg", }; function attachmentIconFile(att) { const mime = (att.mime_type || "").toLowerCase().split(";")[0].trim(); const ext = fileExt(att.filename); if (mime === "application/pdf" || ext === "pdf") return FILE_ICON.pdf; if ( ["doc", "docx", "odt"].includes(ext) || mime.includes("msword") || mime.includes("wordprocessingml") ) { return FILE_ICON.doc; } if ( ["txt", "log", "md", "markdown", "rtf"].includes(ext) || mime === "text/plain" || mime === "text/markdown" || mime === "application/rtf" ) { return FILE_ICON.txt; } if ( ["xls", "xlsx", "csv", "ods"].includes(ext) || mime.includes("spreadsheet") || mime.includes("excel") ) { return FILE_ICON.sheet; } if ( ["ppt", "pptx", "odp", "key"].includes(ext) || mime.includes("presentation") || mime.includes("powerpoint") ) { return FILE_ICON.presentation; } if ( [ "js", "ts", "jsx", "tsx", "mjs", "cjs", "py", "html", "htm", "css", "scss", "less", "json", "xml", "yaml", "yml", "php", "java", "c", "cpp", "h", "hpp", "go", "rs", "rb", "sh", "bash", "sql", "vue", "swift", "kt", "cs", ].includes(ext) || mime.startsWith("text/x-") || mime.includes("javascript") || mime.includes("json") || mime.includes("xml") ) { return FILE_ICON.code; } if ( ["ttf", "otf", "woff", "woff2", "eot"].includes(ext) || mime.includes("font") ) { return FILE_ICON.fonts; } if ( ["exe", "msi", "dmg", "apk", "deb", "rpm", "appimage", "bin", "pkg", "app"].includes(ext) || mime.includes("executable") || mime === "application/vnd.android.package-archive" || mime === "application/x-msdownload" ) { return FILE_ICON.application; } return FILE_ICON.default; } const MSG_ATTACH_NAME_MAX_LEN = 24; function formatMsgAttachDisplayName(filename) { const name = filename || ""; if (name.length <= MSG_ATTACH_NAME_MAX_LEN) return name; return name.slice(0, MSG_ATTACH_NAME_MAX_LEN) + "..."; } function messageAttachmentsFrom(m) { return Array.isArray(m.attachments) ? m.attachments : []; } function messageIdKey(id) { if (id == null || id === "") return null; return String(id); } /** Один id — одно сообщение (WS иногда шлёт number/string по-разному). */ function dedupeMessagesById(messages) { if (!Array.isArray(messages)) return []; const byId = new Map(); const noId = []; for (const m of messages) { const key = messageIdKey(m.id); if (key == null) { noId.push(m); continue; } const prev = byId.get(key); if (!prev) { byId.set(key, m); continue; } const prevN = messageAttachmentsFrom(prev).length; const nextN = messageAttachmentsFrom(m).length; if (nextN >= prevN) byId.set(key, m); } const merged = [...byId.values(), ...noId]; merged.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))); return merged; } function findMessageIndexById(messages, id) { const key = messageIdKey(id); if (key == null) return -1; return messages.findIndex((m) => messageIdKey(m.id) === key); } function isMessageBodyPlaceholder(text) { const t = String(text ?? "").trim(); if (!t || t === MESSAGE_BODY_PLACEHOLDER || t === TICKET_CREATE_BODY_PLACEHOLDER) return true; return /^📎\s/.test(t); } function openOrDownloadBlob(blob, filename, mime) { const m = (mime || blob.type || "").toLowerCase().split(";")[0].trim(); const url = URL.createObjectURL(blob); const tryInline = RASTER_IMAGE_MIME.has(m) || m === "application/pdf" || (m.startsWith("text/") && m !== "text/html"); if (tryInline) { const w = window.open(url, "_blank", "noopener,noreferrer"); if (w) { setTimeout(() => URL.revokeObjectURL(url), 180000); return; } } const a = document.createElement("a"); a.href = url; a.download = filename || "file"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 120000); } /** Сервер без маршрута `POST .../read` (404) — не дёргать повторно, меньше шума в консоли. */ let skipClientReadReceiptPost = false; const state = { tickets: [], currentTicket: null, messages: [], chatModalOpen: false, blobUrls: [], ticketSocket: null, wsReconnectTimer: null, ticketsPollTimer: null, /** @type {File[]} */ createTicketAttachmentFiles: [], /** @type {File[]} */ chatAttachmentFiles: [], /** @type {Record} последний известный «отпечаток» карточки по номеру тикета (для точки без has_unread с API) */ ticketSeenFingerprint: {}, /** @type {Record} сигнатура последнего сообщения (id+время) по тикету — список тикетов часто не меняет preview/activity */ ticketLastMsgSig: Object.create(null), /** @type {Record} новое сообщение по сравнению с последним опросом /messages */ ticketUnreadByLastMsg: Object.create(null), /** Смещение пачки опросов /messages (не грузим все тикеты за один раз) */ msgSigPollIndex: 0, /** Идёт отправка сообщения + upload вложений — не мержить WS (иначе дубли в списке). */ chatOutboundBusy: false, }; function clientIdentityHeaders() { return { "X-F7cloud-User": username, "X-F7cloud-Server": serverAddress, }; } function revokeAttachmentUrls() { for (const u of state.blobUrls) { try { URL.revokeObjectURL(u); } catch (_) {} } state.blobUrls = []; } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function formatBytes(n) { const v = Number(n) || 0; if (v < 1024) return `${v} Б`; if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} КБ`; return `${(v / (1024 * 1024)).toFixed(1)} МБ`; } function formatChatTime(iso) { try { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false }); } catch (_) { return ""; } } function formatTicketCardTime(iso) { try { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const day = d.getDate(); const month = d.toLocaleString("ru-RU", { month: "long" }); const hm = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", hour12: false }); return `${day} ${month}, ${hm}`; } catch (_) { return ""; } } function wsUrlForTicket(ticketNumber) { const tail = `/tickets/${encodeURIComponent(ticketNumber)}`; if (supportWsBaseOverride) { try { const raw = supportWsBaseOverride.replace(/\/$/, ""); const u = new URL(raw.includes("://") ? raw : `wss://${raw}`); const wsProto = u.protocol === "https:" ? "wss:" : u.protocol === "http:" ? "ws:" : u.protocol; let path = (u.pathname || "").replace(/\/$/, ""); if (path === "" || path === "/") { path = "/ws"; } return `${wsProto}//${u.host}${path}${tail}`; } catch (_) { return null; } } try { const u = new URL(apiBase); const wsProto = u.protocol === "https:" ? "wss:" : "ws:"; return `${wsProto}//${u.host}/ws${tail}`; } catch (_) { return null; } } function disconnectTicketSocket() { if (state.wsReconnectTimer) { clearTimeout(state.wsReconnectTimer); state.wsReconnectTimer = null; } if (state.ticketSocket) { try { state.ticketSocket.close(); } catch (_) {} state.ticketSocket = null; } } function connectTicketSocket(ticketNumber) { disconnectTicketSocket(); const url = wsUrlForTicket(ticketNumber); if (!url) return; let socket; try { socket = new WebSocket(url); } catch (_) { return; } state.ticketSocket = socket; socket.onmessage = (event) => { try { const payload = JSON.parse(event.data); if (!payload || !payload.data) return; if (!state.chatModalOpen || state.currentTicket !== ticketNumber) return; if (payload.event === "new_message" || payload.event === "message_updated") { applyIncomingMessage(payload.data); fetchTickets().catch(() => {}); } } catch (_) {} }; socket.onclose = () => { state.ticketSocket = null; if (state.chatModalOpen && state.currentTicket === ticketNumber) { state.wsReconnectTimer = setTimeout(() => connectTicketSocket(ticketNumber), 2500); } }; socket.onerror = () => { try { socket.close(); } catch (_) {} }; } function applyIncomingMessage(msg) { if (state.chatOutboundBusy) return; const idx = findMessageIndexById(state.messages, msg.id); if (idx === -1) state.messages.push(msg); else state.messages[idx] = msg; state.messages = dedupeMessagesById(state.messages); void renderMessages(); if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) { markClientTicketRead(state.currentTicket).catch(() => {}); } } async function markClientTicketRead(ticketNumber) { if (!clientReadReceiptsEnabled || skipClientReadReceiptPost || !ticketNumber) return; let res; try { res = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/read`, { method: "POST", headers: clientIdentityHeaders(), }); } catch { return; } if (res.status === 404) { skipClientReadReceiptPost = true; return; } if (!res.ok) return; const row = state.tickets.find((x) => x.ticket_number === ticketNumber); if (row) row.has_unread = false; delete state.ticketUnreadByLastMsg[String(ticketNumber)]; renderTickets(); reapplyActiveTicketCard(ticketNumber); } function reapplyActiveTicketCard(ticketNumber) { const tnSel = String(ticketNumber).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`); if (card) card.classList.add("f7-ticket-card--active"); } const F7S_CREATE_BTN_PLUS = ``; const F7S_FILE_CLIP_SVG = ``; const F7S_CHIP_REMOVE_SVG = ``; const F7S_SUBMIT_BTN_ICON = ``; const CHAT_MSG_READ_SVG = ``; const CHAT_MSG_UNREAD_SVG = ``; function formatMsgTime(iso) { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; return d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); } function messageIsOutgoing(m) { const r = String(m.author_role || "").toLowerCase(); if (r === "client" || r === "user") return true; const a = String(m.author || "").toLowerCase(); const u = String(username || "").toLowerCase(); return Boolean(u && a === u); } function supportSenderLabel(m) { const r = String(m.author_role || "").toLowerCase(); if (["admin", "support", "staff", "operator", "agent"].includes(r)) return "Админ"; return String(m.author || "").trim() || "Собеседник"; } function incomingSenderKey(m) { const r = String(m.author_role || "").toLowerCase(); const a = String(m.author || "").trim().toLowerCase(); return `${r}\u0001${a}`; } function messageIsRenderable(m) { const text = String(m.text ?? "").trim(); const attachments = messageAttachmentsFrom(m); const showText = text && !isMessageBodyPlaceholder(text); return showText || attachments.length > 0; } function messagesShareGroup(prev, curr) { if (!prev || !curr) return false; const outPrev = messageIsOutgoing(prev); const outCurr = messageIsOutgoing(curr); if (outPrev !== outCurr) return false; if (outCurr) return true; return incomingSenderKey(prev) === incomingSenderKey(curr); } /** Позиция в цепочке подряд идущих сообщений одного автора (скругление углов как в макете). */ function buildRenderableMessageRows(messages) { const visible = dedupeMessagesById(messages).filter(messageIsRenderable); return visible.map((m, i) => { const prev = i > 0 ? visible[i - 1] : null; const next = i < visible.length - 1 ? visible[i + 1] : null; const sharesPrev = messagesShareGroup(prev, m); const sharesNext = messagesShareGroup(m, next); let groupPos = "single"; if (sharesPrev && sharesNext) groupPos = "middle"; else if (sharesPrev && !sharesNext) groupPos = "last"; else if (!sharesPrev && sharesNext) groupPos = "first"; const outgoing = messageIsOutgoing(m); const showSender = !outgoing && !sharesPrev; return { m, groupPos, showSender, outgoing }; }); } function clientMessageIsRead(m) { if (m == null || typeof m !== "object") return false; if (m.is_read === true || m.read === true) return true; if (m.is_read === 1 || m.read === 1) return true; const ra = m.read_at ?? m.read_at_utc ?? m.seen_at ?? m.read_at_iso; if (ra != null && String(ra).trim() !== "") return true; const s = String(m.status ?? m.delivery_status ?? "").toLowerCase(); if (s === "read" || s === "seen" || s === "delivered_read") return true; return false; } const F7S_TICKETS_ICON = ``; async function formatApiError(response, fallback) { try { const data = await response.clone().json(); const d = data?.detail; if (Array.isArray(d)) { return d.map((e) => e.msg || JSON.stringify(e)).join("; ") || fallback; } if (typeof d === "string") return d; } catch (_) {} return fallback; } /** OpenAPI TicketCreate / MessageCreate требуют text minLength:1. */ const TICKET_CREATE_BODY_PLACEHOLDER = "."; const MESSAGE_BODY_PLACEHOLDER = "."; function fileDedupeKey(f) { return `${f.name}|${f.size}|${f.lastModified}`; } const FILE_CHIP_NAME_MAX_LEN = 8; function formatFileChipDisplayName(filename) { const name = filename || ""; if (name.length <= FILE_CHIP_NAME_MAX_LEN) return name; return name.slice(0, FILE_CHIP_NAME_MAX_LEN) + "..."; } function applyCreateTicketFilesToInput() { const input = document.getElementById("create-ticket-attachment-input"); if (!input) return; const dt = new DataTransfer(); for (const f of state.createTicketAttachmentFiles) { dt.items.add(f); } input.files = dt.files; } function clearCreateTicketAttachments() { state.createTicketAttachmentFiles = []; const input = document.getElementById("create-ticket-attachment-input"); if (input) input.value = ""; syncCreateTicketAttachmentsUI(); } function removeCreateTicketFileAt(index) { if (index < 0 || index >= state.createTicketAttachmentFiles.length) return; state.createTicketAttachmentFiles.splice(index, 1); applyCreateTicketFilesToInput(); syncCreateTicketAttachmentsUI(); } function renderCreateTicketAttachmentChips() { const host = document.getElementById("create-ticket-attachment-chips"); if (!host) return; host.innerHTML = ""; state.createTicketAttachmentFiles.forEach((file, index) => { const chip = document.createElement("div"); chip.className = "f7s-file-chip"; const name = document.createElement("span"); name.className = "f7s-file-chip__name"; name.textContent = formatFileChipDisplayName(file.name); if (file.name.length > FILE_CHIP_NAME_MAX_LEN) name.title = file.name; const btn = document.createElement("button"); btn.type = "button"; btn.className = "f7s-file-chip-remove"; btn.setAttribute("aria-label", "Удалить файл"); btn.innerHTML = F7S_CHIP_REMOVE_SVG; btn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); removeCreateTicketFileAt(index); }); chip.append(name, btn); host.appendChild(chip); }); } function syncCreateTicketAttachmentsUI() { const row = document.getElementById("create-attach-row"); const n = state.createTicketAttachmentFiles.length; if (row) row.classList.toggle("f7s-has-attachments", n > 0); renderCreateTicketAttachmentChips(); } function onCreateTicketAttachmentChange() { const input = document.getElementById("create-ticket-attachment-input"); if (!input) return; addFilesToZone("create", input.files); applyCreateTicketFilesToInput(); } function applyChatFilesToInput() { const input = document.getElementById("attachment-input"); if (!input) return; const dt = new DataTransfer(); for (const f of state.chatAttachmentFiles) { dt.items.add(f); } input.files = dt.files; } function clearChatAttachments() { state.chatAttachmentFiles = []; const input = document.getElementById("attachment-input"); if (input) input.value = ""; syncChatAttachmentsUI(); } function removeChatAttachmentAt(index) { if (index < 0 || index >= state.chatAttachmentFiles.length) return; state.chatAttachmentFiles.splice(index, 1); applyChatFilesToInput(); syncChatAttachmentsUI(); } function renderChatAttachmentChips() { const host = document.getElementById("chat-attachment-chips"); if (!host) return; host.innerHTML = ""; state.chatAttachmentFiles.forEach((file, index) => { const chip = document.createElement("div"); chip.className = "f7s-file-chip"; const name = document.createElement("span"); name.className = "f7s-file-chip__name"; name.textContent = formatFileChipDisplayName(file.name); if (file.name.length > FILE_CHIP_NAME_MAX_LEN) name.title = file.name; const btn = document.createElement("button"); btn.type = "button"; btn.className = "f7s-file-chip-remove"; btn.setAttribute("aria-label", "Удалить файл"); btn.innerHTML = F7S_CHIP_REMOVE_SVG; btn.addEventListener("click", (ev) => { ev.preventDefault(); ev.stopPropagation(); removeChatAttachmentAt(index); }); chip.append(name, btn); host.appendChild(chip); }); } function syncChatAttachmentsUI() { const row = document.getElementById("chat-attach-row"); const n = state.chatAttachmentFiles.length; if (row) row.classList.toggle("f7s-has-attachments", n > 0); renderChatAttachmentChips(); } function onChatAttachmentChange() { const input = document.getElementById("attachment-input"); if (!input) return; addFilesToZone("chat", input.files); applyChatFilesToInput(); } function addFilesToZone(zone, fileList) { const files = Array.from(fileList || []); if (!files.length) return; if (zone === "create") { const seen = new Set(state.createTicketAttachmentFiles.map(fileDedupeKey)); for (const f of files) { const k = fileDedupeKey(f); if (seen.has(k)) continue; seen.add(k); state.createTicketAttachmentFiles.push(f); } applyCreateTicketFilesToInput(); syncCreateTicketAttachmentsUI(); } else if (zone === "chat") { const seen = new Set(state.chatAttachmentFiles.map(fileDedupeKey)); for (const f of files) { const k = fileDedupeKey(f); if (seen.has(k)) continue; seen.add(k); state.chatAttachmentFiles.push(f); } applyChatFilesToInput(); syncChatAttachmentsUI(); } } function dndZoneFromElement(el) { const z = el.closest("[data-f7-dnd-zone]"); return z ? z.getAttribute("data-f7-dnd-zone") : null; } async function postMessageToTicket(ticketNumber, text) { const response = await fetch( `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/messages`, { method: "POST", headers: { "Content-Type": "application/json", ...clientIdentityHeaders(), }, body: JSON.stringify({ text }), } ); if (!response.ok) { const msg = await formatApiError(response, "Не удалось отправить сообщение"); throw new Error(msg); } return response.json(); } async function uploadAttachmentForMessage(ticketNumber, messageId, file) { const form = new FormData(); form.append("file", file); form.append("message_id", String(messageId)); const attRes = await fetch( `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/attachments`, { method: "POST", headers: clientIdentityHeaders(), body: form, } ); if (!attRes.ok) { const msg = await formatApiError(attRes, "Вложение не принато"); throw new Error(msg); } return attRes; } root.innerHTML = `

F7 поддержка

Мои запросы

Нажмите на запрос, чтобы открыть чат. Закройте окно чата, чтобы выбрать другой запрос.

`; const messageList = document.getElementById("message-list"); const chatModalTitle = document.getElementById("chat-modal-title"); const errorBox = document.getElementById("error-box"); const createModal = document.getElementById("create-ticket-modal"); const chatModal = document.getElementById("chat-modal"); let clientChatDndBound = false; bindClientChatDnDOnce(); function scrollChatToBottom() { const ml = document.getElementById("message-list"); if (!ml) return; const run = () => { try { ml.scrollTop = ml.scrollHeight; } catch (_) {} }; run(); requestAnimationFrame(() => { run(); requestAnimationFrame(run); }); window.setTimeout(run, 80); window.setTimeout(run, 320); } function lastMessageSignatureFromList(msgs) { if (!Array.isArray(msgs) || msgs.length === 0) return null; const last = msgs[msgs.length - 1]; return `${last.id ?? ""}\u0002${last.created_at ?? ""}`; } function syncCurrentTicketLastMsgSigFromState() { if (!state.currentTicket) return; const tn = String(state.currentTicket); const sig = lastMessageSignatureFromList(state.messages); if (sig == null) return; state.ticketLastMsgSig[tn] = sig; delete state.ticketUnreadByLastMsg[tn]; } async function refreshLastMsgSigOne(tn, opts) { const forceBaseline = opts && opts.forceBaseline === true; const key = String(tn); try { const r = await fetch(`${apiBase}/api/client/tickets/${encodeURIComponent(key)}/messages`, { headers: clientIdentityHeaders(), }); if (!r.ok) return; const msgs = await r.json(); const sig = lastMessageSignatureFromList(msgs); if (sig == null) return; const prev = state.ticketLastMsgSig[key]; state.ticketLastMsgSig[key] = sig; if (!forceBaseline && prev != null && sig !== prev) { state.ticketUnreadByLastMsg[key] = true; } } catch (_) {} } async function refreshLastMessageSignaturesStaggered() { const cur = state.chatModalOpen && state.currentTicket != null ? String(state.currentTicket) : null; const openTickets = state.tickets.filter((t) => { const tn = String(t.ticket_number); if (cur && tn === cur) return false; if (String(t.status || "") === "Закрыт") return false; return true; }); if (!openTickets.length) return; const BATCH = 6; const n = openTickets.length; const start = state.msgSigPollIndex % n; const chunk = []; for (let i = 0; i < Math.min(BATCH, n); i++) { chunk.push(String(openTickets[(start + i) % n].ticket_number)); } state.msgSigPollIndex = (state.msgSigPollIndex + chunk.length) % n; await Promise.all(chunk.map((tn) => refreshLastMsgSigOne(tn, null))); } function ticketBoardFingerprint(t) { const act = String(t.activity_at || t.updated_at || t.last_activity_at || t.created_at || ""); const prev = t.preview_text != null ? String(t.preview_text) : ""; const cnt = t.messages_count ?? t.message_count ?? t.msg_count ?? t.replies_count ?? t.reply_count ?? ""; return `${act}\u0001${prev}\u0001${cnt}`; } /** Для открытого чата учитываем последнее сообщение, чтобы отпечаток не отставал от превью в списке. */ function effectiveBoardFingerprint(t) { const base = ticketBoardFingerprint(t); const tn = String(t.ticket_number); if (state.chatModalOpen && state.currentTicket != null && String(state.currentTicket) === tn) { const msgs = state.messages; if (msgs && msgs.length) { const last = msgs[msgs.length - 1]; return `${base}\u0002${last.id ?? ""}\u0003${last.created_at ?? ""}`; } } return base; } function updateBoardUnreadFingerprints() { for (const t of state.tickets) { const tn = String(t.ticket_number); const fp = effectiveBoardFingerprint(t); const seenKey = tn; if (!Object.prototype.hasOwnProperty.call(state.ticketSeenFingerprint, seenKey)) { state.ticketSeenFingerprint[seenKey] = fp; t._f7_board_unread = false; continue; } if (state.chatModalOpen && state.currentTicket != null && String(state.currentTicket) === tn) { state.ticketSeenFingerprint[seenKey] = fp; t._f7_board_unread = false; continue; } t._f7_board_unread = fp !== state.ticketSeenFingerprint[seenKey]; } } function ticketHasUnread(ticket) { const tn = String(ticket.ticket_number); if (ticket && ticket._f7_board_unread) return true; if (state.ticketUnreadByLastMsg[tn]) return true; const raw = ticket?.has_unread ?? ticket?.hasUnread ?? ticket?.unread ?? ticket?.is_unread ?? ticket?.unread_count; if (typeof raw === "number" && raw > 0) return true; return raw === true || raw === 1 || raw === "1" || raw === "true" || raw === "yes"; } function scheduleTicketsBoardPolling() { if (state.ticketsPollTimer) { clearInterval(state.ticketsPollTimer); state.ticketsPollTimer = null; } if (ticketsPollIntervalMs === 0) return; state.ticketsPollTimer = window.setInterval(() => { if (document.visibilityState === "hidden") return; fetchTickets().catch(() => {}); }, ticketsPollIntervalMs); } function showError(message) { errorBox.textContent = message || ""; } function setModalVisible(el, visible) { el.classList.toggle("f7support-modal-visible", visible); el.setAttribute("aria-hidden", visible ? "false" : "true"); } function bindClientChatDnDOnce() { if (clientChatDndBound) return; clientChatDndBound = true; document.querySelectorAll(".f7support-dnd-target").forEach((el) => { el.addEventListener("dragover", (e) => { e.preventDefault(); e.stopPropagation(); el.classList.add("f7support-dnd-dragover"); }); el.addEventListener("dragleave", (e) => { e.preventDefault(); el.classList.remove("f7support-dnd-dragover"); }); el.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); el.classList.remove("f7support-dnd-dragover"); const zone = dndZoneFromElement(el); if (zone && e.dataTransfer?.files?.length) { addFilesToZone(zone, e.dataTransfer.files); } }); }); } function openChatModal(ticket) { state.chatModalOpen = true; const subj = ticket.subject != null ? String(ticket.subject).trim() : ""; chatModalTitle.textContent = subj ? `Чат : ${subj}` : `Чат : Запрос ${ticket.ticket_number}`; setModalVisible(chatModal, true); } async function closeChatModal() { const closingTn = state.currentTicket; if (closingTn != null) { const row = state.tickets.find((x) => String(x.ticket_number) === String(closingTn)); if (row) state.ticketSeenFingerprint[String(closingTn)] = ticketBoardFingerprint(row); delete state.ticketUnreadByLastMsg[String(closingTn)]; await refreshLastMsgSigOne(String(closingTn), { forceBaseline: true }); } disconnectTicketSocket(); revokeAttachmentUrls(); state.chatModalOpen = false; state.currentTicket = null; state.messages = []; messageList.innerHTML = ""; const msgInput = document.getElementById("message-input"); if (msgInput) msgInput.value = ""; clearChatAttachments(); setModalVisible(chatModal, false); showError(""); document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active")); await fetchTickets().catch(() => {}); } async function fetchTickets() { const response = await fetch(`${apiBase}/api/client/tickets`, { headers: { "X-F7cloud-User": username, "X-F7cloud-Server": serverAddress, }, }); if (!response.ok) throw new Error("Не удалось загрузить список обращений"); state.tickets = await response.json(); await refreshLastMessageSignaturesStaggered(); updateBoardUnreadFingerprints(); renderTickets(); } function partitionClientTickets(list) { const buckets = { newT: [], progress: [], closed: [] }; for (const t of list) { const s = String(t.status || ""); if (s === "Закрыт") buckets.closed.push(t); else if (s === "В работе") buckets.progress.push(t); else if (s === "Новый") buckets.newT.push(t); else buckets.newT.push(t); } const byDate = (a, b) => String(b.activity_at || b.created_at || "").localeCompare(String(a.activity_at || a.created_at || "")); buckets.newT.sort(byDate); buckets.progress.sort(byDate); buckets.closed.sort(byDate); return buckets; } function clientTicketStatusClass(status) { const s = String(status || ""); if (s === "Новый") return "f7-ticket-card__status--new"; if (s === "В работе") return "f7-ticket-card__status--progress"; return "f7-ticket-card__status--closed"; } function renderTicketCardHtml(ticket) { const previewRaw = ticket.preview_text != null ? String(ticket.preview_text).trim() : ""; const preview = previewRaw ? escapeHtml(previewRaw) : 'Текст последнего сообщения…'; const activity = ticket.activity_at || ticket.created_at; const time = escapeHtml(formatTicketCardTime(activity)); const hasUnread = ticketHasUnread(ticket); const unread = hasUnread ? '' : ""; const tn = escapeHtml(ticket.ticket_number); const subjRaw = ticket.subject != null ? String(ticket.subject).trim() : ""; const subj = escapeHtml(subjRaw || "—"); const st = escapeHtml(ticket.status); const stCls = clientTicketStatusClass(ticket.status); return `
  • Тема: ${subj} #${tn} ${st}

    ${preview}

    ${unread}
    ${time}
  • `; } async function tryOpenTicket(ticketNumber) { const ticket = state.tickets.find((t) => t.ticket_number === ticketNumber); if (!ticket) return; if (state.chatModalOpen) { if (state.currentTicket === ticketNumber) return; showError("Чтобы открыть другое обращение, сначала закройте окно чата (крестик справа сверху)."); return; } state.currentTicket = ticketNumber; document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active")); const tnSel = String(ticketNumber).replace(/\\/g, "\\\\").replace(/"/g, '\\"'); const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`); if (card) card.classList.add("f7-ticket-card--active"); openChatModal(ticket); delete state.ticketUnreadByLastMsg[String(ticketNumber)]; try { await fetchMessages(); await fetchTickets(); const row = state.tickets.find((x) => x.ticket_number === ticketNumber); if (row) row.has_unread = false; renderTickets(); reapplyActiveTicketCard(ticketNumber); void markClientTicketRead(ticket.ticket_number); connectTicketSocket(ticket.ticket_number); } catch (e) { showError(e.message || "Не удалось загрузить сообщения"); disconnectTicketSocket(); await closeChatModal(); } } function syncHomeLayout() { const home = document.getElementById("f7support-home"); const board = document.getElementById("f7-ticket-board"); const iconWrap = document.querySelector(".f7s-home-icon-wrap"); if (!home) return; const hasTickets = state.tickets.length > 0; home.classList.toggle("f7s-home--empty", !hasTickets); home.classList.toggle("f7s-home--has-tickets", hasTickets); if (board) board.hidden = !hasTickets; if (iconWrap) iconWrap.hidden = hasTickets; } function renderTickets() { const { newT, progress, closed } = partitionClientTickets(state.tickets); const cols = [ document.getElementById("ticket-col-new"), document.getElementById("ticket-col-progress"), document.getElementById("ticket-col-closed"), ]; const data = [newT, progress, closed]; cols.forEach((ul, i) => { if (!ul) return; ul.innerHTML = data[i].map(renderTicketCardHtml).join(""); ul.querySelectorAll(".f7-ticket-card").forEach((li) => { li.onclick = () => tryOpenTicket(li.getAttribute("data-ticket-number")); }); }); syncHomeLayout(); } async function fetchMessages() { if (!state.currentTicket) return; const response = await fetch(`${apiBase}/api/client/tickets/${state.currentTicket}/messages`, { headers: clientIdentityHeaders(), }); if (!response.ok) throw new Error("Не удалось загрузить сообщения"); state.messages = dedupeMessagesById(await response.json()); syncCurrentTicketLastMsgSigFromState(); await renderMessages(); } async function fetchClientAttachmentBlob(attachmentId) { const url = `${apiBase}/api/client/tickets/${state.currentTicket}/attachments/${attachmentId}`; const res = await fetch(url, { headers: clientIdentityHeaders() }); if (!res.ok) throw new Error("attachment"); return res.blob(); } function appendAttachCard(bubble, att) { const mime = typeof att.mime_type === "string" ? att.mime_type : ""; const filename = att.filename || "file"; const sizePart = att.size_bytes != null ? formatBytes(att.size_bytes) : ""; const displayName = formatMsgAttachDisplayName(filename); const card = document.createElement("div"); card.className = "f7s-msg-attach-card"; card.setAttribute("role", "button"); card.tabIndex = 0; const iconEl = document.createElement("div"); iconEl.className = "f7s-msg-attach-card__icon"; const iconImg = document.createElement("img"); iconImg.className = "f7s-msg-attach-card__icon-img"; iconImg.src = f7sAppImgUrl(attachmentIconFile(att)); iconImg.alt = ""; iconImg.width = 22; iconImg.height = 22; iconEl.appendChild(iconImg); const body = document.createElement("div"); body.className = "f7s-msg-attach-card__body"; const fn = document.createElement("div"); fn.className = "f7s-msg-attach-card__name"; fn.textContent = displayName; if (filename.length > MSG_ATTACH_NAME_MAX_LEN) fn.title = filename; const metaLine = document.createElement("div"); metaLine.className = "f7s-msg-attach-card__size"; metaLine.textContent = sizePart || " "; body.append(fn, metaLine); const openFile = async () => { try { const blob = await fetchClientAttachmentBlob(att.id); openOrDownloadBlob(blob, filename, mime); } catch { showError("Не удалось открыть файл"); } }; card.addEventListener("click", () => void openFile()); card.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); void openFile(); } }); card.append(iconEl, body); bubble.appendChild(card); } async function appendImageAttach(bubble, att) { const filename = att.filename || "file"; const sizePart = att.size_bytes != null ? formatBytes(att.size_bytes) : ""; const displayName = formatMsgAttachDisplayName(filename); const head = document.createElement("div"); head.className = "f7s-msg-img-head"; const fn = document.createElement("div"); fn.className = "f7s-msg-img-name"; fn.textContent = displayName; if (filename.length > MSG_ATTACH_NAME_MAX_LEN) fn.title = filename; const sz = document.createElement("div"); sz.className = "f7s-msg-img-size"; sz.textContent = sizePart; head.append(fn, sz); bubble.appendChild(head); const wrap = document.createElement("div"); wrap.className = "f7s-msg-img-wrap"; const img = document.createElement("img"); img.className = "f7s-msg-img"; img.alt = filename; wrap.appendChild(img); bubble.appendChild(wrap); try { const blob = await fetchClientAttachmentBlob(att.id); const u = URL.createObjectURL(blob); state.blobUrls.push(u); img.src = u; img.addEventListener("load", () => scrollChatToBottom(), { once: true }); img.addEventListener("click", () => { try { window.open(u, "_blank", "noopener,noreferrer"); } catch (_) {} }); } catch { const err = document.createElement("div"); err.className = "f7s-img-err"; err.textContent = `Не удалось показать изображение: ${displayName}`; wrap.appendChild(err); } } async function renderMessages() { revokeAttachmentUrls(); messageList.innerHTML = ""; const ticketNo = state.currentTicket; if (!ticketNo) return; const rows = buildRenderableMessageRows(state.messages); for (const { m, groupPos, showSender, outgoing } of rows) { const text = String(m.text ?? "").trim(); const attachments = messageAttachmentsFrom(m); const showText = text && !isMessageBodyPlaceholder(text); const article = document.createElement("article"); article.className = `f7s-msg f7s-msg--${outgoing ? "out" : "in"} f7s-msg--${groupPos}`; if (m.id != null) article.dataset.f7MsgId = String(m.id); if (!outgoing && showSender) { const sender = document.createElement("div"); sender.className = "f7s-msg-sender"; const avatar = document.createElement("span"); avatar.className = "f7s-msg-avatar"; avatar.textContent = "F7"; const name = document.createElement("span"); name.className = "f7s-msg-sender-name"; name.textContent = supportSenderLabel(m); sender.append(avatar, name); article.appendChild(sender); } const bubble = document.createElement("div"); bubble.className = `f7s-msg-bubble f7s-msg-bubble--${groupPos}`; if (attachments.length > 0) { bubble.classList.add("f7s-msg-bubble--has-attach"); const onlyImages = attachments.every( (a) => String(a.mime_type || "") .toLowerCase() .startsWith("image/") || isRasterImageAtt(a) ); if (onlyImages && !showText) bubble.classList.add("f7s-msg-bubble--image"); } if (showText) { const p = document.createElement("p"); p.className = "f7s-msg-text"; p.textContent = text; bubble.appendChild(p); } for (const att of attachments) { const mime = typeof att.mime_type === "string" ? att.mime_type : ""; const isImg = mime.startsWith("image/") || isRasterImageAtt(att); if (isImg) { await appendImageAttach(bubble, att); } else { appendAttachCard(bubble, att); } } const meta = document.createElement("div"); meta.className = "f7s-msg-meta"; const timeEl = document.createElement("time"); timeEl.className = "f7s-msg-time"; if (m.created_at) timeEl.dateTime = String(m.created_at); timeEl.textContent = formatMsgTime(m.created_at); meta.appendChild(timeEl); if (outgoing) { const read = document.createElement("span"); read.className = "f7s-msg-read"; const isRead = clientMessageIsRead(m); read.setAttribute("aria-label", isRead ? "Прочитано" : "Доставлено"); read.innerHTML = isRead ? CHAT_MSG_READ_SVG : CHAT_MSG_UNREAD_SVG; meta.appendChild(read); } bubble.appendChild(meta); article.appendChild(bubble); messageList.appendChild(article); } scrollChatToBottom(); } async function submitTicketWithRetry(payload) { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const duplicate = attempt > 1 ? 1 : 0; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 180000); try { const response = await fetch(`${apiBase}/api/client/tickets`, { method: "POST", headers: { "Content-Type": "application/json", ...clientIdentityHeaders(), }, body: JSON.stringify({ ...payload, duplicate }), signal: controller.signal, }); clearTimeout(timeout); if (!response.ok) throw new Error("Send failed"); return await response.json(); } catch (e) { clearTimeout(timeout); if (attempt === maxAttempts) { throw new Error("Сервер поддержки недоступен, пожалуйста обратитесь к администратору."); } } } throw new Error("Unknown error"); } document.getElementById("create-ticket-btn").onclick = () => { setModalVisible(createModal, true); showError(""); }; document.getElementById("close-modal-btn").onclick = () => { document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; clearCreateTicketAttachments(); setModalVisible(createModal, false); }; document.getElementById("chat-modal-close-btn").onclick = () => { void closeChatModal(); }; document.getElementById("create-ticket-attachment-input")?.addEventListener("change", onCreateTicketAttachmentChange); document.getElementById("attachment-input")?.addEventListener("change", onChatAttachmentChange); const bodyInputEl = document.getElementById("body-input"); bodyInputEl?.addEventListener("paste", (e) => { const files = e.clipboardData?.files; if (files && files.length) { e.preventDefault(); addFilesToZone("create", files); } }); const messageInputEl = document.getElementById("message-input"); messageInputEl?.addEventListener("paste", (e) => { const files = e.clipboardData?.files; if (files && files.length) { e.preventDefault(); addFilesToZone("chat", files); } }); document.getElementById("submit-ticket-btn").onclick = async () => { try { const subject = document.getElementById("subject-input").value.trim(); const body = document.getElementById("body-input").value.trim(); if (!subject || !body) { showError("Тема и обращение обязательны"); return; } const result = await submitTicketWithRetry({ server_address: serverAddress, username, subject, body: TICKET_CREATE_BODY_PLACEHOLDER, }); const ticketNo = result.ticket_number; let createdMsg; try { createdMsg = await postMessageToTicket(ticketNo, body); } catch (e) { showError( e.message || `Токен ${ticketNo} создан, но текст обращения не попал в чат. Откройте тикет и напишите сообщение вручную.` ); setModalVisible(createModal, false); document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; clearCreateTicketAttachments(); await fetchTickets(); return; } const firstMessageId = createdMsg.id ?? createdMsg.message_id; const createFiles = [...state.createTicketAttachmentFiles]; state.chatOutboundBusy = true; try { if (createFiles.length > 0) { if (firstMessageId == null) { showError(`Токен ${ticketNo} создан, но сервер не вернул id сообщения — вложения не отправлены.`); } else { try { for (const f of createFiles) { await uploadAttachmentForMessage(ticketNo, firstMessageId, f); } } catch (attErr) { showError( attErr.message || `Токен ${ticketNo} создан, текст в чате есть, но не все вложения удалось отправить.` ); } } } } finally { state.chatOutboundBusy = false; } setModalVisible(createModal, false); document.getElementById("subject-input").value = ""; document.getElementById("body-input").value = ""; clearCreateTicketAttachments(); showError(`Токен создан: ${ticketNo}`); await fetchTickets(); void tryOpenTicket(ticketNo); } catch (e) { showError(e.message || "Ошибка отправки"); } }; document.getElementById("send-message-btn").onclick = async () => { if (!state.currentTicket) { showError("Сначала выберите токен"); return; } let text = document.getElementById("message-input").value.trim(); const chatFiles = [...state.chatAttachmentFiles]; if (!text && !chatFiles.length) { showError("Введите сообщение или прикрепите файл"); return; } if (!text && chatFiles.length) { text = MESSAGE_BODY_PLACEHOLDER; } showError(""); state.chatOutboundBusy = true; let created; try { created = await postMessageToTicket(state.currentTicket, text); const messageId = created.id ?? created.message_id; if (chatFiles.length > 0) { if (messageId == null) { showError("Сообщение создано, но сервер не вернул id — вложения не отправлены"); clearChatAttachments(); } else { try { for (const f of chatFiles) { await uploadAttachmentForMessage(state.currentTicket, messageId, f); } } catch (e) { showError(e.message || "Сообщение отправлено, но не все вложения приняты"); } clearChatAttachments(); } } document.getElementById("message-input").value = ""; await fetchMessages(); } catch (e) { showError(e.message || "Не удалось отправить сообщение"); } finally { state.chatOutboundBusy = false; } }; syncCreateTicketAttachmentsUI(); syncChatAttachmentsUI(); queueMicrotask(() => { fetchTickets() .catch((e) => showError(e.message)) .finally(() => { scheduleTicketsBoardPolling(); }); }); })();