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, "status" | "errorKey"> | Omit, "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((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((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> { const errors: Record = {}; 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 ): 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)]; }