195 lines
7.1 KiB
TypeScript
195 lines
7.1 KiB
TypeScript
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<Extract<ChatOptimisticMessageMutation, { kind: "edit_message" }>, "state" | "errorKey">
|
|
| Omit<Extract<ChatOptimisticMessageMutation, { kind: "delete_message" }>, "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<Record<string, string>> {
|
|
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 };
|
|
}
|