Files
agent-desk-ng/web/lib/support-conversation-list.ts

200 lines
6.1 KiB
TypeScript

import type { ImConversation } from "./api/im"
/**
* Mirrors IMConversationStatus.Closed from the generated backend enum.
*
* It is spelled out as a literal rather than imported because Node's type
* stripping cannot load a TypeScript `enum`, and these helpers have to stay
* unit-testable outside the bundler. support-conversation-list.test.mjs reads
* the generated file and fails if this ever drifts, so this is a guarded mirror
* of the generated enum, not a second source of truth.
*/
export const CLOSED_IM_CONVERSATION_STATUS = 4
/**
* Parses an API timestamp string into a millisecond timestamp.
* If the string does not specify a timezone offset, it is interpreted as UTC.
*/
export function parseApiTimestamp(
value?: string | number | Date | null
): number {
if (value === null || value === undefined) {
return 0
}
if (value instanceof Date) {
const time = value.getTime()
return Number.isFinite(time) ? time : 0
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== "string") {
return 0
}
const trimmed = value.trim()
if (!trimmed) {
return 0
}
let normalized = trimmed.replace(" ", "T")
if (!/(Z|[+-]\d{2}(?::?\d{2})?)$/i.test(normalized)) {
normalized += "Z"
}
const timestamp = Date.parse(normalized)
return Number.isFinite(timestamp) ? timestamp : 0
}
/**
* A request counts as open until it is closed, rather than by listing the open
* statuses. The server's own definition is "status is one of ai_serving,
* pending, active", so any new status it introduces is open by default here
* instead of being silently treated as finished.
*/
export function isOpenImConversation(
conversation: ImConversation | null | undefined
): boolean {
if (!conversation) {
return false
}
return conversation.status !== CLOSED_IM_CONVERSATION_STATUS
}
function recencyTimestamp(conversation: ImConversation): number {
const candidates = [conversation.lastActiveAt, conversation.lastMessageAt]
for (const value of candidates) {
if (!value) {
continue
}
const parsed = parseApiTimestamp(value)
if (parsed > 0) {
return parsed
}
}
return 0
}
/**
* Most recently active first, matching the server's list ordering.
*
* A conversation that was just created has no timestamps yet, so treating an
* unknown time as "oldest" would drop the visitor's brand new request to the
* bottom of the switcher. Whenever either side has no usable timestamp we fall
* back to the descending id instead: ids are monotonic, so that still puts the
* newest request first. Array.prototype.sort is stable, so equal keys keep
* their existing relative order instead of shuffling between renders.
*/
export function sortConversationsByRecency(
conversations: readonly ImConversation[]
): ImConversation[] {
return [...conversations].sort((left, right) => {
const leftTime = recencyTimestamp(left)
const rightTime = recencyTimestamp(right)
if (leftTime > 0 && rightTime > 0 && leftTime !== rightTime) {
return rightTime - leftTime
}
return right.id - left.id
})
}
export function upsertConversation(
conversations: readonly ImConversation[],
conversation: ImConversation
): ImConversation[] {
const existing = conversations.some((item) => item.id === conversation.id)
const next = existing
? conversations.map((item) =>
item.id === conversation.id ? { ...item, ...conversation } : item
)
: [conversation, ...conversations]
return sortConversationsByRecency(next)
}
export function findConversationById(
conversations: readonly ImConversation[],
conversationId: number
): ImConversation | null {
if (!conversationId) {
return null
}
return conversations.find((item) => item.id === conversationId) ?? null
}
/**
* The request to rejoin on load: the newest still-open one.
*
* Returns null when every request is closed, which is the signal to start a
* fresh one. Selecting a closed request here would leave the visitor staring at
* a read-only thread with the composer disabled and no way forward.
*/
export function pickConversationToResume(
conversations: readonly ImConversation[]
): ImConversation | null {
return (
sortConversationsByRecency(conversations).find((item) =>
isOpenImConversation(item)
) ?? null
)
}
/**
* What to show as a request's title. The visitor's own subject wins; otherwise
* the latest message stands in, and only then do we fall back to the caller's
* localized generic label.
*/
export function resolveConversationTitle(
conversation: ImConversation | null | undefined,
fallbackLabel: string
): string {
const subject = conversation?.subject?.trim()
if (subject) {
return subject
}
const summary = conversation?.lastMessageSummary?.trim()
if (summary) {
return summary
}
return fallbackLabel
}
/**
* Total unread across every request, for the switcher's badge. A visitor needs
* to notice activity in a request they are not currently looking at.
*/
export function totalUnreadConversations(
conversations: readonly ImConversation[]
): number {
return conversations.reduce(
(total, item) => total + (Number(item.customerUnreadCount) || 0),
0
)
}
/**
* Builds the state patch for switching the visible request. `conversation` is
* re-derived from the list together with the active id, so the header, the
* composer and the message loader can never point at different requests.
* Returns null when there is nothing to switch to.
*/
export function buildSelectConversationPatch(
state: {
conversations: readonly ImConversation[]
activeConversationId: number
},
conversationId: number
) {
const target = findConversationById(state.conversations, conversationId)
if (!target || target.id === state.activeConversationId) {
return null
}
return {
conversations: state.conversations,
activeConversationId: target.id,
conversation: target,
// Messages belong to the request they were loaded for.
messages: [] as never[],
messagesCursor: "",
messagesHasMore: false,
messagesLoadingMore: false,
readingMessageId: 0,
error: "",
}
}