diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3645d6199..00eb69d087 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,6 +49,7 @@ Legends:
- Fixed the keyboard focus indicator around the transcript ignoring the `transcriptVisualKeyboardIndicatorColor` style option and using the text color instead; in the Fluent theme, it now uses the Fluent focus stroke color, in PR [#5888](https://github.com/microsoft/BotFramework-WebChat/pull/5888), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed undefined or misnamed CSS variables: `CopilotMessageHeader` is limited to the maximum message bubble width again instead of spanning the full transcript width in the Fluent variant, citation links in the Fluent theme use the brand stroke color for their hover border instead of the text color, and `--webchat-borderFlair-color3Fade` and `--webchat-borderLoader-color3Fade` now set the third fade color of the border flair and loader, in PR [#5890](https://github.com/microsoft/BotFramework-WebChat/pull/5890), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed the keyboard focus indicator of audio and video attachments, and of videos in animation cards: the bubble clipped the browser focus ring, and the rest was covered by the audio player or not visible over a dark video; it is now shown on all four sides of the player, fixes [#5740](https://github.com/microsoft/BotFramework-WebChat/issues/5740), in PR [#5887](https://github.com/microsoft/BotFramework-WebChat/pull/5887), by [@OEvgeny](https://github.com/OEvgeny)
+- Fixed the "Copied" confirmation of the copy button of code blocks disappearing too quickly: it is now shown for 5 seconds in all themes, like the Copy button of messages; the button is no longer an assertive live region, which announced "Copied" on top of its new name and "Copy" again when the confirmation ended, related to [#5857](https://github.com/microsoft/BotFramework-WebChat/issues/5857), in PR [#5892](https://github.com/microsoft/BotFramework-WebChat/pull/5892), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed syntax-highlighted code having less than 4.5:1 color contrast: comments in the light code block theme (`github-light-default`) were 4.36:1 in the Fluent theme and now use the muted text color of the theme, `#656d76` (5.03:1), in PR [#5893](https://github.com/microsoft/BotFramework-WebChat/pull/5893), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed code blocks becoming empty when Web Chat is rendered with messages already in the store, such as again with the same store or from a saved state: code is shown without highlighting until the syntax highlighter is loaded, in PR [#5893](https://github.com/microsoft/BotFramework-WebChat/pull/5893), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed the native controls of audio and video players, such as volume and "more options", not being reachable with TAB and SHIFT + TAB inside an activity: the focus now moves through every control of the player and wraps within the activity; the players of audio, video, and animation cards are now named after the card title, fixes [#5738](https://github.com/microsoft/BotFramework-WebChat/issues/5738) and [#5709](https://github.com/microsoft/BotFramework-WebChat/issues/5709), in PR [#5891](https://github.com/microsoft/BotFramework-WebChat/pull/5891), by [@OEvgeny](https://github.com/OEvgeny)
diff --git a/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html b/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html
index 4e1e722f57..539edd03fa 100644
--- a/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html
+++ b/__tests__/html2/markdown/codeBlockCopyButton/adaptiveCards/behavior.html
@@ -2,7 +2,7 @@
-
+
@@ -11,7 +11,16 @@
-
+
+
@@ -10,8 +10,17 @@
-
-
+
+
@@ -10,8 +10,17 @@
-
-
+
diff --git a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx
index 4a3c11bd4c..4da7f2873d 100644
--- a/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx
+++ b/packages/component/src/Attachment/Text/private/ActivityCopyButton.tsx
@@ -2,9 +2,10 @@ import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
import { useStyles } from '@msinternal/botframework-webchat-styles/react';
import { hooks } from 'botframework-webchat-api';
import cx from 'classnames';
-import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
+import React, { memo, useCallback, useEffect, useState } from 'react';
import { instance, nullable, object, optional, pipe, readonly, string, type InferInput } from 'valibot';
+import useAutoResetState from '../../../hooks/internal/useAutoResetState';
import { useQueueStaticElement } from '../../../providers/LiveRegionTwin';
import refObject from '../../../types/internal/refObject';
import ActivityButton from './ActivityButton';
@@ -28,12 +29,11 @@ type ActivityCopyButtonProps = InferInput;
function ActivityCopyButton(props: ActivityCopyButtonProps) {
const { className, targetRef } = validateProps(activityCopyButtonPropsSchema, props);
- const [{ clearTimeout, setTimeout }] = usePonyfill();
- const [copied, setCopied] = useState(false);
const [permissionGranted, setPermissionGranted] = useState(false);
+ const [ponyfill] = usePonyfill();
+ const [copied, setCopied] = useAutoResetState(false, COPIED_DURATION, ponyfill);
const [uiState] = useUIState();
const classNames = useStyles(styles);
- const copiedTimeoutRef = useRef | undefined>();
const localize = useLocalizer();
const queueStaticElement = useQueueStaticElement();
@@ -55,17 +55,10 @@ function ActivityCopyButton(props: ActivityCopyButtonProps) {
.catch(error => console.error(`botframework-webchat-fluent-theme: Failed to copy to clipboard.`, error));
// Activating again restarts the confirmation.
- copiedTimeoutRef.current && clearTimeout(copiedTimeoutRef.current);
- copiedTimeoutRef.current = setTimeout(() => setCopied(false), COPIED_DURATION);
setCopied(true);
queueStaticElement({copiedText}
);
- }, [classNames, clearTimeout, copiedText, copiedTimeoutRef, queueStaticElement, setCopied, setTimeout, targetRef]);
-
- useEffect(
- () => () => copiedTimeoutRef.current && clearTimeout(copiedTimeoutRef.current),
- [clearTimeout, copiedTimeoutRef]
- );
+ }, [classNames, copiedText, queueStaticElement, setCopied, targetRef]);
useEffect(() => {
let unmounted = false;
diff --git a/packages/component/src/hooks/internal/useAutoResetState.ts b/packages/component/src/hooks/internal/useAutoResetState.ts
new file mode 100644
index 0000000000..6bce578676
--- /dev/null
+++ b/packages/component/src/hooks/internal/useAutoResetState.ts
@@ -0,0 +1,39 @@
+import { type GlobalScopePonyfill } from 'botframework-webchat-core';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useRefFrom } from 'use-ref-from';
+
+/**
+ * Like `useState()`, but the value is reset to `initialValue` after `resetIntervalInMS` milliseconds.
+ *
+ * Setting the value again restarts the interval.
+ *
+ * The ponyfill is passed in, so components rendered outside of the React tree of Web Chat (custom elements) can use it.
+ *
+ * @returns A tuple of the current value, and the function to set it.
+ */
+export default function useAutoResetState(
+ initialValue: T,
+ resetIntervalInMS: number,
+ { clearTimeout, setTimeout }: Pick
+): readonly [T, (value: T) => void] {
+ const [value, setValue] = useState(initialValue);
+ const initialValueRef = useRefFrom(initialValue);
+ const resetTimeoutRef = useRef | undefined>();
+
+ // Values are wrapped in functions, so a function value is not called as a state updater.
+ const setValueWithAutoReset = useCallback(
+ (nextValue: T) => {
+ resetTimeoutRef.current && clearTimeout(resetTimeoutRef.current);
+ resetTimeoutRef.current = setTimeout(() => setValue(() => initialValueRef.current), resetIntervalInMS);
+ setValue(() => nextValue);
+ },
+ [clearTimeout, initialValueRef, resetIntervalInMS, resetTimeoutRef, setTimeout, setValue]
+ );
+
+ useEffect(
+ () => () => resetTimeoutRef.current && clearTimeout(resetTimeoutRef.current),
+ [clearTimeout, resetTimeoutRef]
+ );
+
+ return useMemo(() => Object.freeze([value, setValueWithAutoReset] as const), [setValueWithAutoReset, value]);
+}
diff --git a/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx b/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx
index 1c53e71821..ba204f8f45 100644
--- a/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx
+++ b/packages/component/src/providers/CustomElements/CustomElementsComposer.tsx
@@ -2,7 +2,7 @@ import mathRandom from 'math-random';
import React, { memo, useCallback, useMemo, type ReactNode } from 'react';
import useReactCodeBlockClass from './customElements/CodeBlock';
-import { CodeBlockCopyButtonElement } from './customElements/CodeBlockCopyButton';
+import useReactCodeBlockCopyButtonClass from './customElements/CodeBlockCopyButton';
import CustomElementsContext from './private/CustomElementsContext';
type CustomElementsComposerProps = Readonly<{
@@ -35,9 +35,11 @@ const CustomElementsComposer = ({ children }: CustomElementsComposerProps) => {
[hash]
);
+ const [CodeBlockCopyButtonClass] = useReactCodeBlockCopyButtonClass();
+
const codeBlockCopyButtonTagName = useMemo(
- () => registerCustomElement('code-block-copy-button', CodeBlockCopyButtonElement),
- [registerCustomElement]
+ () => registerCustomElement('code-block-copy-button', CodeBlockCopyButtonClass),
+ [CodeBlockCopyButtonClass, registerCustomElement]
);
const CodeBlockClass = useReactCodeBlockClass(codeBlockCopyButtonTagName);
diff --git a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.module.css b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.module.css
index 13efbd6b7a..7830569f2c 100644
--- a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.module.css
+++ b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.module.css
@@ -44,20 +44,3 @@
--webchat__component-icon--color: #707070;
--webchat__component-icon--size: 16px;
}
-
-:global(.webchat) .code-block-copy-button__icon--copied {
- opacity: 0;
-}
-
-:global(.webchat) .code-block-copy-button--copied .code-block-copy-button__icon {
- animation: code-block-copy-button__copied-animation 0.5s linear;
-}
-
-@keyframes code-block-copy-button__copied-animation {
- 0% {
- opacity: 100%;
- }
- 100% {
- opacity: unset;
- }
-}
diff --git a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx
index ae69548471..97629f5e14 100644
--- a/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx
+++ b/packages/component/src/providers/CustomElements/customElements/CodeBlockCopyButton.tsx
@@ -1,108 +1,143 @@
-import cx from 'classnames';
+import { validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot';
import { useStyles } from '@msinternal/botframework-webchat-styles/react';
-import React, { memo, useCallback, useState } from 'react';
+import { hooks } from 'botframework-webchat-api';
+import { type GlobalScopePonyfill } from 'botframework-webchat-core';
+import cx from 'classnames';
+import React, { memo, useCallback, useMemo } from 'react';
import { useRefFrom } from 'use-ref-from';
import { useStateWithRef } from 'use-state-with-ref';
+import { custom, function_, is, object, optional, pipe, readonly, string } from 'valibot';
+import useAutoResetState from '../../../hooks/internal/useAutoResetState';
import { ComponentIcon } from '../../../Icon';
import testIds from '../../../testIds';
import wrapAsCustomElement from './wrapAsCustomElement';
import styles from './CodeBlockCopyButton.module.css';
-export type CodeBlockCopyButtonProps = Readonly<{
- className: string;
- 'data-alt-copied': string;
- 'data-alt-copy': string;
- 'data-value': string;
-}>;
+const { usePonyfill } = hooks;
+
+// Same as the Copy button of activities.
+const COPIED_DURATION = 5_000;
+
+const codeBlockCopyButtonPropsSchema = pipe(
+ object({
+ className: optional(string()),
+ 'data-alt-copied': optional(string()),
+ 'data-alt-copy': optional(string()),
+ 'data-value': optional(string()),
+ ponyfill: custom>(value =>
+ is(object({ clearTimeout: function_(), setTimeout: function_() }), value)
+ )
+ }),
+ readonly()
+);
+
+type CodeBlockCopyButtonProps = InferReactProps;
-const CodeBlockCopyButton = memo(
- ({
+function CodeBlockCopyButton(props: CodeBlockCopyButtonProps) {
+ const {
className,
'data-alt-copied': copiedAlt,
'data-alt-copy': copyAlt,
- 'data-value': value
- }: CodeBlockCopyButtonProps) => {
- const classNames = useStyles(styles);
- const [disabled, setDisabled, disabledRef] = useStateWithRef(false);
- const [pressed, setPressed] = useState(false);
- const valueRef = useRefFrom(value);
-
- const handleClick = useCallback(() => {
- if (disabledRef.current) {
- return;
+ 'data-value': value,
+ ponyfill
+ } = validateProps(codeBlockCopyButtonPropsSchema, props);
+
+ const [copied, setCopied] = useAutoResetState(false, COPIED_DURATION, ponyfill);
+ const [disabled, setDisabled, disabledRef] = useStateWithRef(false);
+ const classNames = useStyles(styles);
+ const valueRef = useRefFrom(value);
+
+ const handleClick = useCallback(() => {
+ if (disabledRef.current) {
+ return;
+ }
+
+ let obsoleted = false;
+
+ (async () => {
+ try {
+ const { state } = await navigator.permissions.query({ name: 'clipboard-write' as any });
+
+ if (!obsoleted) {
+ if (state === 'granted') {
+ await navigator.clipboard?.write([
+ new ClipboardItem({ 'text/plain': new Blob([valueRef.current], { type: 'text/plain' }) })
+ ]);
+
+ // Copying again restarts the confirmation.
+ obsoleted || setCopied(true);
+ } else if (state === 'denied') {
+ setDisabled(true);
+ }
+ }
+ } catch (error) {
+ console.warn('botframework-webchat: Failed to copy code block to clipboard.', error);
}
+ })();
+
+ return () => {
+ obsoleted = true;
+ };
+ }, [disabledRef, setCopied, setDisabled, valueRef]);
+
+ // A plain button, like the copy button of activities: its name is "Copied" while the confirmation is shown.
+ // A live region on the button would raise another event for "Copied", then an assertive "Copy" when the confirmation ends.
+ // The "--copied" classes have no style of their own, they are kept for hosts to style.
+ return (
+
+
+
+
+ );
+}
- let obsoleted = false;
-
- (async () => {
- try {
- const { state } = await navigator.permissions.query({ name: 'clipboard-write' as any });
+CodeBlockCopyButton.displayName = 'CodeBlockCopyButton';
- if (!obsoleted) {
- if (state === 'granted') {
- await navigator.clipboard?.write([
- new ClipboardItem({ 'text/plain': new Blob([valueRef.current], { type: 'text/plain' }) })
- ]);
+const MemoizedCodeBlockCopyButton = memo(CodeBlockCopyButton);
- obsoleted || setPressed(true);
- } else if (state === 'denied') {
- setDisabled(true);
- }
- }
- } catch (error) {
- console.warn('botframework-webchat: Failed to copy code block to clipboard.', error);
- }
- })();
-
- return () => {
- obsoleted = true;
- };
- }, [disabledRef, setDisabled, setPressed, valueRef]);
-
- const handleAnimationEnd = useCallback(() => setPressed(false), [setPressed]);
-
- return (
-
-
-
-
- );
+function createCodeBlockCopyButtonClass(ponyfill: CodeBlockCopyButtonProps['ponyfill']): typeof HTMLElement {
+ function CodeBlockCopyButtonWithPonyfill(props: Omit) {
+ return ;
}
-);
-CodeBlockCopyButton.displayName = 'CodeBlockCopyButton';
+ CodeBlockCopyButtonWithPonyfill.displayName = 'CodeBlockCopyButtonWithPonyfill';
+
+ return wrapAsCustomElement(CodeBlockCopyButtonWithPonyfill, [
+ 'className',
+ 'data-alt-copied',
+ 'data-alt-copy',
+ 'data-value'
+ ]);
+}
-const CodeBlockCopyButtonElement = wrapAsCustomElement(CodeBlockCopyButton, [
- 'className',
- 'data-alt-copied',
- 'data-alt-copy',
- 'data-value'
-]);
+/**
+ * Creates the custom element class of the copy button of code blocks.
+ *
+ * The custom element renders in its own React root, outside of the React tree of Web Chat, so the ponyfill is passed down.
+ */
+export default function useReactCodeBlockCopyButtonClass(): readonly [typeof HTMLElement] {
+ const [ponyfill] = usePonyfill();
-export default CodeBlockCopyButton;
+ return useMemo(() => Object.freeze([createCodeBlockCopyButtonClass(ponyfill)] as const), [ponyfill]);
+}
-export { CodeBlockCopyButtonElement };
+export { codeBlockCopyButtonPropsSchema, type CodeBlockCopyButtonProps };