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

308 lines
11 KiB
TypeScript

import { CHAT_EVENT_NAMES, type ChatLocalEcho, type ChatRealtimeMessage, type ChatRemotePresence, type ChatServerEvent } from "@hivegrid/realtime";
import {
buildChatSystemEventVM,
chatMessageIdentityKey,
type ChatMessageActionsVM,
type ChatMessageCandidate,
type ChatResolvedAuthorVM,
type ChatResolvedMentionVM,
} from "@hivegrid/view-models";
const MAX_OVERLAY_MESSAGES = 500;
const MAX_READ_RECEIPT_OVERLAYS = 500;
const NO_MESSAGE_ACTIONS: ChatMessageActionsVM = Object.freeze({
canReact: false,
canReply: false,
canEdit: false,
canDelete: false,
canPin: false,
canMarkAsTask: false,
});
export type ChatRealtimeConnection = {
readonly state: "connecting" | "online" | "reconnecting" | "offline" | "blocked";
readonly reconnectAttempt: number;
readonly retryAtIso: string | null;
};
export type ChatChannelRealtimeState = {
readonly scopeKey: string | null;
readonly connection: ChatRealtimeConnection;
readonly candidates: readonly ChatMessageCandidate[];
readonly readReceipts: readonly ChatReadReceiptOverlay[];
readonly typingLabels: readonly string[];
readonly presence: readonly ChatRemotePresence[];
};
export type ChatReadReceiptOverlay = {
readonly messageId: string;
readonly userId: string;
};
export type ChatChannelRealtimeAction =
| { readonly type: "reset"; readonly scopeKey: string }
| { readonly type: "connection"; readonly scopeKey: string; readonly connection: ChatRealtimeConnection }
| { readonly type: "upsert_candidate"; readonly scopeKey: string; readonly candidate: ChatMessageCandidate }
| { readonly type: "remove_candidate"; readonly scopeKey: string; readonly authorUserId: string; readonly clientMessageId: string }
| { readonly type: "read_receipt"; readonly scopeKey: string; readonly receipt: ChatReadReceiptOverlay }
| { readonly type: "typing"; readonly scopeKey: string; readonly labels: readonly string[] }
| { readonly type: "presence"; readonly scopeKey: string; readonly entries: readonly ChatRemotePresence[] };
export type ChatCandidateMapping = {
readonly currentUser: ChatResolvedAuthorVM;
readonly unknownAuthorLabel: string;
readonly resolveAuthor: (userId: string) => ChatResolvedAuthorVM | null | undefined;
readonly messageActions?: (message: ChatRealtimeMessage) => ChatMessageActionsVM;
};
export function createChatRealtimeScopeKey(tenantSlug: string, chatId: string): string {
return `${tenantSlug}\u0000${chatId}`;
}
export function createInitialChatChannelRealtimeState(scopeKey: string | null = null): ChatChannelRealtimeState {
return {
scopeKey,
connection: { state: "offline", reconnectAttempt: 0, retryAtIso: null },
candidates: [],
readReceipts: [],
typingLabels: [],
presence: [],
};
}
export function chatChannelRealtimeReducer(state: ChatChannelRealtimeState, action: ChatChannelRealtimeAction): ChatChannelRealtimeState {
if (action.type !== "reset" && state.scopeKey !== action.scopeKey) return state;
switch (action.type) {
case "reset":
return createInitialChatChannelRealtimeState(action.scopeKey);
case "connection":
return { ...state, connection: action.connection };
case "upsert_candidate":
return { ...state, candidates: upsertCandidate(state.candidates, action.candidate) };
case "remove_candidate":
return {
...state,
candidates: state.candidates.filter(
(candidate) => candidate.author?.userId !== action.authorUserId || candidate.clientMessageId !== action.clientMessageId
),
};
case "read_receipt":
return { ...state, readReceipts: upsertReadReceipt(state.readReceipts, action.receipt) };
case "typing":
return { ...state, typingLabels: [...new Set(action.labels)] };
case "presence":
return { ...state, presence: action.entries.map((entry) => ({ ...entry })) };
}
}
/** Maps only top-level message events. All other persistent mutations are
* hydrated by the loader revalidation triggered by the hook. */
export function candidateFromChatEvent(event: ChatServerEvent, mapping: ChatCandidateMapping): ChatMessageCandidate | null {
if (event.event !== CHAT_EVENT_NAMES.messageCreated || event.data.message.parentMessageId !== null) return null;
return candidateFromRealtimeMessage(event.data.message, "websocket", "sent", null, mapping);
}
export function candidateFromLocalEcho(echo: ChatLocalEcho, mapping: ChatCandidateMapping): ChatMessageCandidate {
if (echo.canonicalMessage) {
return candidateFromRealtimeMessage(echo.canonicalMessage, "http_ack", "sent", null, mapping);
}
const timestamp = new Date(echo.createdAtMs).toISOString();
return {
id: `local:${echo.clientMessageId}`,
clientMessageId: echo.clientMessageId,
source: "local_echo",
deliveryState: echo.status,
failureReasonKey: echo.failureI18nKey,
retryable: echo.retryable,
author: mapping.currentUser,
bodyMarkdown: echo.body,
bodyFormat: "markdown",
systemEvent: null,
createdAtIso: timestamp,
updatedAtIso: timestamp,
editedAtIso: null,
deletedAtIso: null,
parentMessageId: null,
mentions: echo.mentions.map(resolveMention),
reactions: [],
thread: null,
attachments: [],
linkPreviews: [],
readAggregate: emptyReadAggregate(),
pin: null,
actions: NO_MESSAGE_ACTIONS,
};
}
export function safeResolveChatAuthor(userId: string, mapping: ChatCandidateMapping): ChatResolvedAuthorVM {
if (userId === mapping.currentUser.userId) return mapping.currentUser;
try {
const resolved = mapping.resolveAuthor(userId);
if (resolved) return resolved;
} catch {
// A stale author projection must not break the realtime stream.
}
const label = mapping.unknownAuthorLabel.trim() || userId;
return {
userId,
displayName: label,
avatar: { imageUrl: null, fallbackLabel: label.slice(0, 1).toUpperCase() || "?" },
isCurrentUser: userId === mapping.currentUser.userId,
};
}
/** Once the loader has hydrated a message, its richer author, attachment,
* permission and read projections replace the transport-only candidate. */
export function filterHydratedChatCandidates(
loaderMessages: readonly ChatMessageCandidate[],
realtimeCandidates: readonly ChatMessageCandidate[]
): readonly ChatMessageCandidate[] {
const hydratedKeys = new Set(loaderMessages.map(chatMessageCandidateKey));
return realtimeCandidates.filter((candidate) => !hydratedKeys.has(chatMessageCandidateKey(candidate)));
}
function candidateFromRealtimeMessage(
message: ChatRealtimeMessage,
source: "websocket" | "http_ack",
deliveryState: "sent",
failureReasonKey: null,
mapping: ChatCandidateMapping
): ChatMessageCandidate {
if (message.bodyFormat === "system") {
return {
id: message.id,
clientMessageId: message.clientMessageId,
source,
deliveryState,
failureReasonKey,
author: null,
bodyMarkdown: null,
bodyFormat: "system",
systemEvent: buildChatSystemEventVM(message.systemEvent.kind, message.systemEvent.metadata),
createdAtIso: message.createdAt,
updatedAtIso: message.updatedAt,
editedAtIso: message.editedAt,
deletedAtIso: message.deletedAt,
parentMessageId: null,
mentions: [],
reactions: [],
thread: null,
attachments: [],
linkPreviews: [],
readAggregate: null,
pin: null,
actions: NO_MESSAGE_ACTIONS,
};
}
const isDeleted = message.deletedAt !== null;
let actions = NO_MESSAGE_ACTIONS;
if (!isDeleted) {
try {
actions = mapping.messageActions?.(message) ?? NO_MESSAGE_ACTIONS;
} catch {
// Permission projections can become stale; safe read-only actions win.
}
}
return {
id: message.id,
clientMessageId: message.clientMessageId,
source,
deliveryState,
failureReasonKey,
author: safeResolveChatAuthor(message.authorUserId, mapping),
bodyMarkdown: isDeleted ? null : message.body,
bodyFormat: message.bodyFormat,
systemEvent: null,
createdAtIso: message.createdAt,
updatedAtIso: message.updatedAt,
editedAtIso: message.editedAt,
deletedAtIso: message.deletedAt,
parentMessageId: message.parentMessageId,
mentions: isDeleted ? [] : message.mentions.map(resolveMention),
reactions: [],
thread: null,
// Realtime frames intentionally contain only attachment ids. The loader
// resolves scan state, names and download URLs after revalidation.
attachments: [],
linkPreviews: isDeleted ? [] : message.linkPreviews.map((preview) => ({ ...preview })),
readAggregate: emptyReadAggregate(),
pin: null,
actions,
};
}
function resolveMention(mention: { readonly type: ChatResolvedMentionVM["kind"]; readonly id: string; readonly label: string | null }): ChatResolvedMentionVM {
return {
kind: mention.type,
id: mention.id,
label: mention.label?.trim() || mention.id,
href: null,
};
}
function emptyReadAggregate() {
return {
readCount: 0,
recipientCount: 0,
readerUserIds: [],
readerLabels: [],
labelKey: "chat.message.read.none",
} as const;
}
export function overlayChatReadReceipts(
candidates: readonly ChatMessageCandidate[],
receipts: readonly ChatReadReceiptOverlay[],
mapping: ChatCandidateMapping
): readonly ChatMessageCandidate[] {
if (receipts.length === 0) return candidates;
const receiptsByMessage = new Map<string, Set<string>>();
for (const receipt of receipts) {
const readers = receiptsByMessage.get(receipt.messageId) ?? new Set<string>();
readers.add(receipt.userId);
receiptsByMessage.set(receipt.messageId, readers);
}
return candidates.map((candidate) => {
if (candidate.author === null || candidate.readAggregate === null) return candidate;
const readAggregate = candidate.readAggregate;
const liveReaders = receiptsByMessage.get(candidate.id);
if (!liveReaders) return candidate;
const labelsByUserId = new Map(readAggregate.readerUserIds.map((userId, index) => [userId, readAggregate.readerLabels[index]!]));
for (const userId of liveReaders) {
if (userId === candidate.author.userId || labelsByUserId.has(userId)) continue;
labelsByUserId.set(userId, safeResolveChatAuthor(userId, mapping).displayName);
}
const readers = [...labelsByUserId.entries()].slice(0, readAggregate.recipientCount);
return {
...candidate,
readAggregate: {
...readAggregate,
readCount: readers.length,
readerUserIds: readers.map(([userId]) => userId),
readerLabels: readers.map(([, label]) => label),
labelKey: readers.length > 0 ? "chat.message.read.aggregate" : readAggregate.labelKey,
},
};
});
}
function upsertReadReceipt(receipts: readonly ChatReadReceiptOverlay[], receipt: ChatReadReceiptOverlay): readonly ChatReadReceiptOverlay[] {
const withoutDuplicate = receipts.filter((candidate) => candidate.messageId !== receipt.messageId || candidate.userId !== receipt.userId);
return [...withoutDuplicate, receipt].slice(-MAX_READ_RECEIPT_OVERLAYS);
}
function upsertCandidate(candidates: readonly ChatMessageCandidate[], candidate: ChatMessageCandidate): readonly ChatMessageCandidate[] {
const candidateKey = chatMessageIdentityKey(candidate);
const index = candidates.findIndex((existing) => chatMessageIdentityKey(existing) === candidateKey);
if (index < 0) return [...candidates, candidate].slice(-MAX_OVERLAY_MESSAGES);
const next = [...candidates];
next[index] = candidate;
return next;
}
function chatMessageCandidateKey(candidate: ChatMessageCandidate): string {
return chatMessageIdentityKey(candidate);
}