Skip to content

Commit

Permalink
feat: add support for @mentions in the chat content (#2668)
Browse files Browse the repository at this point in the history
add new styling on MessageThread component
handle @mentions from the graph
render a mention as an MGT person component
handle mentions that can be nullish

---------

Signed-off-by: Musale Martin <martinmusale@microsoft.com>
Signed-off-by: Martin Musale <martinmusale@microsoft.com>
Co-authored-by: Gavin Barron <gavinbarron@microsoft.com>
  • Loading branch information
musale and gavinbarron authored Oct 13, 2023
1 parent 3c00710 commit 7ff602b
Show file tree
Hide file tree
Showing 37 changed files with 207 additions and 101 deletions.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file modified .yarn/install-state.gz
Binary file not shown.
4 changes: 2 additions & 2 deletions packages/mgt-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
"typescript": "^4.9.5"
},
"dependencies": {
"@azure/communication-calling": "1.15.2",
"@azure/communication-calling": "1.16.3",
"@azure/communication-calling-effects": "1.0.1",
"@azure/communication-chat": "1.3.1",
"@azure/communication-chat": "1.3.2",
"@azure/communication-common": "2.2.1",
"@azure/communication-identity": "1.2.0",
"@azure/communication-react": "1.7.0-beta.2",
Expand Down
34 changes: 32 additions & 2 deletions packages/mgt-chat/src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { ErrorBar, FluentThemeProvider, MessageThread, SendBox } from '@azure/communication-react';
import { ErrorBar, FluentThemeProvider, MessageThread, SendBox, MessageThreadStyles } from '@azure/communication-react';
import { FluentTheme, MessageBarType } from '@fluentui/react';
import { FluentProvider, makeStyles, shorthands, teamsLightTheme } from '@fluentui/react-components';
import { Person, PersonCardInteraction, Spinner } from '@microsoft/mgt-react';
import React, { useEffect, useState } from 'react';
import { StatefulGraphChatClient } from 'src/statefulClient/StatefulGraphChatClient';
import { StatefulGraphChatClient } from '../../statefulClient/StatefulGraphChatClient';
import { useGraphChatClient } from '../../statefulClient/useGraphChatClient';
import { onRenderMessage } from '../../utils/chat';
import ChatHeader from '../ChatHeader/ChatHeader';
import ChatMessageBar from '../ChatMessageBar/ChatMessageBar';
import { ManageChatMembers } from '../ManageChatMembers/ManageChatMembers';
import { renderMGTMention } from '../../utils/mentions';
import { registerAppIcons } from '../styles/registerIcons';

registerAppIcons();
Expand Down Expand Up @@ -49,6 +50,29 @@ const useStyles = makeStyles({
}
});

/**
* Styling for the MessageThread and its components.
*/
const messageThreadStyles: MessageThreadStyles = {
chatContainer: {
'& .ui-box': {
zIndex: 'unset'
}
},
chatMessageContainer: {
'& p>mgt-person,msft-mention': {
display: 'inline-block',
...shorthands.marginInline('0px', '2px')
}
},
myChatMessageContainer: {
'& p>mgt-person,msft-mention': {
display: 'inline-block',
...shorthands.marginInline('0px', '2px')
}
}
};

export const Chat = ({ chatId }: IMgtChatProps) => {
const styles = useStyles();
const chatClient: StatefulGraphChatClient = useGraphChatClient(chatId);
Expand Down Expand Up @@ -104,6 +128,12 @@ export const Chat = ({ chatId }: IMgtChatProps) => {
<Person userId={userId} avatarSize="small" personCardInteraction={PersonCardInteraction.hover} />
);
}}
styles={messageThreadStyles}
mentionOptions={{
displayOptions: {
onRenderMention: renderMGTMention(chatState)
}
}}
onRenderMessage={onRenderMessage}
/>
</div>
Expand Down
59 changes: 56 additions & 3 deletions packages/mgt-chat/src/statefulClient/StatefulGraphChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ import {
ChatMessageAttachment,
ChatRenamedEventMessageDetail,
MembersAddedEventMessageDetail,
MembersDeletedEventMessageDetail
MembersDeletedEventMessageDetail,
ChatMessageMention,
NullableOption
} from '@microsoft/microsoft-graph-types';
import { produce } from 'immer';
import { v4 as uuid } from 'uuid';
Expand Down Expand Up @@ -118,6 +120,7 @@ export type GraphChatClient = Pick<
onAddChatMembers: (userIds: string[], history?: Date) => Promise<void>;
onRemoveChatMember: (membershipId: string) => Promise<void>;
onRenameChat: (topic: string | null) => Promise<void>;
mentions: NullableOption<ChatMessageMention[]>;
};

