f7support: опрос доски тикетов, скролл чата, непрочитанные

- Периодический опрос списка тикетов (tickets_poll_ms, по умолчанию 3 с), пока вкладка видима.

- Параметр конфига tickets_poll_ms (3000–120000 мс) и data-messages-poll-ms в шаблоне.

- Надёжный скролл вниз при открытии чата и после загрузки вложений.

- Учёт has_unread как true/1/"1"/"true" для индикатора на карточке.
This commit is contained in:
root
2026-05-14 14:52:02 +03:00
parent b52655c26b
commit 866a64d413
3 changed files with 60 additions and 4 deletions
+48 -3
View File
@@ -8,6 +8,11 @@
const supportWsBaseOverride = (root.dataset.supportWsBase || "").trim(); const supportWsBaseOverride = (root.dataset.supportWsBase || "").trim();
/** Включается в NC: `occ config:app:set f7support client_read_receipts --value=1` после выката API `POST .../read`. */ /** Включается в NC: `occ config:app:set f7support client_read_receipts --value=1` после выката API `POST .../read`. */
const clientReadReceiptsEnabled = root.dataset.clientReadReceipts === "1"; const clientReadReceiptsEnabled = root.dataset.clientReadReceipts === "1";
const ticketsPollIntervalMs = (() => {
const n = parseInt(String(root.dataset.messagesPollMs || "3000"), 10);
if (!Number.isFinite(n)) return 3000;
return Math.min(120000, Math.max(3000, n));
})();
const RASTER_IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "heic", "heif"]); const RASTER_IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "heic", "heif"]);
const RASTER_IMAGE_MIME = new Set([ const RASTER_IMAGE_MIME = new Set([
@@ -64,6 +69,7 @@
blobUrls: [], blobUrls: [],
ticketSocket: null, ticketSocket: null,
wsReconnectTimer: null, wsReconnectTimer: null,
ticketsPollTimer: null,
pendingFile: null, pendingFile: null,
}; };
@@ -259,7 +265,7 @@
} }
const node = messageList.querySelector(`[data-f7-msg-id="${id}"]`); const node = messageList.querySelector(`[data-f7-msg-id="${id}"]`);
if (node) hydrateClientAttachments(node); if (node) hydrateClientAttachments(node);
messageList.scrollTop = messageList.scrollHeight; scrollChatToBottom();
if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) { if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) {
markClientTicketRead(state.currentTicket).catch(() => {}); markClientTicketRead(state.currentTicket).catch(() => {});
} }
@@ -688,6 +694,39 @@
bindClientChatDnDOnce(); 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 ticketHasUnread(ticket) {
const v = ticket?.has_unread;
return v === true || v === 1 || v === "1" || v === "true";
}
function scheduleTicketsBoardPolling() {
if (state.ticketsPollTimer) {
clearInterval(state.ticketsPollTimer);
state.ticketsPollTimer = null;
}
state.ticketsPollTimer = window.setInterval(() => {
if (document.visibilityState === "hidden") return;
fetchTickets().catch(() => {});
}, ticketsPollIntervalMs);
}
function showError(message) { function showError(message) {
errorBox.textContent = message || ""; errorBox.textContent = message || "";
} }
@@ -809,7 +848,7 @@
const preview = escapeHtml(previewRaw); const preview = escapeHtml(previewRaw);
const activity = ticket.activity_at || ticket.created_at; const activity = ticket.activity_at || ticket.created_at;
const time = escapeHtml(formatTicketCardTime(activity)); const time = escapeHtml(formatTicketCardTime(activity));
const hasUnread = Boolean(ticket.has_unread); const hasUnread = ticketHasUnread(ticket);
const unread = hasUnread const unread = hasUnread
? '<span class="f7-unread-dot" title="Новое сообщение" aria-label="Новое сообщение"></span>' ? '<span class="f7-unread-dot" title="Новое сообщение" aria-label="Новое сообщение"></span>'
: ""; : "";
@@ -911,6 +950,7 @@
img.src = objUrl; img.src = objUrl;
img.alt = ""; img.alt = "";
img.loading = "lazy"; img.loading = "lazy";
img.addEventListener("load", () => scrollChatToBottom(), { once: true });
frame.appendChild(img); frame.appendChild(img);
wrap.replaceChildren(frame); wrap.replaceChildren(frame);
} catch (_) { } catch (_) {
@@ -945,6 +985,7 @@
revokeAttachmentUrls(); revokeAttachmentUrls();
messageList.innerHTML = state.messages.map((m) => messageBlockHtml(m)).join(""); messageList.innerHTML = state.messages.map((m) => messageBlockHtml(m)).join("");
hydrateClientAttachments(messageList); hydrateClientAttachments(messageList);
scrollChatToBottom();
} }
async function submitTicketWithRetry(payload) { async function submitTicketWithRetry(payload) {
@@ -1084,6 +1125,10 @@
}; };
queueMicrotask(() => { queueMicrotask(() => {
fetchTickets().catch((e) => showError(e.message)); fetchTickets()
.catch((e) => showError(e.message))
.finally(() => {
scheduleTicketsBoardPolling();
});
}); });
})(); })();
+10
View File
@@ -50,6 +50,15 @@ class PageController extends Controller {
// Отключить вызовы с клиента: occ config:app:set f7support client_read_receipts --value=0 // Отключить вызовы с клиента: occ config:app:set f7support client_read_receipts --value=0
$clientReadReceipts = $this->config->getAppValue('f7support', 'client_read_receipts', '0'); $clientReadReceipts = $this->config->getAppValue('f7support', 'client_read_receipts', '0');
// Интервал опроса списка тикетов (мс), чтобы подтягивать has_unread без WebSocket. occ: f7support tickets_poll_ms
$ticketsPollMs = (int)$this->config->getAppValue('f7support', 'tickets_poll_ms', '3000');
if ($ticketsPollMs < 3000) {
$ticketsPollMs = 3000;
}
if ($ticketsPollMs > 120000) {
$ticketsPollMs = 120000;
}
Util::addStyle('f7support', 'f7support'); Util::addStyle('f7support', 'f7support');
Util::addScript('f7support', 'main'); Util::addScript('f7support', 'main');
@@ -60,6 +69,7 @@ class PageController extends Controller {
'supportApiOrigin' => $supportApiOrigin, 'supportApiOrigin' => $supportApiOrigin,
'supportWsBase' => $supportWsBase, 'supportWsBase' => $supportWsBase,
'clientReadReceipts' => $clientReadReceipts, 'clientReadReceipts' => $clientReadReceipts,
'ticketsPollMs' => (string)$ticketsPollMs,
]); ]);
} }
} }
+2 -1
View File
@@ -5,5 +5,6 @@
data-server-address="<?php p($_['serverAddress']); ?>" data-server-address="<?php p($_['serverAddress']); ?>"
data-support-api-base="<?php p($_['supportApiBase']); ?>" data-support-api-base="<?php p($_['supportApiBase']); ?>"
data-support-ws-base="<?php p($_['supportWsBase'] ?? ''); ?>" data-support-ws-base="<?php p($_['supportWsBase'] ?? ''); ?>"
data-client-read-receipts="<?php p($_['clientReadReceipts'] ?? '0'); ?>"> data-client-read-receipts="<?php p($_['clientReadReceipts'] ?? '0'); ?>"
data-messages-poll-ms="<?php p($_['ticketsPollMs'] ?? '3000'); ?>">
</div> </div>