Skip to content

Commit 99fc5b9

Browse files
authored
[Chat] Inline Image UI and storybook doc for inline image (#2894)
1 parent 3c49184 commit 99fc5b9

16 files changed

Lines changed: 345 additions & 20 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "Implement inline image attachment for chat UI component",
4+
"packageName": "@azure/communication-react",
5+
"email": "77021369+jimchou-dev@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "Implement inline image attachment for chat UI component",
4+
"packageName": "@azure/communication-react",
5+
"email": "77021369+jimchou-dev@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

packages/communication-react/review/beta/communication-react.api.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,12 @@ export type AreParamEqual<A extends (props: any) => JSX.Element | undefined, B e
132132
// @public
133133
export type AreTypeEqual<A, B> = A extends B ? (B extends A ? true : false) : false;
134134

135+
// @beta (undocumented)
136+
export interface AttachmentDownloadResult {
137+
// (undocumented)
138+
blobUrl: string;
139+
}
140+
135141
// @public
136142
export type AvatarPersonaData = {
137143
text?: string;
@@ -2583,6 +2589,7 @@ export type MessageThreadProps = {
25832589
onLoadPreviousChatMessages?: (messagesToLoad: number) => Promise<boolean>;
25842590
onRenderMessage?: (messageProps: MessageProps, messageRenderer?: MessageRenderer) => JSX.Element;
25852591
onRenderFileDownloads?: (userId: string, message: ChatMessage) => JSX.Element;
2592+
onFetchAttachments?: (attachment: FileMetadata) => Promise<AttachmentDownloadResult>;
25862593
onUpdateMessage?: UpdateMessageCallback;
25872594
onCancelMessageEdit?: CancelEditCallback;
25882595
onDeleteMessage?: (messageId: string) => Promise<void>;

packages/communication-react/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,8 @@ export type {
258258
SendBoxErrorBarError,
259259
FileDownloadHandler,
260260
FileDownloadError,
261-
FileMetadata
261+
FileMetadata,
262+
AttachmentDownloadResult
262263
} from '../../react-components/src';
263264
/* @conditional-compile-remove(teams-inline-images) */
264265
export type { FileMetadataAttachmentType } from '../../react-components/src';

packages/react-components/review/beta/react-components.api.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export type AnnouncerProps = {
6464
ariaLive: 'off' | 'polite' | 'assertive' | undefined;
6565
};
6666

67+
// @beta (undocumented)
68+
export interface AttachmentDownloadResult {
69+
// (undocumented)
70+
blobUrl: string;
71+
}
72+
6773
// @public
6874
export interface BaseCustomStyles {
6975
root?: IStyle;
@@ -1146,6 +1152,7 @@ export type MessageThreadProps = {
11461152
onLoadPreviousChatMessages?: (messagesToLoad: number) => Promise<boolean>;
11471153
onRenderMessage?: (messageProps: MessageProps, messageRenderer?: MessageRenderer) => JSX.Element;
11481154
onRenderFileDownloads?: (userId: string, message: ChatMessage) => JSX.Element;
1155+
onFetchAttachments?: (attachment: FileMetadata) => Promise<AttachmentDownloadResult>;
11491156
onUpdateMessage?: UpdateMessageCallback;
11501157
onCancelMessageEdit?: CancelEditCallback;
11511158
onDeleteMessage?: (messageId: string) => Promise<void>;

packages/react-components/src/components/ChatMessage/ChatMessageComponent.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,17 @@ type ChatMessageComponentProps = {
8383
* @beta
8484
*/
8585
onDisplayDateTimeString?: (messageDate: Date) => string;
86+
/* @conditional-compile-remove(teams-inline-images) */
87+
/**
88+
* Optional function to fetch attachments.
89+
* @beta
90+
*/
91+
onFetchAttachments?: (attachment: FileMetadata) => Promise<void>;
92+
/* @conditional-compile-remove(teams-inline-images) */
93+
/**
94+
* Optional map of attachment ids to blob urls.
95+
*/
96+
attachmentsMap?: Record<string, string>;
8697
};
8798

8899
/**
@@ -139,6 +150,10 @@ export const ChatMessageComponent = (props: ChatMessageComponentProps): JSX.Elem
139150
/* @conditional-compile-remove(date-time-customization) */
140151
onDisplayDateTimeString={props.onDisplayDateTimeString}
141152
strings={props.strings}
153+
/* @conditional-compile-remove(teams-inline-images) */
154+
onFetchAttachments={props.onFetchAttachments}
155+
/* @conditional-compile-remove(teams-inline-images) */
156+
attachmentsMap={props.attachmentsMap}
142157
/>
143158
);
144159
}

packages/react-components/src/components/ChatMessage/ChatMessageComponentAsMessageBubble.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import { useIdentifiers } from '../../identifiers/IdentifierProvider';
1515
import { useTheme } from '../../theming';
1616
import { ChatMessageActionFlyout } from './ChatMessageActionsFlyout';
1717
import { ChatMessageContent } from './ChatMessageContent';
18+
import { ChatMessage } from '../../types/ChatMessage';
19+
/* @conditional-compile-remove(teams-inline-images) */
20+
import { FileMetadata } from '../FileDownloadCards';
1821
/* @conditional-compile-remove(data-loss-prevention) */
1922
import { BlockedMessageContent } from './ChatMessageContent';
20-
import { ChatMessage } from '../../types/ChatMessage';
2123
/* @conditional-compile-remove(data-loss-prevention) */
2224
import { BlockedMessage } from '../../types/ChatMessage';
2325
import { MessageThreadStrings } from '../MessageThread';
@@ -66,6 +68,16 @@ type ChatMessageComponentAsMessageBubbleProps = {
6668
* @beta
6769
*/
6870
onDisplayDateTimeString?: (messageDate: Date) => string;
71+
/* @conditional-compile-remove(teams-inline-images) */
72+
/**
73+
* Optional function to fetch attachments.
74+
*/
75+
onFetchAttachments?: (attachment: FileMetadata) => Promise<void>;
76+
/* @conditional-compile-remove(teams-inline-images) */
77+
/**
78+
* Optional map of attachment ids to blob urls.
79+
*/
80+
attachmentsMap?: Record<string, string>;
6981
};
7082

7183
const generateDefaultTimestamp = (
@@ -210,7 +222,14 @@ const MessageBubble = (props: ChatMessageComponentAsMessageBubbleProps): JSX.Ele
210222
}
211223
return (
212224
<div tabIndex={0}>
213-
<ChatMessageContent message={message} strings={strings} />
225+
<ChatMessageContent
226+
message={message}
227+
strings={strings}
228+
/* @conditional-compile-remove(teams-inline-images) */
229+
onFetchAttachment={props.onFetchAttachments}
230+
/* @conditional-compile-remove(teams-inline-images) */
231+
attachmentsMap={props.attachmentsMap}
232+
/>
214233
{props.onRenderFileDownloads ? props.onRenderFileDownloads(userId, message) : defaultOnRenderFileDownloads()}
215234
</div>
216235
);

packages/react-components/src/components/ChatMessage/ChatMessageContent.tsx

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import React from 'react';
55
import { _formatString } from '@internal/acs-ui-common';
6-
import { Parser } from 'html-to-react';
6+
import { Parser, ProcessNodeDefinitions, ProcessingInstructions } from 'html-to-react';
77
import Linkify from 'react-linkify';
88
import { ChatMessage } from '../../types/ChatMessage';
99
/* @conditional-compile-remove(data-loss-prevention) */
@@ -13,10 +13,16 @@ import { Link } from '@fluentui/react';
1313
/* @conditional-compile-remove(data-loss-prevention) */
1414
import { FontIcon, Stack } from '@fluentui/react';
1515
import { MessageThreadStrings } from '../MessageThread';
16+
/* @conditional-compile-remove(teams-inline-images) */
17+
import { FileMetadata } from '../FileDownloadCards';
1618

1719
type ChatMessageContentProps = {
1820
message: ChatMessage;
1921
strings: MessageThreadStrings;
22+
/* @conditional-compile-remove(teams-inline-images) */
23+
attachmentsMap?: Record<string, string>;
24+
/* @conditional-compile-remove(teams-inline-images) */
25+
onFetchAttachment?: (attachment: FileMetadata) => Promise<void>;
2026
};
2127

2228
/* @conditional-compile-remove(data-loss-prevention) */
@@ -32,6 +38,9 @@ type MessageContentWithLiveAriaProps = {
3238
content: JSX.Element;
3339
};
3440

41+
const processNodeDefinitions: ProcessNodeDefinitions = new ProcessNodeDefinitions(React);
42+
const isValidNode = (): boolean => true;
43+
3544
/** @private */
3645
export const ChatMessageContent = (props: ChatMessageContentProps): JSX.Element => {
3746
switch (props.message.contentType) {
@@ -57,14 +66,13 @@ const MessageContentWithLiveAria = (props: MessageContentWithLiveAriaProps): JSX
5766
};
5867

5968
const MessageContentAsRichTextHTML = (props: ChatMessageContentProps): JSX.Element => {
60-
const htmlToReactParser = new Parser();
6169
const liveAuthor = _formatString(props.strings.liveAuthorIntro, { author: `${props.message.senderDisplayName}` });
6270
return (
6371
<MessageContentWithLiveAria
6472
message={props.message}
6573
liveMessage={`${props.message.mine ? '' : liveAuthor} ${extractContent(props.message.content || '')}`}
6674
ariaLabel={messageContentAriaText(props)}
67-
content={htmlToReactParser.parse(props.message.content)}
75+
content={processHtmlToReact(props)}
6876
/>
6977
);
7078
};
@@ -152,3 +160,63 @@ const messageContentAriaText = (props: ChatMessageContentProps): string | undefi
152160
})
153161
: undefined;
154162
};
163+
164+
type NodeProcessInstruction = {
165+
shouldProcessNode: unknown;
166+
processNode: unknown;
167+
};
168+
169+
const processHtmlToReact = (props: ChatMessageContentProps): JSX.Element => {
170+
const htmlToReactParser = new Parser();
171+
172+
/* @conditional-compile-remove(teams-inline-images) */
173+
const processInlineImage: NodeProcessInstruction = {
174+
// Custom <img> processing
175+
shouldProcessNode: (node): boolean => {
176+
// Process img node with id in attachments list
177+
return (
178+
node.name &&
179+
node.name === 'img' &&
180+
node.attribs &&
181+
node.attribs.id &&
182+
props.message.attachedFilesMetadata?.find((f) => f.id === node.attribs.id)
183+
);
184+
},
185+
processNode: (node, children, index): HTMLElement => {
186+
// logic to check id in map/list
187+
const fileMetadata = props.message.attachedFilesMetadata?.find((f) => f.id === node.attribs.id);
188+
// if in cache, early return
189+
if (props.attachmentsMap && node.attribs.id in props.attachmentsMap) {
190+
node.attribs = { ...node.attribs, src: props.attachmentsMap[node.attribs.id] };
191+
return processNodeDefinitions.processDefaultNode(node, children, index);
192+
}
193+
// not yet in cache
194+
if (fileMetadata && props.onFetchAttachment && props.attachmentsMap) {
195+
props.onFetchAttachment(fileMetadata);
196+
if (node.attribs.id in props.attachmentsMap) {
197+
node.attribs = { ...node.attribs, src: props.attachmentsMap[node.attribs.id] };
198+
}
199+
}
200+
return processNodeDefinitions.processDefaultNode(node, children, index);
201+
}
202+
};
203+
204+
const addProcessingStep = (): NodeProcessInstruction[] => {
205+
const steps: NodeProcessInstruction[] = [];
206+
/* @conditional-compile-remove(teams-inline-images) */
207+
steps.push(processInlineImage);
208+
return steps;
209+
};
210+
211+
const processingInstructions: ProcessingInstructions = [
212+
...addProcessingStep(),
213+
{
214+
shouldProcessNode: (): boolean => {
215+
return true;
216+
},
217+
processNode: processNodeDefinitions.processDefaultNode
218+
}
219+
];
220+
221+
return htmlToReactParser.parseWithInstructions(props.message.content, isValidNode, processingInstructions);
222+
};

packages/react-components/src/components/FileDownloadCards.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ export interface FileMetadata {
5656
previewUrl?: string;
5757
}
5858

59+
/* @conditional-compile-remove(teams-inline-images) */
60+
/**
61+
* @beta
62+
*/
63+
export interface AttachmentDownloadResult {
64+
blobUrl: string;
65+
}
66+
5967
/**
6068
* Strings of _FileDownloadCards that can be overridden.
6169
*

packages/react-components/src/components/MessageThread.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { MessageThread } from './MessageThread';
66
import { ChatMessage } from '../types';
77
/* @conditional-compile-remove(data-loss-prevention) */
88
import { BlockedMessage } from '../types';
9+
/* @conditional-compile-remove(teams-inline-images) */
10+
import { AttachmentDownloadResult, FileMetadata } from './FileDownloadCards';
11+
/* @conditional-compile-remove(teams-inline-images) */
12+
import { act } from 'react-dom/test-utils';
913
import Enzyme from 'enzyme';
14+
/* @conditional-compile-remove(teams-inline-images) */
15+
import { mount } from 'enzyme';
1016
import Adapter from 'enzyme-adapter-react-16';
1117
import { mountWithLocalization, createTestLocale } from './utils/testUtils';
1218
/* @conditional-compile-remove(date-time-customization) @conditional-compile-remove(data-loss-prevention) */
@@ -146,3 +152,47 @@ describe('Message blocked should display default blocked text correctly', () =>
146152
expect(component.text()).toContain(testLocale.strings.messageThread.blockedWarningText);
147153
});
148154
});
155+
156+
/* @conditional-compile-remove(teams-inline-images) */
157+
describe('Message should display inline image correctly', () => {
158+
test('Message richtext/html img src should be correct', async () => {
159+
const expectedBeforeImgSrc = 'urlBeforeSrcReplacement';
160+
const expectedImgSrc = 'someImgSrcUrl';
161+
const sampleMessage: ChatMessage = {
162+
messageType: 'chat',
163+
senderId: 'user3',
164+
content: `<p>Test</p><p><img alt="image" src="${expectedBeforeImgSrc}" itemscope="png" width="166.5625" height="250" id="SomeImageId1" style="vertical-align:bottom"></p><p>&nbsp;</p>`,
165+
senderDisplayName: 'Miguel Garcia',
166+
messageId: Math.random().toString(),
167+
createdOn: new Date('2019-04-13T00:00:00.000+08:09'),
168+
mine: false,
169+
attached: false,
170+
contentType: 'html',
171+
attachedFilesMetadata: [
172+
{
173+
id: 'SomeImageId1',
174+
name: 'SomeImageId1',
175+
attachmentType: 'teamsInlineImage',
176+
extension: 'png',
177+
url: 'images/inlineImageExample1.png',
178+
previewUrl: expectedImgSrc
179+
}
180+
]
181+
};
182+
const onFetchAttachment = async (attachment: FileMetadata): Promise<AttachmentDownloadResult> => {
183+
return {
184+
blobUrl: attachment.previewUrl ?? ''
185+
};
186+
};
187+
const component = mount(
188+
<MessageThread userId="user1" messages={[sampleMessage]} onFetchAttachments={onFetchAttachment} />
189+
);
190+
await act(async () => {
191+
expect(component.find('img').prop('src')).toEqual(expectedBeforeImgSrc);
192+
await new Promise((resolve) => setTimeout(resolve, 0));
193+
component.update();
194+
195+
expect(component.find('img').prop('src')).toEqual(expectedImgSrc);
196+
});
197+
});
198+
});

0 commit comments

Comments
 (0)