interface StatefulClient<T> {
Expand Down Expand Up @@ -409,6 +412,7 @@ class StatefulGraphChatClient implements StatefulClient<GraphChatClient> {
this.notifyStateChange((draft: GraphChatClient) => {
draft.status = 'initial';
draft.messages = [];
draft.mentions = [];
draft.chat = undefined;
draft.participants = [];
});
Expand Down Expand Up @@ -504,6 +508,11 @@ class StatefulGraphChatClient implements StatefulClient<GraphChatClient> {
// This gives us both current and eventual values for each message
.map(m => this.convertChatMessage(m));

// Collect mentions
const mentions: NullableOption<ChatMessageMention[]> = messages.value
.map(m => m.mentions)
.filter(m => m?.length) as NullableOption<ChatMessageMention[]>;

// update the state with the current values
this.notifyStateChange((draft: GraphChatClient) => {
draft.participants = this._chat?.members || [];
Expand All @@ -523,6 +532,10 @@ class StatefulGraphChatClient implements StatefulClient<GraphChatClient> {
draft.onLoadPreviousChatMessages = this._nextLink ? this.loadMoreMessages : undefined;
draft.status = this._nextLink ? 'loading messages' : 'ready';
draft.chat = this._chat;
// Keep updating if there was a next link.
draft.mentions = draft.mentions?.concat(
...Array.from(mentions as Iterable<ChatMessageMention>)
) as NullableOption<ChatMessageMention[]>;
});
const futureMessages = messageConversions.filter(m => m.futureValue).map(m => m.futureValue);
// if there are eventual future values, wait for them to resolve and update the state
Expand Down Expand Up @@ -780,6 +793,7 @@ detail: ${JSON.stringify(eventDetail)}`);
* Event handler to be called when a new message is received by the notification service
*/
private readonly onMessageReceived = async (message: ChatMessage) => {
this.updateMentions(message);
await this._cache.cacheMessage(this._chatId, message);
const messageConversion = this.convertChatMessage(message);
const acsMessage = messageConversion.currentValue;
Expand All @@ -791,6 +805,23 @@ detail: ${JSON.stringify(eventDetail)}`);
}
};

/**
* When you receive a new message, check if there are any mentions and update
* the state. This will allow to match users to mentions them during rendering.
*
* @param newMessage from teams.
* @returns
*/
private readonly updateMentions = (newMessage: ChatMessage) => {
if (!newMessage) return;
this.notifyStateChange((draft: GraphChatClient) => {
const mentions = newMessage?.mentions ?? [];
draft.mentions = draft.mentions?.concat(
...Array.from(mentions as Iterable<ChatMessageMention>)
) as NullableOption<ChatMessageMention[]>;
});
};

/*
* Event handler to be called when a message deletion is received by the notification service
*/
Expand Down Expand Up @@ -995,7 +1026,8 @@ detail: ${JSON.stringify(eventDetail)}`);
}

private graphChatMessageToAcsChatMessage(graphMessage: ChatMessage, currentUser: string): MessageConversion {
if (!graphMessage.id) {
const messageId = graphMessage?.id ?? '';
if (!messageId) {
throw new Error('Cannot convert graph message to ACS message. No ID found on graph message');
}
let content = graphMessage.body?.content ?? 'undefined';
Expand All @@ -1004,17 +1036,37 @@ detail: ${JSON.stringify(eventDetail)}`);
if (this.emojiMatch(content)) {
content = this.processEmojiContent(content);
}
// Handle any mentions in the content
content = this.updateMentionsContent(content);

const imageMatch = this.graphImageMatch(content ?? '');
if (imageMatch) {
// if the message contains an image, we need to fetch the image and replace the placeholder
result = this.processMessageContent(graphMessage, currentUser);
} else {
result.currentValue = this.buildAcsMessage(graphMessage, currentUser, graphMessage.id, content);
result.currentValue = this.buildAcsMessage(graphMessage, currentUser, messageId, content);
}
return result;
}

/**
* Teams mentions are in the pattern <at id="1">User</at>. This replacement
* changes the mentions pattern to <msft-mention id="1">User</msft-mention>
* which will trigger the `mentionOptions` prop to be called in MessageThread.
*
* @param content is the message with mentions.
* @returns string with replaced mention parts.
*/
private updateMentionsContent(content: string): string {
const msftMention = `<msft-mention id="$1">$2</msft-mention>`;
const atRegex = /<at\sid="(\d+)">([a-z0-9_.-\s]+)<\/at>/gim;
content = content
.replace(/&nbsp;<at/gim, '<at')
.replace(/at>&nbsp;/gim, 'at>')
.replace(atRegex, msftMention);
return content;
}

private hasUnsupportedContent(content: string, attachments: ChatMessageAttachment[]): boolean {
const unsupportedContentTypes = [
'application/vnd.microsoft.card.codesnippet',
Expand Down Expand Up @@ -1109,6 +1161,7 @@ detail: ${JSON.stringify(eventDetail)}`);
status: 'initial',
userId: '',
messages: [],
mentions: [],
participants: [],
get participantCount() {
return this.participants?.length || 0;
Expand Down
31 changes: 31 additions & 0 deletions packages/mgt-chat/src/utils/mentions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from 'react';
import { PersonCardInteraction } from '@microsoft/mgt-components';
import { MgtTemplateProps, Person } from '@microsoft/mgt-react';
import { ChatMessageMention, User } from '@microsoft/microsoft-graph-types';
import { GraphChatClient } from 'src/statefulClient/StatefulGraphChatClient';
import { Mention } from '@azure/communication-react';

export const renderMGTMention = (chatState: GraphChatClient) => {
return (mention: Mention, defaultRenderer: (mention: Mention) => JSX.Element): JSX.Element => {
let render: JSX.Element = defaultRenderer(mention);

const mentions = chatState?.mentions ?? [];
const flatMentions = mentions.flat();
const teamsMention: ChatMessageMention | undefined = flatMentions.find(
m => m.id?.toString() === mention?.id && m.mentionText === mention?.displayText
);

const user = teamsMention?.mentioned?.user as User;
if (user) {
const MGTMention = (_props: MgtTemplateProps) => {
return defaultRenderer(mention);
};
render = (
<Person userId={user?.id} personCardInteraction={PersonCardInteraction.hover}>
<MGTMention template="default" />
</Person>
);
}
return render;
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ $mgt-flyout-box-shadow: var(--mgt-flyout-box-shadow, var(--elevation-shadow-card
animation-timing-function: cubic-bezier(0.1, 0.9, 0.2, 1);
animation-fill-mode: both;
transition: top 0.3s ease, bottom 0.3s ease, left 0.3s ease;
z-index: 9999999;

&.small {
overflow-y: auto;
Expand Down
Loading

0 comments on commit 7ff602b

Please sign in to comment.