218 lines
8.6 KiB
TypeScript
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);
|
|
}
|