f7support: точка непрочитанного по последнему сообщению (/messages)

Список тикетов часто не меняет preview/activity при ответе оператора. Добавлен опрос сигнатуры последнего сообщения пачками по незакрытым тикетам (кроме открытого чата), сравнение id+created_at. Baseline при закрытии чата (await). Версия 0.1.8.
This commit is contained in:
root
2026-05-14 16:56:40 +03:00
parent 11e111d222
commit 81beafda29
2 changed files with 82 additions and 6 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<name>f7support</name> <name>f7support</name>
<summary>Support ticket client for F7cloud (F7cloud-compatible)</summary> <summary>Support ticket client for F7cloud (F7cloud-compatible)</summary>
<description>f7support client app for creating and viewing support tickets.</description> <description>f7support client app for creating and viewing support tickets.</description>
<version>0.1.7</version> <version>0.1.8</version>
<licence>AGPL</licence> <licence>AGPL</licence>
<author>f7support team</author> <author>f7support team</author>
<namespace>F7Support</namespace> <namespace>F7Support</namespace>
+81 -5
View File
@@ -76,6 +76,12 @@
pendingFile: null, pendingFile: null,
/** @type {Record<string, string>} последний известный «отпечаток» карточки по номеру тикета (для точки без has_unread с API) */ /** @type {Record<string, string>} последний известный «отпечаток» карточки по номеру тикета (для точки без has_unread с API) */
ticketSeenFingerprint: {}, ticketSeenFingerprint: {},
/** @type {Record<string, string>} сигнатура последнего сообщения (id+время) по тикету — список тикетов часто не меняет preview/activity */
ticketLastMsgSig: Object.create(null),
/** @type {Record<string, boolean>} новое сообщение по сравнению с последним опросом /messages */
ticketUnreadByLastMsg: Object.create(null),
/** Смещение пачки опросов /messages (не грузим все тикеты за один раз) */
msgSigPollIndex: 0,
}; };
function clientIdentityHeaders() { function clientIdentityHeaders() {
@@ -271,6 +277,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);
scrollChatToBottom(); scrollChatToBottom();
syncCurrentTicketLastMsgSigFromState();
if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) { if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) {
markClientTicketRead(state.currentTicket).catch(() => {}); markClientTicketRead(state.currentTicket).catch(() => {});
} }
@@ -294,6 +301,7 @@
if (!res.ok) return; if (!res.ok) return;
const row = state.tickets.find((x) => x.ticket_number === ticketNumber); const row = state.tickets.find((x) => x.ticket_number === ticketNumber);
if (row) row.has_unread = false; if (row) row.has_unread = false;
delete state.ticketUnreadByLastMsg[String(ticketNumber)];
renderTickets(); renderTickets();
reapplyActiveTicketCard(ticketNumber); reapplyActiveTicketCard(ticketNumber);
} }
@@ -716,10 +724,71 @@
window.setTimeout(run, 320); 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) { function ticketBoardFingerprint(t) {
const act = String(t.activity_at || t.updated_at || t.last_activity_at || t.created_at || ""); 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 prev = t.preview_text != null ? String(t.preview_text) : "";
return `${act}\u0001${prev}`; const cnt =
t.messages_count ??
t.message_count ??
t.msg_count ??
t.replies_count ??
t.reply_count ??
"";
return `${act}\u0001${prev}\u0001${cnt}`;
} }
/** Для открытого чата учитываем последнее сообщение, чтобы отпечаток не отставал от превью в списке. */ /** Для открытого чата учитываем последнее сообщение, чтобы отпечаток не отставал от превью в списке. */
@@ -756,7 +825,9 @@
} }
function ticketHasUnread(ticket) { function ticketHasUnread(ticket) {
const tn = String(ticket.ticket_number);
if (ticket && ticket._f7_board_unread) return true; if (ticket && ticket._f7_board_unread) return true;
if (state.ticketUnreadByLastMsg[tn]) return true;
const raw = const raw =
ticket?.has_unread ?? ticket?.has_unread ??
ticket?.hasUnread ?? ticket?.hasUnread ??
@@ -843,11 +914,13 @@
setModalVisible(chatModal, true); setModalVisible(chatModal, true);
} }
function closeChatModal() { async function closeChatModal() {
const closingTn = state.currentTicket; const closingTn = state.currentTicket;
if (closingTn != null) { if (closingTn != null) {
const row = state.tickets.find((x) => String(x.ticket_number) === String(closingTn)); const row = state.tickets.find((x) => String(x.ticket_number) === String(closingTn));
if (row) state.ticketSeenFingerprint[String(closingTn)] = ticketBoardFingerprint(row); if (row) state.ticketSeenFingerprint[String(closingTn)] = ticketBoardFingerprint(row);
delete state.ticketUnreadByLastMsg[String(closingTn)];
await refreshLastMsgSigOne(String(closingTn), { forceBaseline: true });
} }
disconnectTicketSocket(); disconnectTicketSocket();
revokeAttachmentUrls(); revokeAttachmentUrls();
@@ -861,7 +934,7 @@
setModalVisible(chatModal, false); setModalVisible(chatModal, false);
showError(""); showError("");
document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active")); document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active"));
fetchTickets().catch(() => {}); await fetchTickets().catch(() => {});
} }
async function fetchTickets() { async function fetchTickets() {
@@ -873,6 +946,7 @@
}); });
if (!response.ok) throw new Error("Не удалось загрузить список обращений"); if (!response.ok) throw new Error("Не удалось загрузить список обращений");
state.tickets = await response.json(); state.tickets = await response.json();
await refreshLastMessageSignaturesStaggered();
updateBoardUnreadFingerprints(); updateBoardUnreadFingerprints();
renderTickets(); renderTickets();
} }
@@ -942,6 +1016,7 @@
const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`); const card = document.querySelector(`.f7-ticket-card[data-ticket-number="${tnSel}"]`);
if (card) card.classList.add("f7-ticket-card--active"); if (card) card.classList.add("f7-ticket-card--active");
openChatModal(ticket); openChatModal(ticket);
delete state.ticketUnreadByLastMsg[String(ticketNumber)];
try { try {
await fetchMessages(); await fetchMessages();
await fetchTickets(); await fetchTickets();
@@ -954,7 +1029,7 @@
} catch (e) { } catch (e) {
showError(e.message || "Не удалось загрузить сообщения"); showError(e.message || "Не удалось загрузить сообщения");
disconnectTicketSocket(); disconnectTicketSocket();
closeChatModal(); await closeChatModal();
} }
} }
@@ -982,6 +1057,7 @@
}); });
if (!response.ok) throw new Error("Не удалось загрузить сообщения"); if (!response.ok) throw new Error("Не удалось загрузить сообщения");
state.messages = await response.json(); state.messages = await response.json();
syncCurrentTicketLastMsgSigFromState();
renderMessages(); renderMessages();
} }
@@ -1088,7 +1164,7 @@
}; };
document.getElementById("chat-modal-close-btn").onclick = () => { document.getElementById("chat-modal-close-btn").onclick = () => {
closeChatModal(); void closeChatModal();
}; };
document.getElementById("attachment-clip-btn").onclick = () => { document.getElementById("attachment-clip-btn").onclick = () => {