Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/services/chatViewModel.server.ts
T

503 lines
21 KiB
TypeScript

import {
CHAT_PERMISSIONS,
type ChatAttachmentRecord,
type ChatMemberChannelDetail,
type ChatMemberChannelRecord,
type ChatParticipantProfileRecord,
} from "@hivegrid/chat";
import { buildChatMessageDeepLink } from "@hivegrid/chat/deep-links";
import type { DiscoverablePublicGroupChat } from "@hivegrid/chat/discovery.server";
import { CHAT_MESSAGE_MAX_BODY_LENGTH, type ChatMention, type ChatRealtimeMessage } from "@hivegrid/realtime";
import {
buildChatChannelDetailVM,
buildChatChannelListVM,
buildChatSystemEventVM,
type ChatAvatarVM,
type ChatAttachmentVM,
type ChatChannelDetailVM,
type ChatChannelListFilterState,
type ChatChannelListRowInput,
type ChatChannelListVM,
type ChatMessageCandidate,
type ChatReadAggregateVM,
type ChatResolvedAuthorVM,
type ChatResolvedMentionVM,
} from "@hivegrid/view-models";
import { mediaVariantPath } from "~/utils/integrations/fileRoutePaths";
import {
chatArchivedPath,
chatChannelPath,
chatChannelSettingsPath,
chatPinnedPath,
chatIndexPath,
chatMentionsPath,
chatNewPath,
chatSearchPath,
chatThreadPath,
} from "../routes/chatRoutePaths";
const CHAT_LIST_SNIPPET_MAX_LENGTH = 160;
export type ChatViewModelLabels = {
readonly directMessage: string;
readonly unnamedGroup: string;
readonly unnamedProject: string;
readonly unknownUser: string;
readonly unknownMention: string;
};
type ChatListViewModelInput = {
readonly channels: readonly ChatMemberChannelRecord[];
readonly tenantSlug: string;
readonly currentUserId: string;
readonly permissions: readonly string[];
readonly filter: ChatChannelListFilterState;
readonly nextCursor: string | null;
readonly discoverChannels?: readonly DiscoverablePublicGroupChat[];
readonly labels: ChatViewModelLabels;
};
type ChatDetailViewModelInput = {
readonly detail: ChatMemberChannelDetail;
readonly tenantSlug: string;
readonly currentUserId: string;
readonly permissions: readonly string[];
readonly featureAvailability?: { readonly files: boolean; readonly tasks: boolean };
/** Injected by the server assembler so the pure builder enforces the 24-hour window deterministically. */
readonly nowIso?: string;
readonly labels: ChatViewModelLabels;
};
/** Maps the already RLS-scoped repository projection to the serializable list
* contract. Filtering remains deterministic and does not trigger client-side
* data access or a second permission path. */
export function buildMemberChatChannelListVM(input: ChatListViewModelInput): ChatChannelListVM {
const rows = input.channels.map((channel) => toChannelListRow(channel, input));
return buildChatChannelListVM({
rows,
discoverRows: (input.discoverChannels ?? []).map((channel) => ({
id: channel.id,
title: channel.name,
description: channel.description,
avatar: fallbackAvatar(channel.name),
memberCount: channel.memberCount,
isJoined: false,
canJoin: true,
joinHref: chatIndexPath(input.tenantSlug),
detailHref: null,
})),
filter: input.filter,
permissions: {
canWrite: hasPermission(input.permissions, CHAT_PERMISSIONS.write),
canCreateGroup: hasPermission(input.permissions, CHAT_PERMISSIONS.createChannel),
canCreateDm: hasPermission(input.permissions, CHAT_PERMISSIONS.createDm),
canDiscover: input.discoverChannels !== undefined && hasPermission(input.permissions, CHAT_PERMISSIONS.readOwnChannels),
canViewMentions: hasPermission(input.permissions, CHAT_PERMISSIONS.readOwnChannels),
canSearch: hasPermission(input.permissions, CHAT_PERMISSIONS.readOwnChannels),
canViewArchived: hasPermission(input.permissions, CHAT_PERMISSIONS.readOwnChannels),
canAdmin: hasPermission(input.permissions, CHAT_PERMISSIONS.admin),
},
hrefs: {
newChannel: `${chatNewPath(input.tenantSlug)}?kind=group`,
newDm: `${chatNewPath(input.tenantSlug)}?kind=dm`,
search: chatSearchPath(input.tenantSlug),
mentions: chatMentionsPath(input.tenantSlug),
archived: chatArchivedPath(input.tenantSlug),
},
totals: {
unread: input.channels.reduce((total, channel) => total + channel.unreadCount, 0),
mentions: input.channels.filter((channel) => channel.hasUnreadMention).length,
},
nextCursor: input.nextCursor,
});
}
/** Builds the first member-channel live-loop without exposing unfinished
* thread/reaction/file/pin actions. Repository data is already authorized and
* contains every profile/read projection needed by this mapper. */
export function buildMemberChatChannelDetailVM(input: ChatDetailViewModelInput): ChatChannelDetailVM {
const { channel } = input.detail;
const profiles = new Map(channel.participants.map((participant) => [participant.userId, participant]));
const title = resolveChannelTitle(channel, input.currentUserId, input.labels);
const counterpart = resolveDmCounterpart(channel, input.currentUserId);
const canWrite = hasPermission(input.permissions, CHAT_PERMISSIONS.write) && channel.archivedAtIso === null && channel.member.canWrite;
const canManageGroup = channel.kind === "group" && (channel.member.roleInChat === "admin" || hasPermission(input.permissions, CHAT_PERMISSIONS.admin));
const featuresByMessageId = new Map((input.detail.messageFeatures ?? []).map((feature) => [feature.messageId, feature]));
const attachmentsByMessageId = new Map<string, ChatMemberChannelDetail["attachments"]>();
for (const attachment of input.detail.attachments ?? []) {
attachmentsByMessageId.set(attachment.messageId, [...(attachmentsByMessageId.get(attachment.messageId) ?? []), attachment]);
}
const canAttachFiles = canWrite && input.featureAvailability?.files === true && hasPermission(input.permissions, "files.write");
const canMarkAsTask = canWrite && input.featureAvailability?.tasks === true && hasPermission(input.permissions, "tasks.write");
const canPinAnyMessage = channel.member.roleInChat === "admin" || hasPermission(input.permissions, CHAT_PERMISSIONS.admin);
const canAdministerMessages = hasPermission(input.permissions, CHAT_PERMISSIONS.admin);
const canDeleteAsChannelAdmin = channel.member.roleInChat === "admin" || canAdministerMessages;
return buildChatChannelDetailVM({
header: {
id: channel.id,
kind: channel.kind,
title,
subtitle: channel.description,
avatar: counterpart ? participantAvatar(counterpart) : fallbackAvatar(title),
project: null,
memberCount: channel.memberCount,
isPrivate: channel.isPrivate,
isArchived: channel.archivedAtIso !== null,
isAuditMode: false,
canManage: canManageGroup,
settingsHref: chatChannelSettingsPath(input.tenantSlug, channel.id),
pinnedHref: channel.kind === "dm" ? null : chatPinnedPath(input.tenantSlug, channel.id),
searchHref: chatIndexPath(input.tenantSlug),
},
messages: input.detail.messages.map((message) =>
toMessageCandidate({
message,
profiles,
readReceipts: input.detail.readReceipts,
memberCount: channel.memberCount,
currentUserId: input.currentUserId,
tenantSlug: input.tenantSlug,
labels: input.labels,
feature: featuresByMessageId.get(message.id) ?? null,
canWrite,
channelKind: channel.kind,
chatId: channel.id,
attachments: attachmentsByMessageId.get(message.id) ?? [],
canMarkAsTask,
canPinAnyMessage,
canAdministerMessages,
canDeleteAsChannelAdmin,
nowIso: input.nowIso ?? "9999-12-31T23:59:59.999Z",
})
),
pinnedMessages: input.detail.messages.flatMap((message) => {
const feature = featuresByMessageId.get(message.id);
if (!feature?.pin || message.bodyFormat !== "markdown") return [];
return [
{
messageId: message.id,
authorLabel: profiles.get(message.authorUserId)?.displayName ?? input.labels.unknownUser,
snippet: chatListSnippet(message.body),
pinnedAtIso: feature.pin.pinnedAtIso,
pinnedByLabel: profiles.get(feature.pin.pinnedByUserId)?.displayName ?? input.labels.unknownUser,
href: buildChatMessageDeepLink({ tenantSlug: input.tenantSlug, chatId: channel.id, messageId: message.id }),
},
];
}),
typingLabels: [],
history: {
olderCursor: input.detail.olderMessageCursor,
hasOlder: input.detail.hasOlderMessages,
},
composer: {
canSend: canWrite,
canAttachFiles,
canRecordVoice: canAttachFiles,
canMention: canWrite,
canMarkAsTask,
disabledReasonKey: canWrite ? null : channel.archivedAtIso ? "chat.composer.archived" : "chat.composer.permissionDenied",
draftStorageKey: `chat:draft:v1:${input.tenantSlug}:${input.currentUserId}:${channel.id}`,
maxBodyLength: CHAT_MESSAGE_MAX_BODY_LENGTH,
},
connection: {
state: "connecting",
reconnectAttempt: 0,
retryAtIso: null,
},
});
}
export function buildChatResolvedAuthors(participants: readonly ChatParticipantProfileRecord[], currentUserId: string): readonly ChatResolvedAuthorVM[] {
return participants.map((participant) => ({
userId: participant.userId,
displayName: participant.displayName,
avatar: participantAvatar(participant),
isCurrentUser: participant.userId === currentUserId,
}));
}
function toChannelListRow(channel: ChatMemberChannelRecord, input: ChatListViewModelInput): ChatChannelListRowInput {
const title = resolveChannelTitle(channel, input.currentUserId, input.labels);
const counterpart = resolveDmCounterpart(channel, input.currentUserId);
const lastMessageAuthor = channel.lastMessage?.bodyFormat === "markdown" ? findParticipant(channel.participants, channel.lastMessage.authorUserId) : null;
return {
id: channel.id,
kind: channel.kind,
title,
subtitle: channel.description,
detailHref: chatChannelPath(input.tenantSlug, channel.id),
avatar: counterpart ? participantAvatar(counterpart) : fallbackAvatar(title),
project: null,
lastMessage: channel.lastMessage
? {
authorLabel: channel.lastMessage.bodyFormat === "system" ? null : (lastMessageAuthor?.displayName ?? input.labels.unknownUser),
snippet: channel.lastMessage.bodyFormat === "system" || channel.lastMessage.deletedAt ? "" : chatListSnippet(channel.lastMessage.body),
systemEvent:
channel.lastMessage.bodyFormat === "system"
? buildChatSystemEventVM(channel.lastMessage.systemEvent.kind, channel.lastMessage.systemEvent.metadata)
: null,
createdAtIso: channel.lastMessage.createdAt,
}
: null,
typingLabels: [],
unreadCount: channel.unreadCount,
hasMention: channel.hasUnreadMention,
isPinned: channel.member.isPinned,
isPrivate: channel.isPrivate,
notificationPreference: channel.member.notificationPreference,
archivedAtIso: channel.archivedAtIso,
memberCount: channel.memberCount,
canManage: false,
};
}
function chatListSnippet(markdown: string): string {
const plainText = markdown
.replace(/<[^>]*>/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[`*_~>#]/g, " ")
.replace(/^\s*[-+]\s+/gm, "")
.replace(/\s+/g, " ")
.trim();
if (plainText.length <= CHAT_LIST_SNIPPET_MAX_LENGTH) return plainText;
return `${plainText.slice(0, CHAT_LIST_SNIPPET_MAX_LENGTH - 1).trimEnd()}…`;
}
function toMessageCandidate(input: {
readonly message: ChatRealtimeMessage;
readonly profiles: ReadonlyMap<string, ChatParticipantProfileRecord>;
readonly readReceipts: ChatMemberChannelDetail["readReceipts"];
readonly memberCount: number;
readonly currentUserId: string;
readonly tenantSlug: string;
readonly labels: ChatViewModelLabels;
readonly feature: NonNullable<ChatMemberChannelDetail["messageFeatures"]>[number] | null;
readonly canWrite: boolean;
readonly channelKind: ChatMemberChannelRecord["kind"];
readonly chatId: string;
readonly attachments: ChatMemberChannelDetail["attachments"];
readonly canMarkAsTask: boolean;
readonly canPinAnyMessage: boolean;
readonly canAdministerMessages: boolean;
readonly canDeleteAsChannelAdmin: boolean;
readonly nowIso: string;
}): ChatMessageCandidate {
if (input.message.bodyFormat === "system") {
return {
id: input.message.id,
clientMessageId: input.message.clientMessageId,
source: "persisted",
deliveryState: "sent",
failureReasonKey: null,
author: null,
bodyMarkdown: null,
bodyFormat: "system",
systemEvent: buildChatSystemEventVM(input.message.systemEvent.kind, input.message.systemEvent.metadata),
createdAtIso: input.message.createdAt,
updatedAtIso: input.message.updatedAt,
editedAtIso: input.message.editedAt,
deletedAtIso: input.message.deletedAt,
parentMessageId: null,
mentions: [],
reactions: [],
thread: null,
attachments: [],
linkPreviews: [],
readAggregate: null,
pin: null,
actions: NO_MESSAGE_ACTIONS,
};
}
const profile = input.profiles.get(input.message.authorUserId);
const readerProfiles = input.readReceipts
.filter((receipt) => receipt.messageId === input.message.id && receipt.userId !== input.message.authorUserId)
.map((receipt) => input.profiles.get(receipt.userId))
.filter((reader): reader is ChatParticipantProfileRecord => reader !== undefined);
const reactionGroups = new Map<string, string[]>();
for (const reaction of input.feature?.reactions ?? []) {
const reactors = reactionGroups.get(reaction.emoji) ?? [];
reactors.push(reaction.userId);
reactionGroups.set(reaction.emoji, reactors);
}
const reactions = [...reactionGroups.entries()].map(([emoji, userIds]) => ({
emoji,
count: userIds.length,
reactedByViewer: userIds.includes(input.currentUserId),
reactorLabels: userIds.map((userId) => input.profiles.get(userId)?.displayName ?? input.labels.unknownUser),
}));
const thread = input.feature?.thread
? {
id: input.feature.thread.id,
replyCount: input.feature.thread.replyCount,
participantLabels: input.feature.thread.participantUserIds.map((userId) => input.profiles.get(userId)?.displayName ?? input.labels.unknownUser),
lastReplyAtIso: input.feature.thread.lastReplyAtIso,
isFollowing: input.feature.thread.participantUserIds.includes(input.currentUserId),
href: chatThreadPath(input.tenantSlug, input.chatId, input.feature.thread.id),
}
: null;
const pin = input.feature?.pin
? {
pinnedAtIso: input.feature.pin.pinnedAtIso,
pinnedByLabel: input.profiles.get(input.feature.pin.pinnedByUserId)?.displayName ?? input.labels.unknownUser,
note: input.feature.pin.note,
}
: null;
const deleted = input.message.deletedAt !== null;
const ownMessage = input.message.authorUserId === input.currentUserId;
const ageMs = Date.parse(input.nowIso) - Date.parse(input.message.createdAt);
const withinOwnMutationWindow = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= 24 * 60 * 60 * 1_000;
return {
id: input.message.id,
clientMessageId: input.message.clientMessageId,
source: "persisted",
deliveryState: "sent",
failureReasonKey: null,
author: {
userId: input.message.authorUserId,
displayName: profile?.displayName ?? input.labels.unknownUser,
avatar: profile ? participantAvatar(profile) : fallbackAvatar(input.labels.unknownUser),
isCurrentUser: input.message.authorUserId === input.currentUserId,
},
bodyMarkdown: deleted ? null : input.message.body,
bodyFormat: input.message.bodyFormat,
systemEvent: null,
createdAtIso: input.message.createdAt,
updatedAtIso: input.message.updatedAt,
editedAtIso: input.message.editedAt,
deletedAtIso: input.message.deletedAt,
parentMessageId: input.message.parentMessageId,
mentions: deleted ? [] : input.message.mentions.map((mention) => toChatResolvedMentionVM(mention, input.profiles, input.tenantSlug, input.labels)),
reactions,
thread,
attachments: deleted ? [] : input.attachments.map((attachment) => toChatAttachmentVM(attachment, input.tenantSlug)),
linkPreviews: deleted ? [] : input.message.linkPreviews,
readAggregate: toReadAggregate(readerProfiles, input.memberCount),
pin,
actions: {
canReact: input.canWrite && !deleted,
canReply: input.canWrite && !deleted,
canEdit: input.canWrite && !deleted && (input.canAdministerMessages || (ownMessage && withinOwnMutationWindow)),
canDelete: input.canWrite && !deleted && (input.canDeleteAsChannelAdmin || (ownMessage && withinOwnMutationWindow)),
canPin: input.canWrite && !deleted && input.channelKind !== "dm" && (ownMessage || input.canPinAnyMessage),
canMarkAsTask: input.canMarkAsTask && !deleted,
},
};
}
export function toChatAttachmentVM(attachment: ChatAttachmentRecord, tenantSlug: string): ChatAttachmentVM {
return {
id: `${attachment.messageId}:${attachment.fileId}`,
fileId: attachment.fileId,
kind:
attachment.linkRole === "voice" || attachment.mimeType.startsWith("audio/")
? "voice"
: attachment.linkRole === "image" || attachment.mimeType.startsWith("image/")
? "image"
: "file",
fileName: attachment.displayName,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
downloadHref: mediaVariantPath(tenantSlug, attachment.fileId, "original"),
previewHref:
attachment.mimeType.startsWith("image/") && attachment.previewStatus === "ready" ? mediaVariantPath(tenantSlug, attachment.fileId, "preview") : null,
altText: attachment.mimeType.startsWith("image/") ? attachment.displayName : null,
durationSeconds: null,
scanStatus: attachment.scanStatus === "clean" ? "clean" : attachment.scanStatus === "pending" ? "pending" : "blocked",
};
}
const NO_MESSAGE_ACTIONS = Object.freeze({
canReact: false,
canReply: false,
canEdit: false,
canDelete: false,
canPin: false,
canMarkAsTask: false,
});
export function toChatResolvedMentionVM(
mention: ChatMention,
profiles: ReadonlyMap<string, ChatParticipantProfileRecord>,
tenantSlug: string,
labels: ChatViewModelLabels
): ChatResolvedMentionVM {
const userLabel = mention.type === "user" ? profiles.get(mention.id)?.displayName : null;
const href =
mention.type === "project"
? `/${tenantSlug}/projects/${encodeURIComponent(mention.id)}`
: mention.type === "contact"
? `/${tenantSlug}/contacts/${encodeURIComponent(mention.id)}`
: mention.type === "company"
? `/${tenantSlug}/contacts/companies/${encodeURIComponent(mention.id)}`
: null;
return {
kind: mention.type,
id: mention.id,
label: userLabel ?? mention.label ?? labels.unknownMention,
href,
};
}
function toReadAggregate(readers: readonly ChatParticipantProfileRecord[], memberCount: number): ChatReadAggregateVM {
const uniqueReaders = [...new Map(readers.map((reader) => [reader.userId, reader])).values()];
return {
readCount: uniqueReaders.length,
recipientCount: Math.max(0, memberCount - 1),
readerUserIds: uniqueReaders.map((reader) => reader.userId),
readerLabels: uniqueReaders.map((reader) => reader.displayName),
labelKey: "chat.message.read.aggregate",
};
}
function resolveChannelTitle(channel: ChatMemberChannelRecord, currentUserId: string, labels: ChatViewModelLabels): string {
if (channel.kind === "dm") {
return resolveDmCounterpart(channel, currentUserId)?.displayName ?? labels.directMessage;
}
const name = channel.name?.trim();
if (name) return name;
return channel.kind === "project" ? labels.unnamedProject : labels.unnamedGroup;
}
function resolveDmCounterpart(channel: ChatMemberChannelRecord, currentUserId: string): ChatParticipantProfileRecord | null {
if (channel.kind !== "dm") return null;
return channel.participants.find((participant) => participant.userId !== currentUserId) ?? null;
}
function findParticipant(participants: readonly ChatParticipantProfileRecord[], userId: string): ChatParticipantProfileRecord | null {
return participants.find((participant) => participant.userId === userId) ?? null;
}
function participantAvatar(participant: ChatParticipantProfileRecord): ChatAvatarVM {
return {
imageUrl: participant.avatarUrl,
fallbackLabel: initials(participant.displayName),
};
}
function fallbackAvatar(label: string): ChatAvatarVM {
return { imageUrl: null, fallbackLabel: initials(label) };
}
function initials(label: string): string {
const result = label
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toLocaleUpperCase() ?? "")
.join("");
return result || "?";
}
function hasPermission(permissions: readonly string[], permission: string): boolean {
return permissions.includes(permission);
}