Skip to content

Commit de1437f

Browse files
[devtools] Update Chat rendering to support blockquote (#254)
* [devtools] Update Chat rendering to support blockquote * [devtools] Support underline and striketrhough too
1 parent a932638 commit de1437f

File tree

6 files changed

+192
-10
lines changed

6 files changed

+192
-10
lines changed

package-lock.json

Lines changed: 32 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/devtools/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"codemirror": "^6.0.1",
2929
"date-fns": "^4.1.0",
3030
"highlight.js": "^11.11.1",
31+
"htmlparser2": "^10.0.0",
3132
"mdast-util-from-markdown": "^2.0.2",
3233
"mdast-util-gfm": "^3.1.0",
3334
"micromark-extension-gfm": "^3.0.0",

packages/devtools/src/components/ChatMessage/ChatMessage.styles.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ const useChatMessageStyles = makeStyles({
1616
display: 'block',
1717
position: 'relative',
1818
padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalM}`,
19+
boxSizing: 'border-box',
1920
borderRadius: tokens.borderRadiusMedium,
2021
border: `${tokens.strokeWidthThick} solid ${tokens.colorSubtleBackground}`,
2122
width: '100%',
2223
wordWrap: 'break-word',
2324
overflowWrap: 'break-word',
24-
whiteSpace: 'pre-wrap',
2525
'&:focus': {
2626
outline: `${tokens.strokeWidthThick} solid ${tokens.colorNeutralForeground2Link}`,
2727
borderRadius: tokens.borderRadiusMedium,

packages/devtools/src/components/ChatMessage/ChatMessage.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import { Message, MessageUser, MessageReaction } from '@microsoft/teams.api';
44

55
import { useChatStore } from '../../stores/ChatStore';
66
import { MessageActionUIPayload } from '../../types/MessageActionUI';
7-
import { hasMarkdownContent } from '../../utils/markdown';
87
import Feedback from '../Feedback/Feedback';
8+
import HtmlMessageContent from '../HtmlMessageContent/HtmlMessageContent';
99
import MessageActionsToolbar from '../MessageActionsToolbar/MessageActionsToolbar';
10-
import { MarkdownContent } from '../MarkdownContent';
1110

1211
import ChatMessageDeleted from './MessageUpdate/ChatMessageDeleted';
1312
import useChatMessageStyles from './ChatMessage.styles';
@@ -117,12 +116,6 @@ const ChatMessage: FC<ChatMessageProps> = memo(
117116
);
118117
}
119118

120-
const messageContent = hasMarkdownContent(content) ? (
121-
<MarkdownContent content={content} />
122-
) : (
123-
<div className={classes.messageText}>{content}</div>
124-
);
125-
126119
return (
127120
<>
128121
<div
@@ -154,7 +147,7 @@ const ChatMessage: FC<ChatMessageProps> = memo(
154147
className={mergeClasses(classes.messageBody, streaming && classes.streaming)}
155148
>
156149
<div className={classes.messageContent}>
157-
{messageContent}
150+
<HtmlMessageContent content={content} />
158151
{value.attachments && value.attachments.length > 0 && (
159152
<MessageAttachments attachments={value.attachments} classes={classes} />
160153
)}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { makeStyles, tokens } from "@fluentui/react-components";
2+
3+
export const useClasses = makeStyles({
4+
contentContainer: {
5+
"& div, & p, & pre, & blockquote": {
6+
margin: 0,
7+
marginBottom: tokens.spacingVerticalS,
8+
},
9+
"& img": {
10+
// this to align inline emojis with the text
11+
verticalAlign: "text-bottom",
12+
},
13+
"& > blockquote": {
14+
// this is used for quoted messages
15+
margin: `${tokens.spacingVerticalXS} 0`,
16+
padding: `${tokens.spacingVerticalS} ${tokens.spacingVerticalS} ${tokens.spacingVerticalS} ${tokens.spacingVerticalM}`,
17+
border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`,
18+
borderLeft: `${tokens.strokeWidthThicker} solid ${tokens.colorNeutralForeground4}`,
19+
background: tokens.colorNeutralBackground2,
20+
fontWeight: tokens.fontWeightRegular,
21+
marginBlock: "unset",
22+
23+
// these are used for author name and timestamp
24+
"& > *:not(p)": {
25+
fontWeight: tokens.fontWeightRegular,
26+
fontSize: tokens.fontSizeBase200,
27+
lineHeight: tokens.lineHeightBase200,
28+
color: tokens.colorNeutralForeground1,
29+
},
30+
// add margin between adjacent elements - e.g between quote author name & timestamp
31+
"& > *:not(:last-child)": {
32+
marginRight: tokens.spacingHorizontalXS,
33+
},
34+
},
35+
"& > pre": {
36+
// this is used for the code blocks
37+
border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`,
38+
background: tokens.colorNeutralBackground2,
39+
40+
"& > code": {
41+
fontFamily: tokens.fontFamilyMonospace,
42+
fontWeight: tokens.fontWeightRegular,
43+
fontSize: tokens.fontSizeBase200,
44+
lineHeight: tokens.lineHeightBase200,
45+
},
46+
},
47+
},
48+
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import React from 'react';
2+
import { parseDocument } from 'htmlparser2';
3+
4+
import { useClasses } from './HtmlMessageContent.styles';
5+
6+
interface Props {
7+
content: string;
8+
}
9+
10+
// htmlparser2 lower-cases attribute names while parsing
11+
type LowercaseProps<T> = {
12+
[K in keyof T as K extends string ? Lowercase<K> : never]?: T[K];
13+
};
14+
type HTMLElementAttributes =LowercaseProps<React.HTMLAttributes<HTMLElement>>;
15+
16+
// these are attributes that we don't want to render on any of the nodes, because they either irrelevant
17+
// or problematic. E.g. className & style can break rendering; while the item* ones are irrelevant and
18+
// React really don't like how they're just lower-case.
19+
const omitAttributes : Readonly<string[]> = ['class', 'classname', 'itemid', 'itemprop', 'itemscope', 'itemtype', 'style'];
20+
21+
// this map captures the fixed set of attributes that must be applied to certain tags
22+
const fixedAttributeMap : Record<string, Readonly<Record<string, string>>> = {
23+
'a':
24+
{
25+
target: '_blank',
26+
rel: 'noopener noreferrer',
27+
}
28+
};
29+
30+
// generates child content based on itemProp, if applicable
31+
const getChildContent = ({ children, itemprop, itemid } : HTMLElementAttributes) => {
32+
switch (itemprop) {
33+
case 'time': {
34+
const date = new Date(Number(itemid ?? ''));
35+
// In the dev tools, this attribute might contain an activity guid rather than a timestamp.
36+
// No biggie, let's just fall back on the current time.
37+
return isNaN(date.getTime()) ? (new Date().toLocaleString()) : (date.toLocaleString());
38+
}
39+
default:
40+
return children;
41+
}
42+
}
43+
44+
// adjusts the attributes that are rendered for each tag - either clean up gunk, or add mandatory attributes.
45+
const getEffectiveAttributes = ({name, attribs } : {name: string, attribs: Record<string, any>}) => {
46+
// remove attributes that irrelevant or problematic.
47+
const effectiveAttributes = Object.fromEntries(
48+
Object.entries(attribs).filter(([key]) => !omitAttributes.includes(key))
49+
);
50+
51+
// add any fixed attributes for the tag
52+
const fixedAttributes = fixedAttributeMap[name];
53+
return !fixedAttributes ? effectiveAttributes : {...effectiveAttributes, ...fixedAttributes};
54+
}
55+
56+
const renderNode = (node: any, key: number = 0): React.ReactNode => {
57+
if (node.type === 'text') {
58+
return <span key={key}>{node.data}</span>;
59+
}
60+
61+
if (node.type === 'tag') {
62+
const childNodes = (node.children?.map((child: any, i: number) => renderNode(child, i)));
63+
const children = getChildContent({ ...node.attribs, children: childNodes});
64+
const attribs = getEffectiveAttributes(node);
65+
66+
switch (node.name) {
67+
case 'a':
68+
case 'b':
69+
case 'blockquote':
70+
case 'code':
71+
case 'div':
72+
case 'em':
73+
case 'i':
74+
case 'li':
75+
case 'ol':
76+
case 'p':
77+
case 'pre':
78+
case 's':
79+
case 'span':
80+
case 'strong':
81+
case 'u':
82+
case 'ul':
83+
{
84+
const Component = node.name;
85+
return <Component key={key} {...attribs}>{children}</Component>;
86+
}
87+
case 'br':
88+
case 'img':
89+
{
90+
const Component = node.name;
91+
return <Component key={key} {...attribs} />;
92+
}
93+
default:
94+
return <span key={key} {...attribs}>{children}</span>;
95+
}
96+
}
97+
98+
return null;
99+
};
100+
101+
const HtmlMessageContent = React.memo(function HtmlMessageContent ({ content }: Props) {
102+
const classes = useClasses();
103+
const dom = parseDocument(content, { });
104+
const body = dom.children || [];
105+
return <div className={classes.contentContainer} >{body.map((node, i) => renderNode(node, i))}</div>;
106+
});
107+
108+
export default HtmlMessageContent;

0 commit comments

Comments
 (0)