Skip to content

Commit c583510

Browse files
authored
Add Copy button to white label UX (#5262)
* Add Copy button to white label * Fix keywords * Fix includes * Use getOrgSchemaMessage * Add entry * Remove experimental on Copy button * Add "clipboard" mode * Add `mode` * Update screenshot * Incorporating PR feedbacks * Move MonochromeImageMasker to internal * Remove port duped in npm start * Add internal to bundle * Add API internal * Revert "Add API internal" This reverts commit dcf3770. * Revert "Add internal to bundle" This reverts commit 5e7ef00. * Revert "Move MonochromeImageMasker to internal" This reverts commit 4e756a4.
1 parent 94018ab commit c583510

File tree

53 files changed

+423
-490
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+423
-490
lines changed

CHANGELOG.md

+1-2
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,13 @@ Notes: web developers are advised to use [`~` (tilde range)](https://github.com/
3838
- Added `CopilotMessageHeader` component for displaying bot information in the "copilot" variant, in PR [#5258](https://github.com/microsoft/BotFramework-WebChat/pull/5258), by [@OEvgeny](https://github.com/OEvgeny)
3939
- Updated Fluent theme styling to improve accessibility and visual consistency, in PR [#5258](https://github.com/microsoft/BotFramework-WebChat/pull/5258), by [@OEvgeny](https://github.com/OEvgeny)
4040
- Fixed header font in copilot variant, in PR [#5261](https://github.com/microsoft/BotFramework-WebChat/pull/5261), by [@OEvgeny](https://github.com/OEvgeny)
41-
- (Experimental) Added "Copy" button to bot messages in Fluent UI if it contains keyword `AllowCopy`, in PR [#5259](https://github.com/microsoft/BotFramework-WebChat/pull/5259), by [@compulim](https://github.com/compulim)
41+
- Added "Copy" button to bot messages in Fluent UI if it contains keyword `AllowCopy`, in PR [#5259](https://github.com/microsoft/BotFramework-WebChat/pull/5259) and [#5262](https://github.com/microsoft/BotFramework-WebChat/pull/5262), by [@compulim](https://github.com/compulim)
4242

4343
### Changed
4444

4545
- Updated `useSuggestedActions` to return the activity the suggested actions originated from, in PR [#5255](https://github.com/microsoft/BotFramework-WebChat/issues/5255), by [@compulim](https://github.com/compulim)
4646
- Improved focus trap implementation by preserving focus state and removing sentinels, in PR [#5243](https://github.com/microsoft/BotFramework-WebChat/pull/5243), by [@OEvgeny](https://github.com/OEvgeny)
4747

48-
4948
### Fixed
5049

5150
- Fixed [#5256](https://github.com/microsoft/BotFramework-WebChat/issues/5256). `styleOptions.maxMessageLength` should support any JavaScript number value including `Infinity`, by [@compulim](https://github.com/compulim), in PR [#5255](https://github.com/microsoft/BotFramework-WebChat/issues/pull/5255)
Loading
Loading
Loading

__tests__/html/fluentTheme/copyButton.html __tests__/html/copyButton.html

+3-9
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
<script crossorigin="anonymous" src="/test-harness.js"></script>
99
<script crossorigin="anonymous" src="/test-page-object.js"></script>
1010
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
11-
<script crossorigin="anonymous" src="/__dist__/botframework-webchat-fluent-theme.production.min.js"></script>
1211
</head>
1312
<body>
1413
<main id="webchat" style="position: relative"></main>
@@ -17,7 +16,7 @@
1716
const {
1817
React,
1918
ReactDOM: { render },
20-
WebChat: { FluentThemeProvider, ReactWebChat }
19+
WebChat: { ReactWebChat }
2120
} = window; // Imports in UMD fashion.
2221

2322
const { directLine, store } = testHelpers.createDirectLineEmulator();
@@ -48,12 +47,7 @@
4847
</React.Fragment>
4948
);
5049

51-
render(
52-
<FluentThemeProvider>
53-
<App />
54-
</FluentThemeProvider>,
55-
document.getElementById('webchat')
56-
);
50+
render(<App />, document.getElementById('webchat'));
5751

5852
await pageConditions.uiConnected();
5953

@@ -87,7 +81,7 @@
8781

8882
// WHEN: Focus on the "Copy" button via keyboard.
8983
await host.click(document.querySelector(`[data-testid="${WebChat.testIds.sendBoxTextBox}"]`));
90-
await host.sendShiftTab(2);
84+
await host.sendShiftTab(3);
9185
await host.sendKeys('ENTER');
9286

9387
// THEN: Should focus on the "Copy" button

__tests__/html/copyButton.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */
2+
3+
test('copy button should work', () => runHTML('copyButton'));

__tests__/html/fluentTheme/copyButton.layout.html __tests__/html/copyButton.layout.html

+18-16
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
run(async function () {
2323
const {
2424
Fluent: { createDarkTheme, createLightTheme, FluentProvider },
25-
React,
2625
ReactDOMClient: { createRoot },
2726
WebChat: { FluentThemeProvider, ReactWebChat }
2827
} = window; // Imports in UMD fashion.
@@ -58,19 +57,23 @@
5857

5958
const root = createRoot(document.getElementById('webchat'));
6059

61-
root.render(
62-
<FluentProvider
63-
theme={
64-
searchParams.get('theme') === 'dark'
65-
? createDarkTheme(customBrandRamp)
66-
: createLightTheme(customBrandRamp)
67-
}
68-
>
69-
<FluentThemeProvider variant={searchParams.get('variant') || ''}>
70-
<App />
71-
</FluentThemeProvider>
72-
</FluentProvider>
73-
);
60+
if (searchParams.get('variant') === 'white label') {
61+
root.render(<App />);
62+
} else {
63+
root.render(
64+
<FluentProvider
65+
theme={
66+
searchParams.get('theme') === 'dark'
67+
? createDarkTheme(customBrandRamp)
68+
: createLightTheme(customBrandRamp)
69+
}
70+
>
71+
<FluentThemeProvider variant={searchParams.get('variant') || ''}>
72+
<App />
73+
</FluentThemeProvider>
74+
</FluentProvider>
75+
);
76+
}
7477

7578
await pageConditions.uiConnected();
7679

@@ -92,8 +95,7 @@
9295
type: 'https://schema.org/Message',
9396
citation: [
9497
{
95-
'@id':
96-
'https://bing.com/',
98+
'@id': 'https://bing.com/',
9799
'@type': 'Claim',
98100
claimInterpreter: {
99101
'@type': 'Project',

__tests__/html/copyButton.layout.js

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/** @jest-environment ./packages/test/harness/src/host/jest/WebDriverEnvironment.js */
2+
3+
describe.each([
4+
['light', 'white label'],
5+
['dark', 'fluent'],
6+
['dark', 'copilot'],
7+
['light', 'fluent'],
8+
['light', 'copilot']
9+
])('with %s theme and %s variant', (theme, variant) =>
10+
test('copy button should layout properly', () =>
11+
runHTML(`copyButton.layout?${new URLSearchParams({ theme, variant }).toString()}`))
12+
);

__tests__/html/fluentTheme/copyButton.js

-5
This file was deleted.

__tests__/html/fluentTheme/copyButton.layout.js

-13
This file was deleted.

docker-compose-wsl2.yml

-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ services:
4141
dockerfile: testharness2.dockerfile
4242
ports:
4343
- '5081:80'
44-
- '5443:443'
4544
stop_grace_period: 0s
4645
volumes:
4746
- ./__tests__/html/:/var/web/__tests__/html/

docs/HOOKS.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -1003,10 +1003,14 @@ This function is for rendering the avatar of an activity. The caller will need t
10031003
10041004
<!-- prettier-ignore-start -->
10051005
```js
1006-
useRenderMarkdownAsHTML(): (markdown: string): string
1006+
useRenderMarkdownAsHTML(
1007+
mode: 'accessible name' | 'adaptive cards' | 'citation modal' | 'clipboard' | 'message activity' = 'message activity'
1008+
): (markdown: string): string
10071009
```
10081010
<!-- prettier-ignore-end -->
10091011
1012+
> New in 4.17.0: A new `mode` option can be passed to indicate how the HTML output will be used.
1013+
10101014
This hook will return a function that, when called, will render Markdown into an HTML string. For example,
10111015
10121016
<!-- prettier-ignore-start -->

packages/bundle/src/index-minimal.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import ReactWebChat, {
1010
concatMiddleware,
1111
createStyleSet,
1212
hooks,
13+
testIds,
1314
withEmoji
1415
} from 'botframework-webchat-component';
1516

@@ -48,10 +49,10 @@ export const createDirectLineAppServiceExtension = options => {
4849
export default ReactWebChat;
4950

5051
export {
51-
Components,
52-
Constants,
5352
buildInfo,
53+
Components,
5454
concatMiddleware,
55+
Constants,
5556
createBrowserWebSpeechPonyfillFactory,
5657
createStore,
5758
createStoreWithDevTools,
@@ -80,6 +81,10 @@ window['WebChat'] = {
8081
hooks,
8182
ReactWebChat,
8283
renderWebChat,
84+
testIds: {
85+
...(window['WebChat']?.testIds || {}),
86+
...testIds
87+
},
8388
withEmoji
8489
};
8590

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import classNames from 'classnames';
2+
import React, { forwardRef, memo, useCallback, type ReactNode } from 'react';
3+
import { useRefFrom } from 'use-ref-from';
4+
import useStyleSet from '../../../hooks/useStyleSet';
5+
import MonochromeImageMasker from '../../../Utils/MonochromeImageMasker';
6+
7+
type Props = Readonly<{
8+
children?: ReactNode | undefined;
9+
className?: string | undefined;
10+
'data-testid'?: string | undefined;
11+
iconURL?: string | undefined;
12+
onClick?: (() => void) | undefined;
13+
text?: string | undefined;
14+
}>;
15+
16+
const ActivityButton = forwardRef<HTMLButtonElement, Props>(
17+
({ children, className, 'data-testid': dataTestId, iconURL, onClick, text }, ref) => {
18+
const [{ activityButton }] = useStyleSet();
19+
const onClickRef = useRefFrom(onClick);
20+
21+
const handleClick = useCallback(() => onClickRef.current?.(), [onClickRef]);
22+
23+
return (
24+
<button
25+
className={classNames(activityButton, 'webchat__activity-button', className)}
26+
data-testid={dataTestId}
27+
onClick={handleClick}
28+
ref={ref}
29+
type="button"
30+
>
31+
{iconURL && <MonochromeImageMasker className="webchat__activity-button__icon" src={iconURL} />}
32+
{text && <span className="webchat__activity-button__text">{text}</span>}
33+
{children}
34+
</button>
35+
);
36+
}
37+
);
38+
39+
export default memo(ActivityButton);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { hooks } from 'botframework-webchat-api';
2+
import classNames from 'classnames';
3+
import React, { memo, useCallback, useRef } from 'react';
4+
import { useRefFrom } from 'use-ref-from';
5+
import useStyleSet from '../../../hooks/useStyleSet';
6+
import ActivityButton from './ActivityButton';
7+
8+
const { useLocalizer } = hooks;
9+
10+
type Props = Readonly<{
11+
className?: string | undefined;
12+
htmlText?: string | undefined;
13+
plainText: string;
14+
}>;
15+
16+
const COPY_ICON_URL = `data:image/svg+xml;utf8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="21" height="20" viewBox="0 0 21 20" fill="none"><path d="M8.5 2C7.39543 2 6.5 2.89543 6.5 4V14C6.5 15.1046 7.39543 16 8.5 16H14.5C15.6046 16 16.5 15.1046 16.5 14V4C16.5 2.89543 15.6046 2 14.5 2H8.5ZM7.5 4C7.5 3.44772 7.94772 3 8.5 3H14.5C15.0523 3 15.5 3.44772 15.5 4V14C15.5 14.5523 15.0523 15 14.5 15H8.5C7.94772 15 7.5 14.5523 7.5 14V4ZM4.5 6.00001C4.5 5.25973 4.9022 4.61339 5.5 4.26758V14.5C5.5 15.8807 6.61929 17 8 17H14.2324C13.8866 17.5978 13.2403 18 12.5 18H8C6.067 18 4.5 16.433 4.5 14.5V6.00001Z" fill="#000000"/></svg>')}`;
17+
18+
const ActivityCopyButton = ({ className, htmlText, plainText }: Props) => {
19+
const [{ activityButton, activityCopyButton }] = useStyleSet();
20+
const buttonRef = useRef<HTMLButtonElement>(null);
21+
const localize = useLocalizer();
22+
const plainTextRef = useRefFrom(plainText);
23+
const htmlTextRef = useRefFrom(htmlText);
24+
25+
const copiedText = localize('COPY_BUTTON_COPIED_TEXT');
26+
const copyText = localize('COPY_BUTTON_TEXT');
27+
28+
const handleClick = useCallback(() => {
29+
const { current: htmlText } = htmlTextRef;
30+
31+
navigator.clipboard
32+
?.write([
33+
new ClipboardItem({
34+
...(htmlText ? { 'text/html': new Blob([htmlText], { type: 'text/html' }) } : {}),
35+
'text/plain': new Blob([plainTextRef.current], { type: 'text/plain' })
36+
})
37+
])
38+
.catch(error => console.error(`botframework-webchat-fluent-theme: Failed to copy to clipboard.`, error));
39+
40+
buttonRef.current?.classList.remove('webchat__activity-copy-button--copied');
41+
42+
// Reading `offsetWidth` will trigger a reflow and this is critical for resetting the animation.
43+
// https://css-tricks.com/restart-css-animation/#aa-update-another-javascript-method-to-restart-a-css-animation
44+
// eslint-disable-next-line no-unused-expressions
45+
buttonRef.current?.offsetWidth;
46+
47+
buttonRef.current?.classList.add('webchat__activity-copy-button--copied');
48+
}, [buttonRef, htmlTextRef, plainTextRef]);
49+
50+
return (
51+
<ActivityButton
52+
className={classNames(
53+
activityButton,
54+
activityCopyButton,
55+
'webchat__activity-button',
56+
'webchat__activity-copy-button',
57+
className
58+
)}
59+
data-testid="copy button"
60+
iconURL={COPY_ICON_URL}
61+
onClick={handleClick}
62+
ref={buttonRef}
63+
text={copyText}
64+
>
65+
<span className="webchat__activity-copy-button__copied-text">{copiedText}</span>
66+
</ActivityButton>
67+
);
68+
};
69+
70+
ActivityCopyButton.displayName = 'ActivityCopyButton';
71+
72+
export default memo(ActivityCopyButton);

packages/component/src/Attachment/Text/private/MarkdownTextContent.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import useRenderMarkdownAsHTML from '../../../hooks/useRenderMarkdownAsHTML';
1818
import useStyleSet from '../../../hooks/useStyleSet';
1919
import useShowModal from '../../../providers/ModalDialog/useShowModal';
2020
import { type PropsOf } from '../../../types/PropsOf';
21+
import ActivityCopyButton from './ActivityCopyButton';
2122
import CitationModalContext from './CitationModalContent';
2223
import MessageSensitivityLabel, { type MessageSensitivityLabelProps } from './MessageSensitivityLabel';
2324
import isHTMLButtonElement from './isHTMLButtonElement';
@@ -52,6 +53,7 @@ const MarkdownTextContent = memo(({ activity, markdown }: Props) => {
5253
const localize = useLocalizer();
5354
const graph = useMemo(() => dereferenceBlankNodes(activity.entities || []), [activity.entities]);
5455
const renderMarkdownAsHTML = useRenderMarkdownAsHTML('message activity');
56+
const renderMarkdownAsHTMLForClipboard = useRenderMarkdownAsHTML('clipboard');
5557
const showModal = useShowModal();
5658

5759
const messageThing = useMemo(() => getOrgSchemaMessage(graph), [graph]);
@@ -67,6 +69,11 @@ const MarkdownTextContent = memo(({ activity, markdown }: Props) => {
6769
[renderMarkdownAsHTML, markdown]
6870
);
6971

72+
const htmlTextForClipboard = useMemo(
73+
() => (markdown ? renderMarkdownAsHTMLForClipboard(markdown) : undefined),
74+
[markdown, renderMarkdownAsHTMLForClipboard]
75+
);
76+
7077
const markdownDefinitions = useMemo(
7178
() => fromMarkdown(markdown).children.filter((node): node is Definition => node.type === 'definition'),
7279
[markdown]
@@ -223,6 +230,13 @@ const MarkdownTextContent = memo(({ activity, markdown }: Props) => {
223230
))}
224231
</LinkDefinitions>
225232
)}
233+
{activity.type === 'message' && activity.text && messageThing?.keywords?.includes('AllowCopy') ? (
234+
<ActivityCopyButton
235+
className="webchat__text-content__activity-copy-button"
236+
htmlText={htmlTextForClipboard}
237+
plainText={activity.text}
238+
/>
239+
) : null}
226240
</div>
227241
);
228242
});

0 commit comments

Comments
 (0)