packages/chat-component-bindings/src/messageThreadSelector.ts (341 lines of code) (raw):
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import {
ChatBaseSelectorProps,
getChatMessages,
getIsLargeGroup,
getLatestReadTime,
getParticipants,
getReadReceipts,
getUserId
} from './baseSelectors';
import { toFlatCommunicationIdentifier } from '@internal/acs-ui-common';
import { ChatClientState, ChatMessageWithStatus, ResourceFetchResult } from '@internal/chat-stateful-client';
import { memoizeFnAll } from '@internal/acs-ui-common';
import {
ChatMessage,
Message,
CommunicationParticipant,
SystemMessage,
MessageContentType,
ReadReceiptsBySenderId
} from '@internal/react-components';
/* @conditional-compile-remove(data-loss-prevention) */
import { BlockedMessage } from '@internal/react-components';
import { createSelector } from 'reselect';
/* @conditional-compile-remove(data-loss-prevention) */
import { DEFAULT_DATA_LOSS_PREVENTION_POLICY_URL } from './utils/constants';
import { ACSKnownMessageType } from './utils/constants';
import { updateMessagesWithAttached } from './utils/updateMessagesWithAttached';
import { AttachmentMetadata } from '@internal/acs-ui-common';
import { ChatAttachment } from '@azure/communication-chat';
import type { ChatParticipant } from '@azure/communication-chat';
import { ChatAttachmentType } from '@internal/react-components';
const memoizedAllConvertChatMessage = memoizeFnAll(
(
_key: string,
chatMessage: ChatMessageWithStatus,
userId: string,
isSeen: boolean,
isLargeGroup: boolean
): Message => {
const messageType = chatMessage.type.toLowerCase();
/* @conditional-compile-remove(data-loss-prevention) */
if (chatMessage.policyViolation?.result === 'contentBlocked') {
return convertToUiBlockedMessage(chatMessage, userId, isSeen, isLargeGroup);
}
if (
messageType === ACSKnownMessageType.text ||
messageType === ACSKnownMessageType.richtextHtml ||
messageType === ACSKnownMessageType.html
) {
return convertToUiChatMessage(chatMessage, userId, isSeen, isLargeGroup);
} else {
return convertToUiSystemMessage(chatMessage);
}
}
);
const extractAttachmentMetadata = (metadata: Record<string, string>): AttachmentMetadata[] => {
const attachmentMetadata = metadata.fileSharingMetadata;
if (!attachmentMetadata) {
return [];
}
try {
return JSON.parse(attachmentMetadata);
} catch (e) {
console.error(e);
return [];
}
};
const extractTeamsAttachmentsMetadata = (
rawAttachments: ChatAttachment[]
): {
attachments: AttachmentMetadata[];
} => {
const attachments: AttachmentMetadata[] = [];
rawAttachments.forEach((rawAttachment) => {
const attachmentType = rawAttachment.attachmentType as ChatAttachmentType;
if (attachmentType === 'file') {
attachments.push({
id: rawAttachment.id,
name: rawAttachment.name ?? '',
url: extractAttachmentUrl(rawAttachment)
});
}
});
return {
attachments
};
};
/* @conditional-compile-remove(data-loss-prevention) */
const convertToUiBlockedMessage = (
message: ChatMessageWithStatus,
userId: string,
isSeen: boolean,
isLargeGroup: boolean
): BlockedMessage => {
const messageSenderId = message.sender !== undefined ? toFlatCommunicationIdentifier(message.sender) : userId;
return {
messageType: 'blocked',
createdOn: message.createdOn,
warningText: undefined,
status: !isLargeGroup && message.status === 'delivered' && isSeen ? 'seen' : message.status,
senderDisplayName: message.senderDisplayName,
senderId: messageSenderId,
messageId: message.id,
deletedOn: message.deletedOn,
mine: messageSenderId === userId,
link: DEFAULT_DATA_LOSS_PREVENTION_POLICY_URL
};
};
const extractAttachmentUrl = (attachment: ChatAttachment): string => {
return attachment.previewUrl ? attachment.previewUrl : attachment.url || '';
};
const processChatMessageContent = (message: ChatMessageWithStatus): string | undefined => {
let content = message.content?.message;
if (
message.content?.attachments &&
message.content?.attachments.length > 0 &&
sanitizedMessageContentType(message.type).includes('html')
) {
const attachments: ChatAttachment[] = message.content?.attachments;
// Fill in the src here
if (content) {
const document = new DOMParser().parseFromString(content ?? '', 'text/html');
document.querySelectorAll('img').forEach((img) => {
const attachmentPreviewUrl = attachments.find((attachment) => attachment.id === img.id)?.previewUrl;
if (attachmentPreviewUrl) {
const resourceCache = message.resourceCache?.[attachmentPreviewUrl];
const src = getResourceSourceUrl(resourceCache);
// if in error state
if (src === undefined) {
const brokenImageView = getBrokenImageViewNode(img);
img.parentElement?.replaceChild(brokenImageView, img);
} else {
// else in loading or success state
img.setAttribute('src', src);
}
setImageWidthAndHeight(img);
}
});
content = document.body.innerHTML;
}
const teamsImageHtmlContent = attachments
.filter(
(attachment) =>
attachment.attachmentType === 'image' &&
attachment.previewUrl !== undefined &&
!message.content?.message?.includes(attachment.id)
)
.map((attachment) => generateImageAttachmentImgHtml(message, attachment))
.join('');
if (teamsImageHtmlContent) {
return (content ?? '') + teamsImageHtmlContent;
}
}
return content;
};
const generateImageAttachmentImgHtml = (message: ChatMessageWithStatus, attachment: ChatAttachment): string => {
if (attachment.previewUrl !== undefined) {
const contentType = extractAttachmentContentTypeFromName(attachment.name);
const resourceCache = message.resourceCache?.[attachment.previewUrl];
const src = getResourceSourceUrl(resourceCache);
// if in error state
if (src === undefined) {
return `\r\n<p>${getBrokenImageViewNode().outerHTML}</p>`;
}
// else in loading or success state
return `\r\n<p><img alt="image" src="${src}" itemscope="${contentType}" id="${attachment.id}"></p>`;
}
return '';
};
const getResourceSourceUrl = (result?: ResourceFetchResult): string | undefined => {
if (result) {
if (!result.error && result.sourceUrl) {
// return sourceUrl for success state
return result.sourceUrl;
} else {
// return undefined for error state
return undefined;
}
}
// return empty string for loading state
return '';
};
const extractAttachmentContentTypeFromName = (name?: string): string => {
if (name === undefined) {
return '';
}
const indexOfLastDot = name.lastIndexOf('.');
if (indexOfLastDot === undefined || indexOfLastDot < 0) {
return '';
}
const contentType = name.substring(indexOfLastDot + 1);
return contentType;
};
const setImageWidthAndHeight = (img?: HTMLImageElement): void => {
if (img) {
// define aspect ratio explicitly to prevent image not being displayed correctly
// in safari, this includes image placeholder for loading state
const width = img.width;
const height = img.height;
img.style.aspectRatio = `${width}/${height}`;
}
};
const extractAttachmentsMetadata = (message: ChatMessageWithStatus): { attachments?: AttachmentMetadata[] } => {
let attachments: AttachmentMetadata[] = [];
if (message.metadata) {
attachments = attachments.concat(extractAttachmentMetadata(message.metadata));
}
if (message.content?.attachments) {
const teamsAttachments = extractTeamsAttachmentsMetadata(message.content?.attachments);
attachments = attachments.concat(teamsAttachments.attachments);
}
return { attachments: attachments.length > 0 ? attachments : undefined };
};
const convertToUiChatMessage = (
message: ChatMessageWithStatus,
userId: string,
isSeen: boolean,
isLargeGroup: boolean
): ChatMessage => {
const messageSenderId = message.sender !== undefined ? toFlatCommunicationIdentifier(message.sender) : userId;
const { attachments } = extractAttachmentsMetadata(message);
return {
messageType: 'chat',
createdOn: message.createdOn,
content: processChatMessageContent(message),
contentType: sanitizedMessageContentType(message.type),
status: !isLargeGroup && message.status === 'delivered' && isSeen ? 'seen' : message.status,
senderDisplayName: message.senderDisplayName,
senderId: messageSenderId,
messageId: message.id,
clientMessageId: message.clientMessageId,
editedOn: message.editedOn,
deletedOn: message.deletedOn,
mine: messageSenderId === userId,
metadata: message.metadata,
attachments
};
};
const convertToUiSystemMessage = (message: ChatMessageWithStatus): SystemMessage => {
const systemMessageType = message.type;
if (systemMessageType === 'participantAdded' || systemMessageType === 'participantRemoved') {
return {
messageType: 'system',
systemMessageType,
createdOn: message.createdOn,
participants:
message.content?.participants
// TODO: In our moderator logic, we use undefined name as our displayName for moderator, which should be filtered out
// Once we have a better solution to identify the moderator, remove this line
?.filter((participant: ChatParticipant) => participant.displayName && participant.displayName !== '')
.map(
(participant: ChatParticipant): CommunicationParticipant => ({
userId: toFlatCommunicationIdentifier(participant.id),
displayName: participant.displayName
})
) ?? [],
messageId: message.id,
iconName: systemMessageType === 'participantAdded' ? 'PeopleAdd' : 'PeopleBlock'
};
} else {
// Only topic updated type left, according to ACSKnown type
return {
messageType: 'system',
systemMessageType: 'topicUpdated',
createdOn: message.createdOn,
topic: message.content?.topic ?? '',
messageId: message.id,
iconName: 'Edit'
};
}
};
/**
* Selector type for {@link MessageThread} component.
*
* @public
*/
export type MessageThreadSelector = (
state: ChatClientState,
props: ChatBaseSelectorProps
) => {
userId: string;
showMessageStatus: boolean;
messages: Message[];
};
/** Returns `true` if the message has participants and at least one participant has a display name. */
const hasValidParticipant = (chatMessage: ChatMessageWithStatus): boolean =>
!!chatMessage.content?.participants && chatMessage.content.participants.some((p: ChatParticipant) => !!p.displayName);
/**
*
* @private
*/
export const messageThreadSelectorWithThread: () => MessageThreadSelector = () =>
createSelector(
[getUserId, getChatMessages, getLatestReadTime, getIsLargeGroup, getReadReceipts, getParticipants],
(userId, chatMessages, latestReadTime, isLargeGroup, readReceipts = [], participants) => {
// We can't get displayName in teams meeting interop for now, disable rr feature when it is teams interop
const isTeamsInterop = Object.values(participants).find((p) => 'microsoftTeamsUserId' in p.id) !== undefined;
// get number of participants
// filter out the non valid participants (no display name)
// Read Receipt details will be disabled when participant count is 0
const participantCount = isTeamsInterop
? undefined
: Object.values(participants).filter((p) => p.displayName && p.displayName !== '').length;
// creating key value pairs of senderID: last read message information
const readReceiptsBySenderId: ReadReceiptsBySenderId = {};
// readReceiptsBySenderId[senderID] gets updated every time a new message is read by this sender
// in this way we can make sure that we are only saving the latest read message id and read on time for each sender
readReceipts
.filter((r) => r.sender && toFlatCommunicationIdentifier(r.sender) !== userId)
.forEach((r) => {
readReceiptsBySenderId[toFlatCommunicationIdentifier(r.sender)] = {
lastReadMessage: r.chatMessageId,
displayName: participants[toFlatCommunicationIdentifier(r.sender)]?.displayName ?? ''
};
});
// A function takes parameter above and generate return value
const convertedMessages = memoizedAllConvertChatMessage((memoizedFn) =>
Object.values(chatMessages)
.filter(
(message) =>
message.type.toLowerCase() === ACSKnownMessageType.text ||
message.type.toLowerCase() === ACSKnownMessageType.richtextHtml ||
message.type.toLowerCase() === ACSKnownMessageType.html ||
(message.type === ACSKnownMessageType.participantAdded && hasValidParticipant(message)) ||
(message.type === ACSKnownMessageType.participantRemoved && hasValidParticipant(message)) ||
// TODO: Add support for topicUpdated system messages in MessageThread component.
// message.type === ACSKnownMessageType.topicUpdated ||
message.clientMessageId !== undefined
)
.filter(isMessageValidToRender)
.map((message) => {
return memoizedFn(
message.id ?? message.clientMessageId,
message,
userId,
message.createdOn <= latestReadTime,
isLargeGroup
);
})
);
updateMessagesWithAttached(convertedMessages);
return {
userId,
showMessageStatus: true,
messages: convertedMessages,
participantCount,
readReceiptsBySenderId
};
}
);
const sanitizedMessageContentType = (type: string): MessageContentType => {
const lowerCaseType = type.toLowerCase();
return lowerCaseType === 'text' || lowerCaseType === 'html' || lowerCaseType === 'richtext/html'
? lowerCaseType
: 'unknown';
};
const getBrokenImageViewNode = (img?: HTMLDivElement): HTMLDivElement => {
const wrapper = document.createElement('div');
Array.from(img?.attributes ?? []).forEach((attr) => {
wrapper.setAttribute(attr.nodeName, attr.nodeValue ?? '');
});
wrapper.setAttribute('class', 'broken-image-wrapper');
wrapper.setAttribute('data-ui-id', 'broken-image-icon');
return wrapper;
};
const isMessageValidToRender = (message: ChatMessageWithStatus): boolean => {
if (message.deletedOn) {
return false;
}
if (message.metadata?.fileSharingMetadata || message.content?.attachments?.length) {
return true;
}
/* @conditional-compile-remove(data-loss-prevention) */
if (message.policyViolation?.result === 'contentBlocked') {
return true;
}
return !!(message.content && message.content?.message !== '');
};
/**
* Selector for {@link MessageThread} component.
*
* @public
*/
export const messageThreadSelector: MessageThreadSelector = messageThreadSelectorWithThread();