503 lines
21 KiB
TypeScript
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);
|
|
}
|