Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: render adaptive cards #2746

Open
wants to merge 40 commits into
base: next/mgt-chat
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
3427a73
Add adaptive cards and markdownit to render md
musale Oct 3, 2023
69abaed
Render adaptive cards in a chat
musale Oct 3, 2023
2134f17
Render only Action.OpenUrl type of cards
musale Oct 3, 2023
a6c449d
Add types for markdown-it
musale Oct 3, 2023
84b898d
Merge branch 'next/mgt-chat' into adaptive-cards
musale Oct 5, 2023
7594315
WIP: redo rendering the cards from the state to the chat component
musale Oct 6, 2023
b04905b
Merge branch 'next/mgt-chat' into adaptive-cards
musale Oct 12, 2023
c56bc11
Use hooks to render the adaptive card
musale Oct 19, 2023
39d26a5
Merge branch 'next/mgt-chat' into adaptive-cards
musale Oct 19, 2023
9e44636
Merge chat.tsx to onRenderMessage.tsx'
musale Oct 19, 2023
c6ac564
Fine tune the adaptive cards rendered
musale Oct 23, 2023
6c137c4
Remove unnecessary assertions
musale Oct 24, 2023
4379e2f
Fix naming and imports issues
musale Oct 24, 2023
03da770
Update the artifacts
musale Oct 26, 2023
992c356
Merge branch 'next/mgt-chat' into adaptive-cards
musale Oct 26, 2023
deb0051
Render multiple cards in a message and clear div before append
musale Oct 26, 2023
d18846d
Add react-components to the mgt-chat package
musale Oct 31, 2023
062cc3c
Add ChatMessage and ChatMyMessage as wrappers around adaptive card me…
musale Oct 31, 2023
0789365
Add the chat message header props with styling
musale Oct 31, 2023
726cd3d
Merge branch 'next/mgt-chat' into adaptive-cards
musale Oct 31, 2023
dd13a48
Add copyright header text on new files
musale Oct 31, 2023
bfe432d
Update the string descriptions and fix tests
musale Oct 31, 2023
b12f680
Add adaptivecards-designer
musale Nov 1, 2023
8a20450
Add teams light as the default CSS styling
musale Nov 1, 2023
a38da05
Add styling for my chat messages
musale Nov 1, 2023
47af024
Add message details prop to the container
musale Nov 1, 2023
28d56e9
WIP: set icons for status
musale Nov 2, 2023
afebe03
Merge branch 'next/mgt-chat' into adaptive-cards
musale Nov 7, 2023
740d3dd
Fix tests for displayDates
musale Nov 7, 2023
30e12d7
Move Mgt adaptive cards component to it's pwn file
musale Nov 8, 2023
3b7562f
Move message container to it's own file
musale Nov 8, 2023
87d3f0a
WIP: find icons
musale Nov 8, 2023
a84a063
Revert to default rendering for the uncustomized messages
musale Nov 8, 2023
cad5fc7
Remove displayDates util and use utils in mgt-component
musale Nov 8, 2023
5f13cc3
Add tests for getRelativeDisplayDate
musale Nov 8, 2023
7f7dab1
Merge branch 'next/mgt-chat' into adaptive-cards
sebastienlevert Nov 14, 2023
cd86198
Move MgtMessageContainer to it's own file
musale Nov 14, 2023
ea55ed4
WIP: render emojis
musale Nov 14, 2023
fe88388
Merge branch 'adaptive-cards' of github.com:microsoftgraph/microsoft-…
musale Nov 14, 2023
ce90d31
Merge branch 'next/mgt-chat' into adaptive-cards
gavinbarron Nov 15, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"@babel/preset-typescript": "^7.23.0",
"@custom-elements-manifest/analyzer": "^0.8.3",
"@esm-bundle/chai": "^4.3.4-fix.0",
"@fluentui-contrib/react-chat": "^0.1.7",
"@microsoft/eslint-config-msgraph": "^2.0.0",
"@octokit/rest": "^18.5.3",
"@open-wc/testing": "^3.2.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/mgt-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"author": "Microsoft",
"license": "MIT",
"devDependencies": {
"@types/markdown-it": "^13.0.2",
"@types/react": "^17.0.0",
"adaptivecards-designer": "^2.4.3",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-scripts": "5.0.1",
Expand All @@ -63,8 +65,11 @@
"@microsoft/microsoft-graph-types": "^2.0.0",
"@microsoft/microsoft-graph-types-beta": "^0.16.0-preview",
"@microsoft/signalr": "^7.0.4",
"adaptivecards": "^3.0.1",
"immer": "^9.0.6",
"markdown-it": "^13.0.2",
"opencrypto": "1.5.5",
"swiper": "^10.3.1",
"uuid": "^9.0.0",
"web-vitals": "^2.1.4"
},
Expand Down
41 changes: 38 additions & 3 deletions packages/mgt-chat/src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { Person, PersonCardInteraction, Spinner } from '@microsoft/mgt-react';
import React, { useEffect, useState } from 'react';
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 { onRenderMessage } from '../../utils/onRenderMessage';
import { renderMGTMention } from '../../utils/mentions';
import { registerAppIcons } from '../styles/registerIcons';
import { ChatHeader } from '../ChatHeader/ChatHeader';

