200 lines
6.1 KiB
TypeScript
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: "",
|
|
}
|
|
}
|