diff --git a/appinfo/info.xml b/appinfo/info.xml
index 40927af..38ed2ab 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -4,7 +4,7 @@
f7support
Support ticket client for F7cloud (F7cloud-compatible)
f7support client app for creating and viewing support tickets.
- 0.1.0
+ 0.1.1
AGPL
f7support team
F7Support
@@ -14,5 +14,11 @@
+
+
+ f7support
+ 30
+
+
diff --git a/css/f7support.css b/css/f7support.css
index d08734c..c002ca1 100644
--- a/css/f7support.css
+++ b/css/f7support.css
@@ -1,5 +1,4 @@
-/* f7support UI — вынесено из innerHTML для меньшего парсинга JS и кэширования CSS ядром NC */
-
+/* Базовая изоляция; основной UI — в
`;
- const ticketList = document.getElementById("ticket-list");
const messageList = document.getElementById("message-list");
- const chatTitle = document.getElementById("chat-title");
+ const chatModalTitle = document.getElementById("chat-modal-title");
const errorBox = document.getElementById("error-box");
- const chatErrorBox = document.getElementById("chat-error-box");
const createModal = document.getElementById("create-ticket-modal");
const chatModal = document.getElementById("chat-modal");
+ bindClientChatDnDOnce();
+
function showError(message) {
errorBox.textContent = message || "";
}
- function showChatError(message) {
- chatErrorBox.textContent = message || "";
+ function setModalVisible(el, visible) {
+ el.classList.toggle("f7support-modal-visible", visible);
+ el.setAttribute("aria-hidden", visible ? "false" : "true");
}
- function isChatOpen() {
- return chatModal.classList.contains("f7s-open");
- }
-
- function openChatModal() {
- chatModal.classList.add("f7s-open");
- }
-
- function stopChatMessagesPoll() {
- if (chatMessagesPollTimer !== null) {
- clearInterval(chatMessagesPollTimer);
- chatMessagesPollTimer = null;
+ function updatePendingFileUi() {
+ const row = document.getElementById("pending-attachment-row");
+ const label = document.getElementById("pending-attachment-label");
+ if (!row || !label) return;
+ if (state.pendingFile) {
+ label.textContent = `Вложение: ${state.pendingFile.name}`;
+ row.hidden = false;
+ } else {
+ row.hidden = true;
+ label.textContent = "";
}
}
- function startChatMessagesPoll() {
- stopChatMessagesPoll();
- chatMessagesPollTimer = setInterval(() => {
- if (document.hidden) return;
- if (!state.currentTicket || !isChatOpen()) return;
- void loadMessages(false, false);
- }, messagesPollMs);
+ function setPendingFile(file) {
+ if (!file) return;
+ state.pendingFile = file;
+ updatePendingFileUi();
}
- function messagesFingerprint(list) {
- if (!Array.isArray(list)) return "";
- return list
- .map((m) => {
- const attIds = (Array.isArray(m.attachments) ? m.attachments : [])
- .map((a) => a.id)
- .join(",");
- return `${m.id}|${m.created_at ?? ""}|${(m.text || "").length}|${attIds}`;
- })
- .join("\n");
+ function clearPendingFile() {
+ state.pendingFile = null;
+ const inp = document.getElementById("attachment-input");
+ if (inp) inp.value = "";
+ updatePendingFileUi();
}
- /**
- * @param {boolean} force — всегда перерисовать (после отправки / первое открытие)
- * @param {boolean} showErrors — показывать ошибку в UI (только при явной загрузке)
- */
- async function loadMessages(force, showErrors) {
- if (!state.currentTicket) return;
- try {
- const response = await fetch(
- `${apiBase}/api/client/tickets/${encodeURIComponent(state.currentTicket)}/messages`,
- { headers: clientHeaders() }
- );
- if (!response.ok) {
- if (showErrors) throw new Error("Не удалось загрузить сообщения");
- return;
- }
- const next = await response.json();
- if (!force && messagesFingerprint(next) === messagesFingerprint(state.messages)) {
- return;
- }
- state.messages = next;
- await renderMessages();
- } catch (e) {
- if (showErrors) throw e;
- }
+ let clientChatDndBound = false;
+ 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 f = e.dataTransfer?.files?.[0];
+ if (f) setPendingFile(f);
+ });
+ });
+ }
+
+ function openChatModal(ticket) {
+ state.chatModalOpen = true;
+ chatModalTitle.textContent = `Чат #${ticket.ticket_number} — ${ticket.subject}`;
+ setModalVisible(chatModal, true);
}
function closeChatModal() {
- stopChatMessagesPoll();
- revokePreviewBlobs();
- chatModal.classList.remove("f7s-open");
+ disconnectTicketSocket();
+ revokeAttachmentUrls();
+ state.chatModalOpen = false;
state.currentTicket = null;
state.messages = [];
messageList.innerHTML = "";
const msgInput = document.getElementById("message-input");
- const fileInput = document.getElementById("attachment-input");
if (msgInput) msgInput.value = "";
- if (fileInput) fileInput.value = "";
- showChatError("");
- chatTitle.textContent = "Чат";
+ clearPendingFile();
+ setModalVisible(chatModal, false);
+ showError("");
+ document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active"));
+ fetchTickets().catch(() => {});
}
- function ticketsStorageKey() {
- return `f7support.tickets.v1|${username}|${serverAddress}`;
- }
-
- function readTicketsCache() {
- try {
- const raw = sessionStorage.getItem(ticketsStorageKey());
- if (!raw) return null;
- const j = JSON.parse(raw);
- return Array.isArray(j) ? j : null;
- } catch {
- return null;
- }
- }
-
- function writeTicketsCache(list) {
- try {
- sessionStorage.setItem(ticketsStorageKey(), JSON.stringify(list));
- } catch {
- /* quota / private mode */
- }
- }
-
- function applyTickets(list) {
- state.tickets = list;
+ 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();
renderTickets();
}
- async function fetchTicketsFromNetwork() {
- const response = await fetch(`${apiBase}/api/client/tickets`, {
- headers: clientHeaders(),
- });
- if (!response.ok) throw new Error("Не удалось получить токены");
- const list = await response.json();
- writeTicketsCache(list);
- applyTickets(list);
+ 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 scheduleTicketsBootstrap() {
- const cached = readTicketsCache();
- if (cached) {
- applyTickets(cached);
+ 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 ? ticket.preview_text : "";
+ const preview = escapeHtml(previewRaw);
+ const activity = ticket.activity_at || ticket.created_at;
+ const time = escapeHtml(formatTicketCardTime(activity));
+ const hasUnread = Boolean(ticket.has_unread);
+ const unread = hasUnread
+ ? ''
+ : "";
+ const tn = escapeHtml(ticket.ticket_number);
+ const st = escapeHtml(ticket.status);
+ const stCls = clientTicketStatusClass(ticket.status);
+ return `
+
+ Тема#${tn}
+ ${st}
+
+
+
${preview || "—"}
+ ${unread}
+
+
+ `;
+ }
+
+ 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;
}
- const run = () => {
- fetchTicketsFromNetwork().catch((e) => {
- if (!cached) {
- showError(e.message);
- }
- });
- };
- if (typeof requestIdleCallback !== "undefined") {
- requestIdleCallback(run, { timeout: 2500 });
- } else {
- queueMicrotask(() => setTimeout(run, 0));
+ 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);
+ try {
+ await fetchMessages();
+ await markClientTicketRead(ticket.ticket_number);
+ connectTicketSocket(ticket.ticket_number);
+ } catch (e) {
+ showError(e.message || "Не удалось загрузить сообщения");
+ disconnectTicketSocket();
+ closeChatModal();
}
}
function renderTickets() {
- ticketList.innerHTML = "";
- for (const ticket of state.tickets) {
- const li = document.createElement("li");
- li.className = "f7s-ticket-item";
- li.textContent = `${ticket.ticket_number} — ${ticket.subject} [${ticket.status}]`;
- li.onclick = () => {
- if (isChatOpen()) {
- return;
- }
- void openTicketChat(ticket.ticket_number);
- };
- ticketList.appendChild(li);
- }
- }
-
- async function openTicketChat(ticketNumber) {
- showError("");
- state.currentTicket = ticketNumber;
- chatTitle.textContent = `Чат #${ticketNumber}`;
- showChatError("");
- openChatModal();
- try {
- await loadMessages(true, true);
- startChatMessagesPoll();
- } catch (e) {
- showChatError(e.message || "Ошибка загрузки");
- startChatMessagesPoll();
- }
- }
-
- function attachmentDownloadUrl(ticketNumber, attachmentId) {
- return `${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/attachments/${encodeURIComponent(String(attachmentId))}`;
- }
-
- async function fetchAttachmentBlob(ticketNumber, attachmentId) {
- const res = await fetch(attachmentDownloadUrl(ticketNumber, attachmentId), {
- headers: clientHeaders(),
+ 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"));
+ });
});
- if (!res.ok) throw new Error("Не удалось загрузить вложение");
+ }
+
+ 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 = await response.json();
+ 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();
}
- async function renderMessages() {
- revokePreviewBlobs();
- messageList.innerHTML = "";
- const ticketNo = state.currentTicket;
- if (!ticketNo) return;
-
- for (const m of state.messages) {
- const row = document.createElement("div");
- row.className = "f7s-msg-row";
-
- const line = document.createElement("div");
- const b = document.createElement("b");
- b.textContent = m.author ?? "";
- line.appendChild(b);
- line.appendChild(document.createTextNode(` (${m.author_role ?? ""}): `));
- const textPart = document.createElement("span");
- textPart.textContent = m.text ?? "";
- line.appendChild(textPart);
- row.appendChild(line);
-
- const attachments = Array.isArray(m.attachments) ? m.attachments : [];
- for (const att of attachments) {
- 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)}` : "";
-
- if (mime.startsWith("image/")) {
- const wrap = document.createElement("div");
- wrap.className = "f7s-img-wrap";
+ function hydrateClientAttachments(scope) {
+ const root = scope || messageList;
+ if (!root || !state.currentTicket) return;
+ root.querySelectorAll("[data-f7-att-preview]").forEach((wrap) => {
+ const id = wrap.getAttribute("data-f7-att-preview");
+ (async () => {
+ try {
+ const blob = await fetchClientAttachmentBlob(id);
+ const objUrl = URL.createObjectURL(blob);
+ state.blobUrls.push(objUrl);
+ const frame = document.createElement("div");
+ frame.className = "chat-inline-img-wrap";
const img = document.createElement("img");
- img.className = "f7s-msg-img";
- img.alt = filename;
- wrap.appendChild(img);
- row.appendChild(wrap);
- try {
- const blob = await fetchAttachmentBlob(ticketNo, att.id);
- const u = URL.createObjectURL(blob);
- state.previewBlobUrls.push(u);
- img.src = u;
- } catch {
- const err = document.createElement("div");
- err.className = "f7s-img-err";
- err.textContent = `Не удалось показать изображение: ${filename}`;
- wrap.appendChild(err);
- }
- } else {
- const wrap = document.createElement("div");
- wrap.className = "f7s-attach-row";
- const label = document.createElement("span");
- label.textContent = `${filename}${sizePart}`;
- const btn = document.createElement("button");
- btn.type = "button";
- btn.textContent = "Скачать";
- btn.onclick = async () => {
- let objectUrl = null;
- try {
- const blob = await fetchAttachmentBlob(ticketNo, att.id);
- objectUrl = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = objectUrl;
- a.download = filename;
- a.rel = "noopener";
- a.style.display = "none";
- document.body.appendChild(a);
- a.click();
- a.remove();
- setTimeout(() => {
- try {
- URL.revokeObjectURL(objectUrl);
- } catch {
- /* ignore */
- }
- }, 60_000);
- } catch {
- showChatError("Не удалось скачать вложение");
- if (objectUrl) {
- try {
- URL.revokeObjectURL(objectUrl);
- } catch {
- /* ignore */
- }
- }
- }
- };
- wrap.appendChild(label);
- wrap.appendChild(btn);
- row.appendChild(wrap);
+ img.className = "chat-inline-img";
+ img.src = objUrl;
+ img.alt = "";
+ img.loading = "lazy";
+ frame.appendChild(img);
+ wrap.replaceChildren(frame);
+ } catch (_) {
+ wrap.textContent = "Не удалось показать файл";
}
- }
+ })();
+ });
- messageList.appendChild(row);
- }
- messageList.scrollTop = messageList.scrollHeight;
+ root.querySelectorAll("[data-f7-att-file]").forEach((row) => {
+ const handler = async () => {
+ const id = row.getAttribute("data-f7-att-file");
+ const fn = row.getAttribute("data-f7-filename") || "file";
+ const mime = row.getAttribute("data-f7-mime") || "";
+ try {
+ const blob = await fetchClientAttachmentBlob(id);
+ openOrDownloadBlob(blob, fn, mime);
+ } catch (_) {
+ showError("Не удалось открыть файл");
+ }
+ };
+ row.onclick = handler;
+ row.onkeydown = (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handler();
+ }
+ };
+ });
}
- /** OpenAPI TicketCreate требует body minLength:1; реальный текст уходит первым сообщением в чат. */
- const TICKET_CREATE_BODY_PLACEHOLDER = ".";
-
- /**
- * POST текстового сообщения в тикет (как в форме чата).
- * @returns {Promise