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

218 lines
8.6 KiB
TypeScript

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<string, ChatMemberChannelRecord["participants"][number]>,
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<string, string[]>();
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<string, readonly ChatAttachmentRecord[]> {
const grouped = new Map<string, ChatAttachmentRecord[]>();
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);
}