chore: complete clean Agent Desk source snapshot
This commit is contained in:
199
web/lib/support-conversation-list.ts
Normal file
199
web/lib/support-conversation-list.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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: "",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user