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>
|
<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
@@ -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 = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user