registerAppIcons();

Expand All @@ -23,7 +23,11 @@ const useStyles = makeStyles({
flexDirection: 'column',
height: '100%',
...shorthands.overflow('auto'),
paddingBlockEnd: '12px'
paddingBlockEnd: '12px',

'& p': {
...shorthands.margin('unset')
}
},
chatMessages: {
height: 'auto',
Expand Down Expand Up @@ -69,6 +73,37 @@ const messageThreadStyles: MessageThreadStyles = {
chatContainer: {
'& .ui-box': {
zIndex: 'unset'
},
'& .fui-ChatMessage': {
marginLeft: 'unset',
width: '100%'
},
'& .fui-ChatMessage__author': {
fontWeight: 'var(--fontWeightSemibold)',
color: 'var(--colorNeutralForeground1)',
...shorthands.margin('0px', '0px', 'var(--spacingVerticalXL)', '0px')
},
'& .fui-ChatMessage__timestamp,.fui-ChatMessage__details': {
fontWeight: 'var(--fontWeightRegular)',
color: 'var(--colorNeutralForeground3)',
...shorthands.margin('0px', '0px', 'var(--spacingVerticalXL)', '0px')
},
'& .fui-ChatMyMessage': {
gridTemplateColumns: 'auto auto',
columnGap: 'unset'
},
'& .fui-ChatMyMessage__body': {
background: '#c7e0f4' // No token found for this color, yet.
},
'& .fui-ChatMyMessage__author': {
fontWeight: 'var(--fontWeightSemibold)',
color: 'var(--colorNeutralForeground1)',
...shorthands.margin('0px', '0px', 'var(--spacingVerticalXL)', '0px')
},
'& span.fui-ChatMyMessage__timestamp,.fui-ChatMyMessage__details': {
fontWeight: 'var(--fontWeightRegular)',
color: 'var(--colorNeutralForeground3)',
...shorthands.margin('0px', '0px', 'var(--spacingVerticalXL)', '0px')
}
},
chatMessageContainer: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/

import React from 'react';
import { MessageProps, MessageRenderer } from '@azure/communication-react';
import { getRelativeDisplayDate } from '@microsoft/mgt-components';
import { messageContainer } from '../../utils/messageContainer';
import { isChatMessage } from '../../utils/types';

interface MgtMessageContainerProps {
messageProps: MessageProps;
defaultOnRender?: MessageRenderer;
}

const MgtMessageContainer = ({ messageProps, defaultOnRender }: MgtMessageContainerProps) => {
// TODO: find out how to render emojis
// <p>This is <emoji id=\"cool\" alt=\"😎\" title=\"Cool\"></emoji></p>
if (isChatMessage(messageProps.message)) {
const author = messageProps.message?.senderDisplayName ?? '';
const timestamp = getRelativeDisplayDate(new Date(messageProps.message.createdOn));
const details = messageProps.message?.status ?? '';
const body: string = messageProps.message?.content ?? '';
const contentType = messageProps.message.contentType;
const Container = messageContainer(messageProps.message);

console.log(messageProps);
switch (contentType) {
case 'text':
return (
<Container author={author} timestamp={timestamp}>
{body}
</Container>
);
case 'html':
case 'richtext/html': {
const bodyContent = processEmojiContent(body);
const html = { __html: bodyContent };
return (
<Container author={author} timestamp={timestamp}>
<div dangerouslySetInnerHTML={html}></div>
</Container>
);
}
default:
return defaultOnRender ? defaultOnRender(messageProps) : <></>;
}
}
return defaultOnRender ? defaultOnRender(messageProps) : <></>;
};

/**
* Regex to detect and extract emoji alt text
*
* Pattern breakdown:
* (<emoji[^>]+): Captures the opening emoji tag, including any attributes.
* alt=["'](\w*[^"']*)["']: Matches and captures the "alt" attribute value within single or double quotes. The value can contain word characters but not quotes.
* (.*[^>]): Captures any remaining text within the opening emoji tag, excluding the closing tag.
* </emoji>: Matches the closing emoji tag.
*/
const emojiRegex = /(<emoji[^>]+)alt=["'](\w*[^"']*)["'](.*[^>])<\/emoji>/;

const emojiMatch = (messageContent: string): RegExpMatchArray | null => {
return messageContent.match(emojiRegex);
};

const processEmojiContent = (messageContent: string): string => {
let result = messageContent;
let match = emojiMatch(result);
while (match) {
result = result.replace(emojiRegex, '$2');
match = emojiMatch(result);
}
return result;
};

export default MgtMessageContainer;
152 changes: 152 additions & 0 deletions packages/mgt-chat/src/components/MgtAdaptiveCard/MgtAdaptiveCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* -------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved. Licensed under the MIT License.
* See License in the project root for license information.
* -------------------------------------------------------------------------------------------
*/

import { MessageProps } from '@azure/communication-react';
import { Action, AdaptiveCard, IMarkdownProcessingResult } from 'adaptivecards';
import MarkdownIt from 'markdown-it';
import React, { useEffect, useRef } from 'react';
import { isChatMessage, isActionOpenUrl } from '../../utils/types';
import { ChatMessageAttachment } from '@microsoft/microsoft-graph-types';
import { FluentIcon } from '@fluentui/react-icons/lib/utils/createFluentIcon';
import { Eye12Filled, Eye12Regular, Send16Filled, Send16Regular, bundleIcon } from '@fluentui/react-icons';
import {
IAdaptiveCard,
ISubmitAction,
IOpenUrlAction,
IShowCardAction,
IExecuteAction
} from 'adaptivecards/lib/schema';
import { messageContainer } from '../../utils/messageContainer';
import { getRelativeDisplayDate } from '@microsoft/mgt-components';

type IAction = ISubmitAction | IOpenUrlAction | IShowCardAction | IExecuteAction;

/**
* Props for an adaptive card message.s
*/
interface MgtAdaptiveCardProps {
attachments: ChatMessageAttachment[];
defaultOnRender?: (props: MessageProps) => JSX.Element;
messageProps: MessageProps;
}

/**
* TODO: find the correct icons, if needed.
* Message status icons.
*/
const detailsIcons: Record<string, FluentIcon> = {
seen: bundleIcon(Eye12Filled, Eye12Regular),
delivered: bundleIcon(Eye12Filled, Eye12Regular),
sending: bundleIcon(Send16Filled, Send16Regular),
failed: bundleIcon(Eye12Filled, Eye12Regular),
'': bundleIcon(Eye12Filled, Eye12Regular)
};

/**
* Render an adaptive card from the attachments
*/
const MgtAdaptiveCard = (msg: MgtAdaptiveCardProps) => {
const cardRef = useRef<HTMLDivElement | null>(null);
const attachments = msg.attachments;
const adaptiveCardAttachments = getAdaptiveCardAttachments(attachments);
useEffect(() => {
if (adaptiveCardAttachments.length) {
const cardElement = cardRef?.current;
// Remove all children before appending the attachment elements
while (cardElement?.firstChild) cardElement.removeChild(cardElement?.lastChild as Node);
for (const attachment of adaptiveCardAttachments) {
const cardHtmlElement = getHtmlElementFromAttachment(attachment);
cardElement?.appendChild(cardHtmlElement!);
}
}
}, [cardRef, adaptiveCardAttachments]);
const defaultOnRender = msg?.defaultOnRender;
const messageProps = msg.messageProps;
const defaultRender = defaultOnRender ? defaultOnRender(messageProps) : <></>;
const Container = messageContainer(msg.messageProps.message);
const author = isChatMessage(msg.messageProps.message) ? msg.messageProps.message?.senderDisplayName : '';
const timestamp = getRelativeDisplayDate(new Date(msg.messageProps.message.createdOn));
const details = isChatMessage(msg.messageProps.message) ? msg.messageProps.message?.status : '';
const DetailsIcon: FluentIcon = detailsIcons[details as string];

return adaptiveCardAttachments.length ? (
<Container author={author} timestamp={timestamp} details={<DetailsIcon />}>
<div ref={cardRef}></div>
</Container>
) : (
defaultRender
);
};

/**
* Filters out the adaptive card attachments.
* @param attachments
* @returns
*/
const getAdaptiveCardAttachments = (attachments: ChatMessageAttachment[]): ChatMessageAttachment[] => {
const cardAttachments: ChatMessageAttachment[] = [];
for (const att of attachments) {
const contentType = att?.contentType ?? '';
if (contentType === 'application/vnd.microsoft.card.adaptive') {
cardAttachments.push(att);
}
}
return cardAttachments;
};

/**
* Process the attachment object and return an HTMLElement or nothing.
* @param attachment
* @returns
*/
const getHtmlElementFromAttachment = (attachment: ChatMessageAttachment | undefined): HTMLElement | undefined => {
const adaptiveCard = new AdaptiveCard();
const adaptiveCardContentString: string = attachment?.content ?? '';
const adaptiveCardContent = JSON.parse(adaptiveCardContentString) as IAdaptiveCard;

// Check if the actions property has OpenUrl actions only
const actions = adaptiveCardContent?.actions?.filter(ac => ac.type === 'Action.OpenUrl');
if (actions) {
// Update actions to only Action.OpenUrl actions.
adaptiveCardContent.actions = actions;
}

// Check if the body has actionSet actions and filter for OpenUrl only
const actionSetArray = adaptiveCardContent?.body?.filter(ac => Object.values(ac).includes('ActionSet'));
if (actionSetArray) {
const finalInnerActions = [];
for (const actionSet of actionSetArray) {
const innerActions = actionSet?.actions as IAction[];
const valid = innerActions?.filter(ac => ac?.type === 'Action.OpenUrl');
if (valid) finalInnerActions.push(...valid);
}

for (const b of adaptiveCardContent?.body ?? []) {
if (Object.values(b).includes('ActionSet')) {
b.actions = finalInnerActions;
}
}
}

// markdown support
AdaptiveCard.onProcessMarkdown = (text: string, result: IMarkdownProcessingResult) => {
const md = new MarkdownIt();
result.outputHtml = md.render(text);
result.didProcess = true;
};

adaptiveCard.parse(adaptiveCardContent);
adaptiveCard.onExecuteAction = (action: Action) => {
if (isActionOpenUrl(action)) {
const url: string = action?.url ?? '';
window.open(url, '_blank', 'noopener,noreferrer');
}
};
return adaptiveCard.render();
};

export default MgtAdaptiveCard;
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ const useStyles = makeStyles({
boxShadow: '0px 4px 8px 0px rgba(0, 0, 0, 0.14), 0px 0px 2px 0px rgba(0, 0, 0, 0.12)',
textDecorationLine: 'none',
color: '#424242',
...shorthands.margin('18px', '0px', '8px', '0px'),
alignItems: 'center',
...shorthands.margin('0px', '0px', '2px', '0px'),
...shorthands.borderRadius('6px'),
...shorthands.padding('16px'),
...shorthands.gap('6px'),
Expand Down
Loading
Loading