308 lines
11 KiB
TypeScript
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);
|
|
}
|