f7support: точка непрочитанного по последнему сообщению (/messages)
Список тикетов часто не меняет preview/activity при ответе оператора. Добавлен опрос сигнатуры последнего сообщения пачками по незакрытым тикетам (кроме открытого чата), сравнение id+created_at. Baseline при закрытии чата (await). Версия 0.1.8.
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@
|
||||
<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.7</version>
|
||||
<version>0.1.8</version>
|
||||
<licence>AGPL</licence>
|
||||
<author>f7support team</author>
|
||||
<namespace>F7Support</namespace>
|
||||
|
||||
+81
-5
@@ -76,6 +76,12 @@
|
||||
pendingFile: null,
|
||||
/** @type {Record<string, string>} последний известный «отпечаток» карточки по номеру тикета (для точки без has_unread с API) */
|
||||
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() {
|
||||
@@ -271,6 +277,7 @@
|
||||
const node = messageList.querySelector(`[data-f7-msg-id="${id}"]`);
|
||||
if (node) hydrateClientAttachments(node);
|
||||
scrollChatToBottom();
|
||||
syncCurrentTicketLastMsgSigFromState();
|
||||
if (clientReadReceiptsEnabled && msg.author_role === "support" && state.currentTicket) {
|
||||
markClientTicketRead(state.currentTicket).catch(() => {});
|
||||
}
|
||||
@@ -294,6 +301,7 @@
|
||||
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);
|
||||
}
|
||||
@@ -716,10 +724,71 @@
|
||||
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) : "";
|
||||
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) {
|
||||
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 ??
|
||||
@@ -843,11 +914,13 @@
|
||||
setModalVisible(chatModal, true);
|
||||
}
|
||||
|
||||
function closeChatModal() {
|
||||
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();
|
||||
@@ -861,7 +934,7 @@
|
||||
setModalVisible(chatModal, false);
|
||||
showError("");
|
||||
document.querySelectorAll(".f7-ticket-card--active").forEach((n) => n.classList.remove("f7-ticket-card--active"));
|
||||
fetchTickets().catch(() => {});
|
||||
await fetchTickets().catch(() => {});
|
||||
}
|
||||
|
||||
async function fetchTickets() {
|
||||
@@ -873,6 +946,7 @@
|
||||
});
|
||||
if (!response.ok) throw new Error("Не удалось загрузить список обращений");
|
||||
state.tickets = await response.json();
|
||||
await refreshLastMessageSignaturesStaggered();
|
||||
updateBoardUnreadFingerprints();
|
||||
renderTickets();
|
||||
}
|
||||
@@ -942,6 +1016,7 @@
|
||||
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();
|
||||
@@ -954,7 +1029,7 @@
|
||||
} catch (e) {
|
||||
showError(e.message || "Не удалось загрузить сообщения");
|
||||
disconnectTicketSocket();
|
||||
closeChatModal();
|
||||
await closeChatModal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,6 +1057,7 @@
|
||||
});
|
||||
if (!response.ok) throw new Error("Не удалось загрузить сообщения");
|
||||
state.messages = await response.json();
|
||||
syncCurrentTicketLastMsgSigFromState();
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
@@ -1088,7 +1164,7 @@
|
||||
};
|
||||
|
||||
document.getElementById("chat-modal-close-btn").onclick = () => {
|
||||
closeChatModal();
|
||||
void closeChatModal();
|
||||
};
|
||||
|
||||
document.getElementById("attachment-clip-btn").onclick = () => {
|
||||
|
||||
Reference in New Issue
Block a user