Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ Breaking changes in this release:
- Added core mute/unmute functionality for speech-to-speech via `useRecorder` hook (silent chunks keep server connection alive), in PR [#5688](https://github.com/microsoft/BotFramework-WebChat/pull/5688), by [@pranavjoshi](https://github.com/pranavjoshi001)
- 🧪 Added incremental streaming Markdown renderer for livestreaming, in PR [#5799](https://github.com/microsoft/BotFramework-WebChat/pull/5799), by [@OEvgeny](https://github.com/OEvgeny)
- Fixed streaming Markdown renderer to preserve link reference definitions during incremental rendering and recover on error, in PR [#5808](https://github.com/microsoft/BotFramework-WebChat/pull/5808), by [@OEvgeny](https://github.com/OEvgeny)
- Added clipboard paste and drag-and-drop file support to both basic and fluent themes, in PR [#5829](https://github.com/microsoft/BotFramework-WebChat/pull/5829), by [@OEvgeny](https://github.com/OEvgeny)

### Changed

Expand Down
130 changes: 130 additions & 0 deletions __tests__/html2/basic/dragAndDrop.upload.html
Comment thread
OEvgeny marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Drag and drop file upload (basic theme)</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone@7.8.7/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.6/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
<script crossorigin="anonymous" src="/__dist__/botframework-webchat-fluent-theme.production.min.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

renderWebChat(
{
directLine,
store
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

const dataTransfer = new DataTransfer();

dataTransfer.items.add(new File([new ArrayBuffer(100)], 'simple.txt'));

// WHEN: Dragging a file into document.
const dragEnterDocumentEvent = new DragEvent('dragenter', {
bubbles: true,
cancelable: true,
dataTransfer
});

document.dispatchEvent(dragEnterDocumentEvent);

// THEN: Should render the drop zone.
await pageConditions.became(
'Drop zone should appear',
() => !!document.querySelector(`[data-testid="${WebChat.testIds['sendBoxDropZone']}"]`),
1000
);

await host.snapshot('local');

// WHEN: Dragging into the drop zone.
const dragEnterDropZoneEvent = new DragEvent('dragenter', {
bubbles: true,
cancelable: true,
dataTransfer
});

document
.querySelector(`[data-testid="${WebChat.testIds['sendBoxDropZone']}"]`)
.dispatchEvent(dragEnterDropZoneEvent);

// THEN: Should render drop zone in droppable state.
await host.snapshot('local');

// WHEN: Dropping into the drop zone.
const dropEvent = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer
});

document.querySelector(`[data-testid="${WebChat.testIds['sendBoxDropZone']}"]`).dispatchEvent(dropEvent);

// THEN: An attachment should appear in the attachment bar.
await pageConditions.became(
'Attachment bar item should appear',
() => !!pageElements.byTestId(WebChat.testIds.sendBoxAttachmentBarItem),
1000
);

await host.snapshot('local');

// WHEN: Dragging a file into document again.
const dragEnterDocumentEvent1 = new DragEvent('dragenter', {
bubbles: true,
cancelable: true,
dataTransfer
});

document.dispatchEvent(dragEnterDocumentEvent1);

// THEN: Should render the drop zone again.
await pageConditions.became(
'Drop zone should appear again',
() => !!document.querySelector(`[data-testid="${WebChat.testIds['sendBoxDropZone']}"]`),
1000
);

// WHEN: Dragging a file over the document.
const dragOverDocumentEvent = new DragEvent('dragover', {
bubbles: true,
cancelable: true,
dataTransfer
});

document.dispatchEvent(dragOverDocumentEvent);

// THEN: The default browser behavior should be prevented.
await pageConditions.became(
'DragOver event preventDefault is called',
() => dragOverDocumentEvent.defaultPrevented,
1000
);

// WHEN: Dropping out of the drop zone.
const dropEvent1 = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer
});

document.body.dispatchEvent(dropEvent1);

// THEN: Should render single attachment (no duplicate from out-of-zone drop).
await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
80 changes: 80 additions & 0 deletions __tests__/html2/basic/pasteFile.html
Comment thread
OEvgeny marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Paste file into send box</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script crossorigin="anonymous" src="https://unpkg.com/@babel/standalone@7.8.7/babel.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react@16.8.6/umd/react.production.min.js"></script>
<script crossorigin="anonymous" src="https://unpkg.com/react-dom@16.8.6/umd/react-dom.production.min.js"></script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script crossorigin="anonymous" src="/__dist__/webchat-es5.js"></script>
<script crossorigin="anonymous" src="/__dist__/botframework-webchat-fluent-theme.production.min.js"></script>
</head>
<body>
<main id="webchat"></main>
<script>
run(async function () {
const { directLine, store } = testHelpers.createDirectLineEmulator();

renderWebChat(
{
directLine,
store
},
document.getElementById('webchat')
);

await pageConditions.uiConnected();

// WHEN: Pasting a file into the send box text area.
const textBox = document.querySelector(`[data-testid="${WebChat.testIds['sendBoxTextBox']}"]`);
const dataTransfer = new DataTransfer();

dataTransfer.items.add(new File([new ArrayBuffer(100)], 'test-document.txt', { type: 'text/plain' }));

const pasteEvent = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer
});

textBox.dispatchEvent(pasteEvent);

// THEN: The paste event should be prevented (file handled by webchat).
expect(pasteEvent.defaultPrevented).toBeTruthy();

// THEN: An attachment should appear in the attachment bar.
await pageConditions.became(
'Attachment bar item should appear',
() => !!pageElements.byTestId(WebChat.testIds.sendBoxAttachmentBarItem),
1000
);

await host.snapshot('local');

// WHEN: Pasting a second file.
const dataTransfer2 = new DataTransfer();

dataTransfer2.items.add(new File([new ArrayBuffer(200)], 'test-spreadsheet.csv', { type: 'text/csv' }));

const pasteEvent2 = new ClipboardEvent('paste', {
bubbles: true,
cancelable: true,
clipboardData: dataTransfer2
});

textBox.dispatchEvent(pasteEvent2);

// THEN: Both attachments should appear (accumulated, not replaced).
await pageConditions.became(
'Two attachment bar items should appear',
() => document.querySelectorAll(`[data-testid="${WebChat.testIds.sendBoxAttachmentBarItem}"]`).length === 2,
1000
);

await host.snapshot('local');
});
</script>
</body>
</html>
Binary file added __tests__/html2/basic/pasteFile.html.snap-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added __tests__/html2/basic/pasteFile.html.snap-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/bundle/src/boot/actual/hook/minimal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export {
useDisabled,
useDismissNotification,
useEmitTypingIndicator,
useFileDropZone,
useFocus,
useGetActivitiesByKey,
useGetActivityByKey,
Expand Down Expand Up @@ -100,5 +101,6 @@ export {
useVoiceSelector,
useVoiceState,
useWebSpeechPonyfill,
type DropZoneState,
type SendBoxFocusOptions
} from 'botframework-webchat-component/hook.js';
2 changes: 1 addition & 1 deletion packages/component/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
"scripts": {
"build": "npm run --if-present build:pre && npm run build:run && npm run --if-present build:post",
"build:post": "npm run build:post:dtsroll && npm run build:post:validate",
"build:post:dtsroll": "dtsroll ./dist/*.d.* && sed -E -i 's/^([[:space:]]*export[[:space:]]+)type[[:space:]]+(\\{[[:space:]]+type[[:space:]]+)/\\1\\2/' ./dist/*.d.ts",
"build:post:dtsroll": "dtsroll ./dist/*.d.* && sed -E -i 's/^([[:space:]]*export[[:space:]]+)type[[:space:]]+(\\{[[:space:]]+type[[:space:]]+)/\\1\\2/' ./dist/*.d.* && sed -i -E -e 's/\\{[[:space:]]*DropZoneState\\b/{ type DropZoneState/g' -e 's/\\btypeof[[:space:]]+DropZoneState\\b/DropZoneState/g' ./dist/*.d.*",
"build:post:validate": "npm run build:post:validate:css && npm run build:post:validate:inject-css && npm run build:post:validate:dts",
"build:post:validate:css": "vg ast-check lightning-css ./dist/*.css",
"build:post:validate:dts": "vg ast-check dist-types ./dist/*.d.*",
Expand Down
74 changes: 71 additions & 3 deletions packages/component/src/SendBox/BasicSendBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@ import { SendBoxToolbarMiddlewareProxy, hooks } from 'botframework-webchat-api';
import { validateProps } from '@msinternal/botframework-webchat-react-valibot';
import { Constants } from 'botframework-webchat-core';
import classNames from 'classnames';
import React, { memo } from 'react';
import React, { memo, useCallback, type ClipboardEventHandler } from 'react';
import { useRefFrom } from 'use-ref-from';
import { object, optional, pipe, readonly, string, type InferInput } from 'valibot';

import { useStyleToEmotionObject } from '../hooks/internal/styleToEmotionObject';
import useMakeThumbnail from '../hooks/useMakeThumbnail';
import useStyleSet from '../hooks/useStyleSet';
import useWebSpeechPonyfill from '../hooks/useWebSpeechPonyfill';
import useErrorMessageId from '../providers/internal/SendBox/useErrorMessageId';
import useSubmit from '../providers/internal/SendBox/useSubmit';
import { AttachmentBar } from './AttachmentBar/index';
import DictationInterims from './DictationInterims';
import DropZone from './DropZone';
import MicrophoneButton from './MicrophoneButton';
import SendButton from './SendButton';
import SuggestedActions from './SuggestedActions';
Expand All @@ -20,10 +24,29 @@ const {
DictateState: { DICTATING, STARTING }
} = Constants;

const { useDirection, useDictateState, useStyleOptions } = hooks;
const { useDirection, useDictateState, useSendBoxAttachments, useStyleOptions, useUIState } = hooks;

const ROOT_STYLE = {
'&.webchat__send-box': {
display: 'grid',
gridTemplateAreas: '"suggested-actions" "content"',
gridTemplateRows: 'auto 1fr',

'& .webchat__suggested-actions': {
gridArea: 'suggested-actions',
minWidth: '0'
},

'& .webchat__send-box__main': {
gridArea: 'content',
minWidth: '0'
},

'& .webchat__drop-zone': {
gridArea: 'content',
minWidth: '0'
},

'& .webchat__send-box__button': { flexShrink: 0 },
'& .webchat__send-box__dictation-interims': { flex: 10000 },
'& .webchat__send-box__microphone-button': { flex: 1 },
Expand All @@ -49,18 +72,61 @@ type BasicSendBoxProps = InferInput<typeof basicSendBoxPropsSchema>;
function BasicSendBox(props: BasicSendBoxProps) {
const { className } = validateProps(basicSendBoxPropsSchema, props);

const [{ sendBoxButtonAlignment }] = useStyleOptions();
const [{ disableFileUpload, sendAttachmentOn, sendBoxButtonAlignment }] = useStyleOptions();
const [{ sendBox: sendBoxStyleSet }] = useStyleSet();
const [{ SpeechRecognition = undefined } = {}] = useWebSpeechPonyfill();
const [direction] = useDirection();
const [errorMessageId] = useErrorMessageId();
const [sendBoxAttachments, setSendBoxAttachments] = useSendBoxAttachments();
const [speechInterimsVisible] = useSendBoxSpeechInterimsVisible();
const [uiState] = useUIState();
const makeThumbnail = useMakeThumbnail();
const styleToEmotionObject = useStyleToEmotionObject();
const submit = useSubmit();

const rootClassName = styleToEmotionObject(ROOT_STYLE) + '';

const disabled = uiState === 'disabled';
const sendAttachmentOnRef = useRefFrom(sendAttachmentOn);
const sendBoxAttachmentsRef = useRefFrom(sendBoxAttachments);

const supportSpeechRecognition = !!SpeechRecognition;

const handleAddFiles = useCallback(
async (inputFiles: readonly File[]) => {
const newAttachments = Object.freeze(
await Promise.all(
inputFiles.map(file =>
makeThumbnail(file).then(thumbnailURL =>
Comment thread
OEvgeny marked this conversation as resolved.
Object.freeze({ blob: file, ...(thumbnailURL && { thumbnailURL }) })
)
)
)
);

setSendBoxAttachments(Object.freeze([].concat(sendBoxAttachmentsRef.current, newAttachments)));

sendAttachmentOnRef.current === 'attach' && submit();
},
[makeThumbnail, sendAttachmentOnRef, sendBoxAttachmentsRef, setSendBoxAttachments, submit]
);

const handlePaste = useCallback<ClipboardEventHandler>(
Comment thread
OEvgeny marked this conversation as resolved.
event => {
if (disableFileUpload || disabled) {
return;
}

const { files } = event.clipboardData;

if (files.length) {
event.preventDefault();
handleAddFiles(Object.freeze(Array.from(files)));
}
},
[disabled, disableFileUpload, handleAddFiles]
);

const buttonClassName = classNames('webchat__send-box__button', {
'webchat__send-box__button--align-bottom': sendBoxButtonAlignment === 'bottom',
'webchat__send-box__button--align-stretch': sendBoxButtonAlignment !== 'bottom' && sendBoxButtonAlignment !== 'top',
Expand All @@ -74,6 +140,7 @@ function BasicSendBox(props: BasicSendBoxProps) {
aria-invalid={!!errorMessageId}
className={classNames('webchat__send-box', sendBoxStyleSet + '', rootClassName + '', (className || '') + '')}
dir={direction}
onPaste={handlePaste}
role="form"
>
<SuggestedActions />
Expand All @@ -93,6 +160,7 @@ function BasicSendBox(props: BasicSendBoxProps) {
<SendButton className={buttonClassName} />
)}
</div>
{!disableFileUpload && !disabled && <DropZone onFilesAdded={handleAddFiles} />}
</div>
);
}
Expand Down
Loading
Loading