271 lines
10 KiB
TypeScript
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)];
|
|
}
|