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(); 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; readonly readReceipts: ChatMemberChannelDetail["readReceipts"]; readonly memberCount: number; readonly currentUserId: string; readonly tenantSlug: string; readonly labels: ChatViewModelLabels; readonly feature: NonNullable[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(); 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, 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); }