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

271 lines
10 KiB
TypeScript

import type { ChatChannelDetailVM, ChatFeatureMessageVM, ChatMessageVM, ChatPinnedMessagesVM, ChatThreadVM } from "@hivegrid/view-models";
type FeatureMutationStatus = "pending" | "confirmed" | "failed";
const MAX_OPTIMISTIC_FEATURE_MUTATIONS = 100;
type FeatureMutationBase = {
readonly requestId: string;
readonly messageId: string;
readonly actorLabel: string;
readonly startedAtIso: string;
readonly status: FeatureMutationStatus;
readonly errorKey: string | null;
};
export type ChatOptimisticFeatureMutation =
| (FeatureMutationBase & {
readonly kind: "reaction";
readonly emoji: string;
readonly active: boolean;
})
| (FeatureMutationBase & {
readonly kind: "pin";
readonly pinned: boolean;
});
export type ChatOptimisticFeatureMutationInput =
| Omit<Extract<ChatOptimisticFeatureMutation, { kind: "reaction" }>, "status" | "errorKey">
| Omit<Extract<ChatOptimisticFeatureMutation, { kind: "pin" }>, "status" | "errorKey">;
export type ChatOptimisticFeatureState = readonly ChatOptimisticFeatureMutation[];
export function beginChatFeatureMutation(
state: ChatOptimisticFeatureState,
mutation: ChatOptimisticFeatureMutationInput
): ChatOptimisticFeatureState {
const next: ChatOptimisticFeatureMutation = { ...mutation, status: "pending", errorKey: null };
return [...state.filter((candidate) => candidate.requestId !== mutation.requestId && !sameFeature(candidate, next)), next].slice(
-MAX_OPTIMISTIC_FEATURE_MUTATIONS
);
}
export function settleChatFeatureMutation(
state: ChatOptimisticFeatureState,
requestId: string,
result: { readonly ok: boolean; readonly errorKey: string | null }
): ChatOptimisticFeatureState {
const status: FeatureMutationStatus = result.ok ? "confirmed" : "failed";
const errorKey = result.ok ? null : (result.errorKey ?? "chat.errors.invalidInput");
let changed = false;
const next = state.map<ChatOptimisticFeatureMutation>((mutation) => {
if (mutation.requestId !== requestId || (mutation.status === status && mutation.errorKey === errorKey)) return mutation;
changed = true;
return { ...mutation, status, errorKey };
});
return changed ? next : state;
}
export function retryChatFeatureMutation(
state: ChatOptimisticFeatureState,
requestId: string
): ChatOptimisticFeatureState {
return state.map<ChatOptimisticFeatureMutation>((mutation) =>
mutation.requestId === requestId ? { ...mutation, status: "pending", errorKey: null } : mutation
);
}
export function failedChatFeatureMutationForMessage(
state: ChatOptimisticFeatureState,
messageId: string
): ChatOptimisticFeatureMutation | null {
return [...state].reverse().find((mutation) => mutation.messageId === messageId && mutation.status === "failed") ?? null;
}
export function chatFeatureErrorsByMessageId(state: ChatOptimisticFeatureState): Readonly<Record<string, string>> {
const errors: Record<string, string> = {};
for (const mutation of state) {
if (mutation.status === "failed" && mutation.errorKey) errors[mutation.messageId] = mutation.errorKey;
}
return errors;
}
export function overlayChatChannelFeatureMutations(
vm: ChatChannelDetailVM,
state: ChatOptimisticFeatureState
): ChatChannelDetailVM {
if (vm.status !== "ready") return vm;
const overlayById = new Map(vm.data.messages.map((message) => [message.id, overlayChannelMessage(message, state)]));
return {
...vm,
data: {
...vm.data,
messages: vm.data.messages.map((message) => overlayById.get(message.id) ?? message),
messageGroups: vm.data.messageGroups.map((group) => ({
...group,
messages: group.messages.map((message) => overlayById.get(message.id) ?? message),
})),
pinnedMessages: vm.data.pinnedMessages.filter((row) => !isOptimisticallyUnpinned(state, row.messageId)),
},
};
}
export function overlayChatThreadFeatureMutations(vm: ChatThreadVM, state: ChatOptimisticFeatureState): ChatThreadVM {
if (vm.status !== "ready") return vm;
return {
...vm,
data: {
...vm.data,
parent: overlayFeatureMessage(vm.data.parent, state),
replies: vm.data.replies.map((message) => overlayFeatureMessage(message, state)),
},
};
}
export function overlayChatPinnedFeatureMutations(
vm: ChatPinnedMessagesVM,
state: ChatOptimisticFeatureState
): ChatPinnedMessagesVM {
if (vm.status !== "ready") return vm;
return {
...vm,
data: {
...vm.data,
rows: vm.data.rows.filter((row) => !isOptimisticallyUnpinned(state, row.message.id)),
},
};
}
export function reconcileChatChannelFeatureMutations(
state: ChatOptimisticFeatureState,
vm: ChatChannelDetailVM
): ChatOptimisticFeatureState {
if (vm.status !== "ready") return state;
const byId = new Map(vm.data.messages.map((message) => [message.id, message]));
return reconcileConfirmed(state, (mutation) => authoritativeChannelStateMatches(byId.get(mutation.messageId), mutation));
}
export function reconcileChatThreadFeatureMutations(
state: ChatOptimisticFeatureState,
vm: ChatThreadVM
): ChatOptimisticFeatureState {
if (vm.status !== "ready") return state;
const messages = [vm.data.parent, ...vm.data.replies];
const byId = new Map(messages.map((message) => [message.id, message]));
return reconcileConfirmed(state, (mutation) => authoritativeFeatureStateMatches(byId.get(mutation.messageId), mutation));
}
export function reconcileChatPinnedFeatureMutations(
state: ChatOptimisticFeatureState,
vm: ChatPinnedMessagesVM
): ChatOptimisticFeatureState {
if (vm.status !== "ready") return state;
const pinnedIds = new Set(vm.data.rows.map((row) => row.message.id));
return reconcileConfirmed(state, (mutation) => mutation.kind === "pin" && pinnedIds.has(mutation.messageId) === mutation.pinned);
}
function overlayChannelMessage(message: ChatMessageVM, state: ChatOptimisticFeatureState): ChatMessageVM {
let next = message;
for (const mutation of activeMutations(state, message.id)) {
if (mutation.kind === "reaction") {
next = { ...next, reactions: overlayReactions(next.reactions, mutation) };
} else {
next = {
...next,
pin: mutation.pinned
? { pinnedAtIso: mutation.startedAtIso, pinnedByLabel: mutation.actorLabel, note: null }
: null,
};
}
}
return next;
}
function overlayFeatureMessage(message: ChatFeatureMessageVM, state: ChatOptimisticFeatureState): ChatFeatureMessageVM {
let next = message;
for (const mutation of activeMutations(state, message.id)) {
if (mutation.kind === "reaction") {
next = {
...next,
reactions: overlayReactions(
next.reactions.map((reaction) => ({ ...reaction, reactorLabels: [] })),
mutation
).map(({ emoji, count, reactedByViewer }) => ({ emoji, count, reactedByViewer })),
};
} else {
next = { ...next, pinned: mutation.pinned };
}
}
return next;
}
type OverlayReaction = {
readonly emoji: string;
readonly count: number;
readonly reactedByViewer: boolean;
readonly reactorLabels: readonly string[];
};
function overlayReactions(
reactions: readonly OverlayReaction[],
mutation: Extract<ChatOptimisticFeatureMutation, { kind: "reaction" }>
): readonly OverlayReaction[] {
const current = reactions.find((reaction) => reaction.emoji === mutation.emoji);
if (current?.reactedByViewer === mutation.active || (!current && !mutation.active)) return reactions;
if (!current) {
return [
...reactions,
{
emoji: mutation.emoji,
count: 1,
reactedByViewer: true,
reactorLabels: [mutation.actorLabel],
},
];
}
const nextCount = current.count + (mutation.active ? 1 : -1);
if (nextCount <= 0) return reactions.filter((reaction) => reaction.emoji !== mutation.emoji);
const reactorLabels = mutation.active
? [...current.reactorLabels, mutation.actorLabel]
: removeFirst(current.reactorLabels, mutation.actorLabel);
return reactions.map((reaction) =>
reaction.emoji === mutation.emoji
? { ...reaction, count: nextCount, reactedByViewer: mutation.active, reactorLabels }
: reaction
);
}
function activeMutations(state: ChatOptimisticFeatureState, messageId: string): readonly ChatOptimisticFeatureMutation[] {
return state.filter((mutation) => mutation.messageId === messageId && mutation.status !== "failed");
}
function isOptimisticallyUnpinned(state: ChatOptimisticFeatureState, messageId: string): boolean {
return activeMutations(state, messageId).some((mutation) => mutation.kind === "pin" && !mutation.pinned);
}
function reconcileConfirmed(
state: ChatOptimisticFeatureState,
matches: (mutation: ChatOptimisticFeatureMutation) => boolean
): ChatOptimisticFeatureState {
const next = state.filter((mutation) => mutation.status !== "confirmed" || !matches(mutation));
return next.length === state.length ? state : next;
}
function authoritativeChannelStateMatches(
message: ChatMessageVM | undefined,
mutation: ChatOptimisticFeatureMutation
): boolean {
if (!message) return false;
if (mutation.kind === "pin") return (message.pin !== null) === mutation.pinned;
return (message.reactions.find((reaction) => reaction.emoji === mutation.emoji)?.reactedByViewer ?? false) === mutation.active;
}
function authoritativeFeatureStateMatches(
message: ChatFeatureMessageVM | undefined,
mutation: ChatOptimisticFeatureMutation
): boolean {
if (!message) return false;
if (mutation.kind === "pin") return message.pinned === mutation.pinned;
return (message.reactions.find((reaction) => reaction.emoji === mutation.emoji)?.reactedByViewer ?? false) === mutation.active;
}
function sameFeature(left: ChatOptimisticFeatureMutation, right: ChatOptimisticFeatureMutation): boolean {
if (left.messageId !== right.messageId || left.kind !== right.kind) return false;
return left.kind === "pin" || (right.kind === "reaction" && left.emoji === right.emoji);
}
function removeFirst(values: readonly string[], value: string): readonly string[] {
const index = values.indexOf(value);
if (index < 0) return values;
return [...values.slice(0, index), ...values.slice(index + 1)];
}