import type { ChatChannelDetailVM, ChatFeatureMessageVM, ChatMessageVM, ChatResolvedMentionVM, ChatThreadVM } from "@hivegrid/view-models"; export type ChatMessageMutationState = "pending" | "confirmed" | "failed"; type ChatMessageMutationBase = Readonly<{ requestId: string; messageId: string; startedAtIso: string; state: ChatMessageMutationState; errorKey: string | null; }>; export type ChatOptimisticMessageMutation = | (ChatMessageMutationBase & Readonly<{ kind: "edit_message"; bodyMarkdown: string; mentions: readonly ChatResolvedMentionVM[]; }>) | (ChatMessageMutationBase & Readonly<{ kind: "delete_message" }>); export type ChatOptimisticMessageMutationInput = | Omit, "state" | "errorKey"> | Omit, "state" | "errorKey">; export type ChatMessageMutationResponse = Readonly<{ ok: boolean; requestId: string; errorKey: string | null; }>; export function beginChatMessageMutation( current: readonly ChatOptimisticMessageMutation[], input: ChatOptimisticMessageMutationInput ): readonly ChatOptimisticMessageMutation[] { return [ ...current.filter((mutation) => mutation.messageId !== input.messageId), { ...input, state: "pending", errorKey: null } as ChatOptimisticMessageMutation, ]; } export function settleChatMessageMutation( current: readonly ChatOptimisticMessageMutation[], response: ChatMessageMutationResponse ): readonly ChatOptimisticMessageMutation[] { return current.map((mutation) => mutation.requestId === response.requestId ? { ...mutation, state: response.ok ? "confirmed" : "failed", errorKey: response.ok ? null : response.errorKey ?? "chat.errors.integrityFailure" } : mutation ); } /** Retains the stable request id for an explicit retry. */ export function retryChatMessageMutation( current: readonly ChatOptimisticMessageMutation[], requestId: string ): readonly ChatOptimisticMessageMutation[] { return current.map((mutation) => (mutation.requestId === requestId ? { ...mutation, state: "pending", errorKey: null } : mutation)); } export function reconcileChatMessageMutations( current: readonly ChatOptimisticMessageMutation[], authoritativeVm: ChatChannelDetailVM ): readonly ChatOptimisticMessageMutation[] { if (authoritativeVm.status !== "ready") return current; const messages = new Map(authoritativeVm.data.messages.map((message) => [message.id, message])); const next = current.filter((mutation) => { if (mutation.state === "failed" || mutation.state === "pending") return true; const message = messages.get(mutation.messageId); if (!message) return true; if (mutation.kind === "delete_message") return message.deletedAtIso === null; return message.bodyMarkdown !== mutation.bodyMarkdown || message.editedAtIso === null; }); return next.length === current.length ? current : next; } export function failedChatMessageMutationForMessage( current: readonly ChatOptimisticMessageMutation[], messageId: string ): ChatOptimisticMessageMutation | null { return current.find((mutation) => mutation.messageId === messageId && mutation.state === "failed") ?? null; } export function chatMessageMutationErrors( current: readonly ChatOptimisticMessageMutation[] ): Readonly> { return Object.fromEntries( current.flatMap((mutation) => (mutation.state === "failed" && mutation.errorKey ? [[mutation.messageId, mutation.errorKey] as const] : [])) ); } export function overlayChatMessageMutations( vm: ChatChannelDetailVM, mutations: readonly ChatOptimisticMessageMutation[] ): ChatChannelDetailVM { if (vm.status !== "ready" || mutations.length === 0) return vm; const active = new Map(mutations.filter((mutation) => mutation.state !== "failed").map((mutation) => [mutation.messageId, mutation])); if (active.size === 0) return vm; const messages = vm.data.messages.map((message) => overlayMessage(message, active.get(message.id))); const messagesById = new Map(messages.map((message) => [message.id, message])); return { ...vm, data: { ...vm.data, messages, messageGroups: vm.data.messageGroups.map((group) => ({ ...group, messages: group.messages.map((message) => messagesById.get(message.id) ?? message), })), pinnedMessages: vm.data.pinnedMessages.filter((pin) => active.get(pin.messageId)?.kind !== "delete_message"), }, }; } export function reconcileChatThreadMessageMutations( current: readonly ChatOptimisticMessageMutation[], authoritativeVm: ChatThreadVM ): readonly ChatOptimisticMessageMutation[] { if (authoritativeVm.status !== "ready") return current; const messages = new Map([authoritativeVm.data.parent, ...authoritativeVm.data.replies].map((message) => [message.id, message])); const next = current.filter((mutation) => { if (mutation.state === "failed" || mutation.state === "pending") return true; const message = messages.get(mutation.messageId); if (!message) return true; if (mutation.kind === "delete_message") return !message.deleted; return message.bodyMarkdown !== mutation.bodyMarkdown; }); return next.length === current.length ? current : next; } export function overlayChatThreadMessageMutations( vm: ChatThreadVM, mutations: readonly ChatOptimisticMessageMutation[] ): ChatThreadVM { if (vm.status !== "ready" || mutations.length === 0) return vm; const active = new Map(mutations.filter((mutation) => mutation.state !== "failed").map((mutation) => [mutation.messageId, mutation])); if (active.size === 0) return vm; return { ...vm, data: { ...vm.data, parent: overlayFeatureMessage(vm.data.parent, active.get(vm.data.parent.id)), replies: vm.data.replies.map((message) => overlayFeatureMessage(message, active.get(message.id))), }, }; } function overlayMessage(message: ChatMessageVM, mutation: ChatOptimisticMessageMutation | undefined): ChatMessageVM { if (!mutation) return message; if (mutation.kind === "delete_message") { return { ...message, bodyMarkdown: null, editedAtIso: null, deletedAtIso: mutation.startedAtIso, mentions: [], reactions: [], attachments: [], linkPreviews: [], pin: null, actions: { canReact: false, canReply: false, canEdit: false, canDelete: false, canPin: false, canMarkAsTask: false }, tombstoneKey: "chat.message.deleted", }; } return { ...message, bodyMarkdown: mutation.bodyMarkdown, mentions: mutation.mentions, editedAtIso: mutation.startedAtIso, }; } function overlayFeatureMessage( message: ChatFeatureMessageVM, mutation: ChatOptimisticMessageMutation | undefined ): ChatFeatureMessageVM { if (!mutation) return message; if (mutation.kind === "delete_message") { return { ...message, bodyMarkdown: null, deleted: true, mentions: [], reactions: [], attachments: [], pinned: false, canReact: false, canPin: false, canEdit: false, canDelete: false, }; } return { ...message, bodyMarkdown: mutation.bodyMarkdown, mentions: mutation.mentions }; }