Initial import of f7support application.

This commit is contained in:
root
2026-05-05 11:01:54 +03:00
commit 1a58baebb2
7 changed files with 915 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0"?>
<info>
<id>f7support</id>
<name>f7support</name>
<summary>Support ticket client for F7cloud (F7cloud-compatible)</summary>
<description>f7support client app for creating and viewing support tickets.</description>
<version>0.1.0</version>
<licence>AGPL</licence>
<author>f7support team</author>
<namespace>F7Support</namespace>
<types>
<type>filesystem</type>
</types>
<dependencies>
<f7cloud min-version="32" max-version="32"/>
</dependencies>
</info>
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'routes' => [
['name' => 'page#index', 'url' => '/', 'verb' => 'GET'],
],
];
+201
View File
@@ -0,0 +1,201 @@
/* f7support UI — вынесено из innerHTML для меньшего парсинга JS и кэширования CSS ядром NC */
#f7support-app {
box-sizing: border-box;
font-family: sans-serif;
}
#f7support-app *,
#f7support-app *::before,
#f7support-app *::after {
box-sizing: inherit;
}
#f7support-app .f7s-main {
padding: 16px;
max-width: 720px;
}
#f7support-app .f7s-error {
color: #b00020;
}
#f7support-app .f7s-section-title {
margin-top: 16px;
}
#f7support-app .f7s-hint {
color: #666;
font-size: 14px;
margin: 4px 0 8px;
}
#f7support-app .f7s-ticket-list {
padding-left: 20px;
margin: 0;
}
#f7support-app .f7s-ticket-item {
cursor: pointer;
margin-bottom: 6px;
}
#f7support-app .f7s-modal {
display: none;
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
}
#f7support-app .f7s-modal.f7s-open {
display: flex;
}
#f7support-app .f7s-modal--create {
z-index: 1000;
}
#f7support-app .f7s-modal--chat {
z-index: 1100;
}
#f7support-app .f7s-modal-panel {
background: #fff;
border: 1px solid #333;
padding: 16px;
width: min(560px, 92vw);
max-height: 85vh;
overflow: auto;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
#f7support-app .f7s-modal-head {
display: flex;
justify-content: space-between;
align-items: center;
}
#f7support-app .f7s-modal-head h3 {
margin: 0;
}
#f7support-app .f7s-btn-icon {
font-size: 20px;
line-height: 1;
border: none;
background: transparent;
cursor: pointer;
}
#f7support-app .f7s-btn-icon--lg {
font-size: 22px;
padding: 4px 8px;
}
#f7support-app .f7s-input {
width: 100%;
margin-top: 8px;
}
#f7support-app .f7s-textarea {
width: 100%;
height: 140px;
margin-top: 8px;
}
#f7support-app .f7s-textarea--message {
min-height: 72px;
height: auto;
}
#f7support-app .f7s-mt-8 {
margin-top: 8px;
}
#f7support-app .f7s-chat-shell {
background: #fff;
width: min(720px, 94vw);
max-height: 88vh;
display: flex;
flex-direction: column;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
overflow: hidden;
}
#f7support-app .f7s-chat-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
flex-shrink: 0;
}
#f7support-app .f7s-chat-title {
margin: 0;
font-size: 1.1rem;
}
#f7support-app .f7s-chat-error {
margin: 0;
padding: 8px 16px;
min-height: 1.2em;
}
#f7support-app .f7s-message-list {
flex: 1;
min-height: 220px;
max-height: 45vh;
overflow: auto;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
padding: 12px 16px;
}
#f7support-app .f7s-chat-footer {
padding: 12px 16px 16px;
flex-shrink: 0;
}
#f7support-app .f7s-chat-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 8px;
}
#f7support-app .f7s-msg-row {
margin-bottom: 12px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
#f7support-app .f7s-img-wrap {
margin-top: 8px;
}
#f7support-app .f7s-msg-img {
max-width: 100%;
max-height: 260px;
object-fit: contain;
border: 1px solid #ddd;
border-radius: 4px;
}
#f7support-app .f7s-img-err {
color: #888;
font-size: 13px;
}
#f7support-app .f7s-attach-row {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
+616
View File
@@ -0,0 +1,616 @@
/**
* Производительность: список тикетов — после первого кадра (requestIdleCallback / queueMicrotask);
* кэш списка в sessionStorage на время вкладки; обновление при возврате фокуса.
* Скрипт подключается через Util::addScript — ядро NC уже даёт defer и ?v=… (версия приложения / хэш).
* «Живой» чат без опроса — только WebSocket/SSE на apiBase (отдельная задача бэкенда).
*/
(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 messagesPollMs = Math.max(2000, parseInt(root.dataset.messagesPollMs, 10) || 5000);
/** @type {ReturnType<typeof setInterval> | null} */
let chatMessagesPollTimer = null;
const state = {
tickets: [],
currentTicket: null,
messages: [],
/** Preview images in the message list (revoked on re-render / close chat). */
/** @type {string[]} */
previewBlobUrls: [],
};
function clientHeaders(extra) {
const h = {
"X-F7cloud-User": username,
"X-F7cloud-Server": serverAddress,
};
if (extra && typeof extra === "object") {
Object.assign(h, extra);
}
return h;
}
function revokePreviewBlobs() {
for (const u of state.previewBlobUrls) {
try {
URL.revokeObjectURL(u);
} catch {
/* ignore */
}
}
state.previewBlobUrls = [];
}
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 {
/* ignore */
}
return fallback;
}
function formatBytes(n) {
if (n == null || Number.isNaN(n)) return "";
const u = ["байт", "КБ", "МБ", "ГБ"];
let v = n;
let i = 0;
while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i++;
}
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
}
root.innerHTML = `
<div class="f7s-main">
<h2>f7support</h2>
<button id="create-ticket-btn" type="button">Создать токен</button>
<p id="error-box" class="f7s-error"></p>
<h3 class="f7s-section-title">Мои токены</h3>
<p id="ticket-hint" class="f7s-hint">Нажмите на токен, чтобы открыть чат. Закройте окно чата, чтобы выбрать другой токен.</p>
<ul id="ticket-list" class="f7s-ticket-list"></ul>
</div>
<div id="create-ticket-modal" class="f7s-modal f7s-modal--create">
<div class="f7s-modal-panel">
<div class="f7s-modal-head">
<h3>Создание токена</h3>
<button type="button" id="close-modal-btn" class="f7s-btn-icon" aria-label="Закрыть">✕</button>
</div>
<input id="subject-input" class="f7s-input" maxlength="255" placeholder="Тема" />
<textarea id="body-input" class="f7s-input f7s-textarea" maxlength="4000" placeholder="Опишите проблему (до 4000 символов)"></textarea>
<div class="f7s-chat-actions f7s-mt-8">
<input id="create-ticket-attachment-input" type="file" multiple />
</div>
<button type="button" id="submit-ticket-btn" class="f7s-mt-8">Отправить</button>
</div>
</div>
<div id="chat-modal" class="f7s-modal f7s-modal--chat">
<div class="f7s-chat-shell" role="dialog" aria-modal="true" aria-labelledby="chat-title">
<div class="f7s-chat-head">
<h3 id="chat-title" class="f7s-chat-title">Чат</h3>
<button type="button" id="close-chat-btn" class="f7s-btn-icon f7s-btn-icon--lg" aria-label="Закрыть чат">✕</button>
</div>
<p id="chat-error-box" class="f7s-error f7s-chat-error"></p>
<div id="message-list" class="f7s-message-list"></div>
<div class="f7s-chat-footer">
<textarea id="message-input" class="f7s-input f7s-textarea f7s-textarea--message" maxlength="4000" placeholder="Сообщение (до 4000 символов)"></textarea>
<div class="f7s-chat-actions">
<input id="attachment-input" type="file" />
<button type="button" id="send-message-btn">Отправить</button>
</div>
</div>
</div>
</div>
`;
const ticketList = document.getElementById("ticket-list");
const messageList = document.getElementById("message-list");
const chatTitle = document.getElementById("chat-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");
function showError(message) {
errorBox.textContent = message || "";
}
function showChatError(message) {
chatErrorBox.textContent = message || "";
}
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 startChatMessagesPoll() {
stopChatMessagesPoll();
chatMessagesPollTimer = setInterval(() => {
if (document.hidden) return;
if (!state.currentTicket || !isChatOpen()) return;
void loadMessages(false, false);
}, messagesPollMs);
}
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");
}
/**
* @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;
}
}
function closeChatModal() {
stopChatMessagesPoll();
revokePreviewBlobs();
chatModal.classList.remove("f7s-open");
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 = "Чат";
}
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;
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 scheduleTicketsBootstrap() {
const cached = readTicketsCache();
if (cached) {
applyTickets(cached);
}
const run = () => {
fetchTicketsFromNetwork().catch((e) => {
if (!cached) {
showError(e.message);
}
});
};
if (typeof requestIdleCallback !== "undefined") {
requestIdleCallback(run, { timeout: 2500 });
} else {
queueMicrotask(() => setTimeout(run, 0));
}
}
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(),
});
if (!res.ok) throw new Error("Не удалось загрузить вложение");
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";
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);
}
}
messageList.appendChild(row);
}
messageList.scrollTop = messageList.scrollHeight;
}
/** OpenAPI TicketCreate требует body minLength:1; реальный текст уходит первым сообщением в чат. */
const TICKET_CREATE_BODY_PLACEHOLDER = ".";
/**
* POST текстового сообщения в тикет (как в форме чата).
* @returns {Promise<object>} тело ответа API
*/
async function postMessageToTicket(ticketNumber, text) {
const response = await fetch(
`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/messages`,
{
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ text }),
}
);
if (!response.ok) {
const msg = await formatApiError(response, "Не удалось отправить сообщение");
throw new Error(msg);
}
return response.json();
}
/**
* POST одного файла к сообщению (message_id в query).
* @param {string|number} messageId
* @param {File} file
*/
async function uploadAttachmentForMessage(ticketNumber, messageId, file) {
const form = new FormData();
form.append("file", file);
const q = new URLSearchParams({ message_id: String(messageId) });
const attRes = await fetch(
`${apiBase}/api/client/tickets/${encodeURIComponent(ticketNumber)}/attachments?${q.toString()}`,
{
method: "POST",
headers: clientHeaders(),
body: form,
}
);
if (!attRes.ok) {
const msg = await formatApiError(attRes, "Вложение не принято");
throw new Error(msg);
}
return attRes;
}
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" },
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("close-chat-btn").onclick = () => {
closeChatModal();
};
document.getElementById("create-ticket-btn").onclick = () => {
createModal.classList.add("f7s-open");
showError("");
};
document.getElementById("close-modal-btn").onclick = () => {
document.getElementById("subject-input").value = "";
document.getElementById("body-input").value = "";
const catt = document.getElementById("create-ticket-attachment-input");
if (catt) catt.value = "";
createModal.classList.remove("f7s-open");
};
createModal.addEventListener("click", (ev) => {
if (ev.target === createModal) {
document.getElementById("close-modal-btn").click();
}
});
chatModal.addEventListener("click", (ev) => {
if (ev.target === chatModal) {
closeChatModal();
}
});
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} создан, но текст обращения не попал в чат. Откройте тикет и напишите сообщение вручную.`
);
createModal.classList.remove("f7s-open");
document.getElementById("subject-input").value = "";
document.getElementById("body-input").value = "";
const cattFail = document.getElementById("create-ticket-attachment-input");
if (cattFail) cattFail.value = "";
await fetchTicketsFromNetwork();
return;
}
const firstMessageId = createdMsg.id ?? createdMsg.message_id;
const createAtt = document.getElementById("create-ticket-attachment-input");
const createFiles = createAtt && createAtt.files ? Array.from(createAtt.files) : [];
if (createFiles.length > 0) {
if (firstMessageId == null) {
showError(`Токен ${ticketNo} создан, но сервер не вернул id сообщения — вложения не отправлены.`);
createAtt.value = "";
} else {
try {
for (const f of createFiles) {
await uploadAttachmentForMessage(ticketNo, firstMessageId, f);
}
} catch (attErr) {
showError(
attErr.message ||
`Токен ${ticketNo} создан, текст в чате есть, но не все вложения удалось отправить.`
);
}
}
createAtt.value = "";
}
createModal.classList.remove("f7s-open");
document.getElementById("subject-input").value = "";
document.getElementById("body-input").value = "";
showError(`Токен создан: ${ticketNo}`);
await fetchTicketsFromNetwork();
void openTicketChat(ticketNo);
} catch (e) {
showError(e.message || "Ошибка отправки");
}
};
document.getElementById("send-message-btn").onclick = async () => {
if (!state.currentTicket) {
showChatError("Сначала выберите токен");
return;
}
const text = document.getElementById("message-input").value.trim();
if (!text) return;
showChatError("");
let created;
try {
created = await postMessageToTicket(state.currentTicket, text);
} catch (e) {
showChatError(e.message || "Не удалось отправить сообщение");
return;
}
const messageId = created.id ?? created.message_id;
const fileInput = document.getElementById("attachment-input");
if (fileInput.files && fileInput.files[0]) {
if (messageId == null) {
showChatError("Сообщение создано, но сервер не вернул id — вложение не отправлено");
fileInput.value = "";
} else {
try {
await uploadAttachmentForMessage(state.currentTicket, messageId, fileInput.files[0]);
} catch (e) {
showChatError(e.message || "Сообщение отправлено, но вложение не принято");
}
fileInput.value = "";
}
}
document.getElementById("message-input").value = "";
await loadMessages(true, false);
};
document.addEventListener("visibilitychange", () => {
if (document.hidden) return;
void fetchTicketsFromNetwork().catch(() => {});
if (isChatOpen() && state.currentTicket) {
void loadMessages(false, false);
}
});
scheduleTicketsBootstrap();
})();
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace OCA\F7Support;
use OCP\AppFramework\App;
class Application extends App {
public const APP_ID = 'f7support';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace OCA\F7Support\Controller;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Util;
class PageController extends Controller {
public function __construct(
string $AppName,
IRequest $request,
private IUserSession $userSession,
private IURLGenerator $urlGenerator
) {
parent::__construct($AppName, $request);
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*/
public function index(): TemplateResponse {
$user = $this->userSession->getUser();
$baseUrl = $this->urlGenerator->getBaseUrl();
$serverHost = parse_url($baseUrl, PHP_URL_HOST) ?: 'localhost';
$supportApiBase = 'https://support.f7cloud.ru';
$supportParts = parse_url($supportApiBase);
$supportApiOrigin = ($supportParts['scheme'] ?? 'https') . '://' . ($supportParts['host'] ?? '');
Util::addStyle('f7support', 'f7support');
Util::addScript('f7support', 'main');
return new TemplateResponse('f7support', 'main', [
'username' => $user ? $user->getUID() : '',
'serverAddress' => $serverHost,
'supportApiBase' => $supportApiBase,
'supportApiOrigin' => $supportApiOrigin,
]);
}
}
+8
View File
@@ -0,0 +1,8 @@
<link rel="dns-prefetch" href="<?php p($_['supportApiOrigin']); ?>">
<link rel="preconnect" href="<?php p($_['supportApiOrigin']); ?>" crossorigin>
<div id="f7support-app"
data-username="<?php p($_['username']); ?>"
data-server-address="<?php p($_['serverAddress']); ?>"
data-support-api-base="<?php p($_['supportApiBase']); ?>"
data-messages-poll-ms="5000">
</div>