Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/client/chatMessageMutationOptimistic.ts
T

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 };
}