import { CHAT_PERMISSIONS, type ChatAttachmentRecord, type ChatMemberChannelRecord, type ChatMessageFeatureRecord } from "@hivegrid/chat"; import { buildChatMessageDeepLink } from "@hivegrid/chat/deep-links"; import type { ChatRealtimeMessage } from "@hivegrid/realtime"; import { getChatThreadDetail, listChatPinnedMessages, sendChatThreadReply, setChatMessagePin, setChatReaction } from "@hivegrid/chat/message-features.server"; import { buildChatPinnedMessagesVM, buildChatThreadVM, type ChatFeatureMessageVM, type ChatPinnedMessagesVM, type ChatThreadVM } from "@hivegrid/view-models"; import type { ChatRouteContext } from "../routes/chatAccess.server"; import { chatChannelPath } from "../routes/chatRoutePaths"; import { toChatAttachmentVM, toChatResolvedMentionVM } from "./chatViewModel.server"; export type ChatMessageFeatureLabels = { readonly directMessage: string; readonly unnamedGroup: string; readonly unnamedProject: string; readonly unknownUser: string; readonly unknownMention: string; }; export async function loadChatThreadPage(input: { readonly context: ChatRouteContext; readonly tenantSlug: string; readonly chatId: string; readonly threadId: string; readonly labels: ChatMessageFeatureLabels; }): Promise<{ readonly vm: ChatThreadVM; readonly eventCursor: string }> { const detail = await getChatThreadDetail({ ...input.context, chatId: input.chatId, threadId: input.threadId }); const features = new Map(detail.messageFeatures.map((feature) => [feature.messageId, feature])); const attachments = groupAttachments(detail.attachments); const profiles = new Map(detail.channel.participants.map((profile) => [profile.userId, profile])); const canWrite = input.context.permissions.includes(CHAT_PERMISSIONS.write) && detail.channel.member.canWrite && detail.channel.archivedAtIso === null; const canAdministerMessages = input.context.permissions.includes(CHAT_PERMISSIONS.admin); const canDeleteAsChannelAdmin = detail.channel.member.roleInChat === "admin" || canAdministerMessages; const nowIso = new Date().toISOString(); const vm = buildChatThreadVM({ chatId: detail.channel.id, threadId: detail.parentMessage.id, channelTitle: resolveChannelTitle(detail.channel, input.context.userId, input.labels), backHref: chatChannelPath(input.tenantSlug, detail.channel.id), parent: mapFeatureMessage( detail.parentMessage, features.get(detail.parentMessage.id) ?? null, profiles, input.context.userId, canWrite, detail.channel.kind, input.tenantSlug, attachments.get(detail.parentMessage.id) ?? [], canAdministerMessages, canDeleteAsChannelAdmin, nowIso, input.labels ), replies: detail.replies.map((message) => mapFeatureMessage( message, features.get(message.id) ?? null, profiles, input.context.userId, canWrite, detail.channel.kind, input.tenantSlug, attachments.get(message.id) ?? [], canAdministerMessages, canDeleteAsChannelAdmin, nowIso, input.labels ) ), replyCount: detail.replies.length, canReply: canWrite, }); return { vm, eventCursor: detail.channel.eventCursor }; } export async function loadChatPinnedPage(input: { readonly context: ChatRouteContext; readonly tenantSlug: string; readonly chatId: string; readonly labels: ChatMessageFeatureLabels; }): Promise<{ readonly vm: ChatPinnedMessagesVM; readonly eventCursor: string }> { const result = await listChatPinnedMessages({ ...input.context, chatId: input.chatId }); const profiles = new Map(result.channel.participants.map((profile) => [profile.userId, profile])); const canWrite = input.context.permissions.includes(CHAT_PERMISSIONS.write) && result.channel.member.canWrite && result.channel.archivedAtIso === null; const vm = buildChatPinnedMessagesVM({ chatId: result.channel.id, channelTitle: resolveChannelTitle(result.channel, input.context.userId, input.labels), backHref: chatChannelPath(input.tenantSlug, result.channel.id), rows: result.pinnedMessages.map((row) => ({ message: mapFeatureMessage( row.message, { messageId: row.message.id, reactions: [], thread: null, pin: row.pin, }, profiles, input.context.userId, false, result.channel.kind, input.tenantSlug, [], false, false, new Date().toISOString(), input.labels ), pinnedAtIso: row.pin.pinnedAtIso, pinnedByLabel: row.pinnedBy?.displayName ?? input.labels.unknownUser, note: row.pin.note, messageHref: buildChatMessageDeepLink({ tenantSlug: input.tenantSlug, chatId: result.channel.id, messageId: row.message.id }), canUnpin: canWrite, })), }); return { vm, eventCursor: result.channel.eventCursor }; } export async function createChatThreadReply(input: { readonly context: ChatRouteContext; readonly chatId: string; readonly threadId: string; readonly body: string; readonly requestId: string; readonly clientMessageId: string; }) { return sendChatThreadReply({ ...input.context, ...input }); } export async function changeChatReaction(input: { readonly context: ChatRouteContext; readonly chatId: string; readonly messageId: string; readonly emoji: string; readonly active: boolean; readonly requestId: string; }) { return setChatReaction({ ...input.context, ...input }); } export async function changeChatMessagePin(input: { readonly context: ChatRouteContext; readonly chatId: string; readonly messageId: string; readonly pinned: boolean; readonly requestId: string; }) { return setChatMessagePin({ ...input.context, ...input }); } function mapFeatureMessage( message: ChatRealtimeMessage, feature: ChatMessageFeatureRecord | null, profiles: ReadonlyMap, currentUserId: string, canWrite: boolean, channelKind: ChatMemberChannelRecord["kind"], tenantSlug: string, attachments: readonly ChatAttachmentRecord[], canAdministerMessages: boolean, canDeleteAsChannelAdmin: boolean, nowIso: string, labels: ChatMessageFeatureLabels ): ChatFeatureMessageVM { const deleted = message.deletedAt !== null; const author = message.authorUserId ? profiles.get(message.authorUserId) : null; const ownMessage = message.authorUserId === currentUserId; const ageMs = Date.parse(nowIso) - Date.parse(message.createdAt); const withinOwnMutationWindow = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= 24 * 60 * 60 * 1_000; const reactionGroups = new Map(); for (const reaction of feature?.reactions ?? []) { const userIds = reactionGroups.get(reaction.emoji) ?? []; userIds.push(reaction.userId); reactionGroups.set(reaction.emoji, userIds); } return { id: message.id, authorLabel: author?.displayName ?? "?", authorAvatarUrl: author?.avatarUrl ?? null, bodyMarkdown: deleted ? null : message.body, createdAtIso: message.createdAt, deleted, mentions: deleted ? [] : message.mentions.map((mention) => toChatResolvedMentionVM(mention, profiles, tenantSlug, { ...labels, }) ), attachments: deleted ? [] : attachments.map((attachment) => toChatAttachmentVM(attachment, tenantSlug)), reactions: [...reactionGroups].map(([emoji, userIds]) => ({ emoji, count: userIds.length, reactedByViewer: userIds.includes(currentUserId), })), pinned: feature?.pin !== null && feature?.pin !== undefined, canReact: canWrite && !deleted, canPin: canWrite && !deleted && channelKind !== "dm", canEdit: canWrite && !deleted && (canAdministerMessages || (ownMessage && withinOwnMutationWindow)), canDelete: canWrite && !deleted && (canDeleteAsChannelAdmin || (ownMessage && withinOwnMutationWindow)), }; } function groupAttachments(attachments: readonly ChatAttachmentRecord[]): ReadonlyMap { const grouped = new Map(); for (const attachment of attachments) { grouped.set(attachment.messageId, [...(grouped.get(attachment.messageId) ?? []), attachment]); } return grouped; } function resolveChannelTitle(channel: ChatMemberChannelRecord, currentUserId: string, labels: ChatMessageFeatureLabels): string { if (channel.kind === "dm") { return channel.participants.find((participant) => participant.userId !== currentUserId)?.displayName ?? labels.directMessage; } return channel.name?.trim() || (channel.kind === "project" ? labels.unnamedProject : labels.unnamedGroup); }