Repository: Automattic/wp-calypso
Pull request #100404: Help Center: attachment drop and paste
Author: login: heavyweight
Created: 2025-02-25T16:53:17Z | Merged: 2025-03-10T12:55:44Z

<!--
Link a related issue to this PR. If the PR does not immediately resolve the issue,
for example, it requires a separate deployment to production, avoid
using the "fixes" keyword and instead attach the [Status] Fix Inbound label to
the linked issue.
-->

Fixes https://github.com/Automattic/wp-calypso/issues/100231

## Proposed Changes
 
* Adds drop and paste for images

<img width="467" alt="Screenshot 2025-03-10 at 11 31 41" src="https://github.com/user-attachments/assets/4e53414c-74b5-4093-8088-ddef096130c6" />

## Testing Instructions

<!--
Add as many details as possible to help others reproduce the issue and test the fix.
"Before / After" screenshots can also be very helpful when the change is visual.
-->

* Open the Help Center.
* You shouldn't be able to drop an image in Odie.
* You should be able to do it on Zendesk.
* Only images are allowed.



Diff:
diff --git a/packages/odie-client/src/components/send-message-input/attachment-button.tsx b/packages/odie-client/src/components/send-message-input/attachment-button.tsx
index bb551e49b046..e445ae6bb9be 100644
--- a/packages/odie-client/src/components/send-message-input/attachment-button.tsx
+++ b/packages/odie-client/src/components/send-message-input/attachment-button.tsx
@@ -1,92 +1,23 @@
-import { HelpCenterSelect } from '@automattic/data-stores';
-import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores';
-import {
-	useAttachFileToConversation,
-	useAuthenticateZendeskMessaging,
-} from '@automattic/zendesk-client';
 import { FormFileUpload, Spinner } from '@wordpress/components';
-import { useSelect } from '@wordpress/data';
-import { useCallback } from '@wordpress/element';
 import { Icon, image } from '@wordpress/icons';
 import React from 'react';
-import { useOdieAssistantContext } from '../../context';
-import { zendeskMessageConverter } from '../../utils';
-
-const getFileType = ( file: File ) => {
-	if ( file.type.includes( 'image' ) ) {
-		return 'image-placeholder';
-	}
-
-	return 'text';
-};
-
-const getPlaceholderAttachmentMessage = ( file: File ) => {
-	return zendeskMessageConverter( {
-		role: 'user',
-		type: getFileType( file ),
-		displayName: '',
-		text: '',
-		id: String( new Date().getTime() ),
-		received: new Date().getTime(),
-		source: { type: 'web', id: '', integrationId: '' },
-		mediaUrl: URL.createObjectURL( file ),
-	} );
-};
 
 export const AttachmentButton: React.FC< {
 	attachmentButtonRef?: React.RefObject< HTMLElement >;
-} > = ( { attachmentButtonRef } ) => {
-	const { chat, addMessage, trackEvent, isUserEligibleForPaidSupport } = useOdieAssistantContext();
-	const { data: authData } = useAuthenticateZendeskMessaging(
-		isUserEligibleForPaidSupport,
-		'messenger'
-	);
-	const { isPending: isAttachingFile, mutateAsync: attachFileToConversation } =
-		useAttachFileToConversation();
-	const { zendeskClientId } = useSelect( ( select ) => {
-		const helpCenterSelect: HelpCenterSelect = select( HELP_CENTER_STORE );
-		return {
-			zendeskClientId: helpCenterSelect.getZendeskClientId(),
-		};
-	}, [] );
-
-	const inferredClientId = chat.clientId ? chat.clientId : zendeskClientId;
-	const onFileUpload = useCallback(
-		async ( event: React.ChangeEvent< HTMLInputElement > ) => {
-			if (
-				authData &&
-				chat.conversationId &&
-				inferredClientId &&
-				event.currentTarget.files?.length
-			) {
-				const file = event.currentTarget.files[ 0 ];
-				attachFileToConversation( {
-					authData,
-					file,
-					conversationId: chat.conversationId,
-					clientId: inferredClientId,
-				} ).then( () => {
-					addMessage( getPlaceholderAttachmentMessage( file ) );
-					trackEvent( 'send_message_attachment', { type: file.type } );
-				} );
-			}
-		},
-		[
-			authData,
-			chat.conversationId,
-			inferredClientId,
-			attachFileToConversation,
-			addMessage,
-			trackEvent,
-		]
-	);
-
-	if ( ! chat.conversationId || ! inferredClientId ) {
-		return null;
-	}
-
+	onFileUpload: ( file: File ) => void;
+	isAttachingFile: boolean;
+} > = ( { attachmentButtonRef, onFileUpload, isAttachingFile } ) => {
 	return (
-		<FormFileUpload accept="image/*" onChange={ onFileUpload } disabled={ isAttachingFile }>
+		<FormFileUpload
+			accept="image/*"
+			onChange={ ( event ) => {
+				const file = event?.currentTarget?.files?.[ 0 ];
+				if ( file ) {
+					onFileUpload( file );
+				}
+			} }
+			disabled={ isAttachingFile }
+		>
 			{ isAttachingFile && <Spinner style={ { margin: 0 } } /> }
 			{ ! isAttachingFile && <Icon ref={ attachmentButtonRef } icon={ image } /> }
 		</FormFileUpload>
diff --git a/packages/odie-client/src/components/send-message-input/index.tsx b/packages/odie-client/src/components/send-message-input/index.tsx
index 63ce0b267e7d..e30ddea8e5f4 100644
--- a/packages/odie-client/src/components/send-message-input/index.tsx
+++ b/packages/odie-client/src/components/send-message-input/index.tsx
@@ -1,26 +1,115 @@
-import { Spinner } from '@wordpress/components';
+import { HelpCenterSelect } from '@automattic/data-stores';
+import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores';
+import {
+	useAttachFileToConversation,
+	useAuthenticateZendeskMessaging,
+} from '@automattic/zendesk-client';
+import { DropZone, Spinner } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
 import { useCallback, useRef, useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
 import clsx from 'clsx';
 import { SendMessageIcon } from '../../assets/send-message-icon';
+import { ODIE_WRONG_FILE_TYPE_MESSAGE } from '../../constants';
 import { useOdieAssistantContext } from '../../context';
 import { useSendChatMessage } from '../../hooks';
 import { Message } from '../../types';
+import { zendeskMessageConverter } from '../../utils';
 import { AttachmentButton } from './attachment-button';
 import { ResizableTextarea } from './resizable-textarea';
 
 import './style.scss';
 
+const getFileType = ( file: File ) => {
+	if ( file.type.startsWith( 'image/' ) ) {
+		return 'image-placeholder';
+	}
+
+	return 'text';
+};
+
+const getPlaceholderAttachmentMessage = ( file: File ) => {
+	return zendeskMessageConverter( {
+		role: 'user',
+		type: getFileType( file ),
+		displayName: '',
+		text: '',
+		id: String( new Date().getTime() ),
+		received: new Date().getTime(),
+		source: { type: 'web', id: '', integrationId: '' },
+		mediaUrl: URL.createObjectURL( file ),
+	} );
+};
+
 export const OdieSendMessageButton = () => {
 	const divContainerRef = useRef< HTMLDivElement >( null );
 	const inputRef = useRef< HTMLTextAreaElement >( null );
 	const attachmentButtonRef = useRef< HTMLElement >( null );
-	const { trackEvent, chat } = useOdieAssistantContext();
+	const { trackEvent, chat, addMessage, isUserEligibleForPaidSupport } = useOdieAssistantContext();
 	const sendMessage = useSendChatMessage();
 	const isChatBusy = chat.status === 'loading' || chat.status === 'sending';
 	const [ isMessageSizeValid, setIsMessageSizeValid ] = useState( true );
 	const [ submitDisabled, setSubmitDisabled ] = useState( true );
 
+	const { data: authData } = useAuthenticateZendeskMessaging(
+		isUserEligibleForPaidSupport,
+		'messenger'
+	);
+	const { zendeskClientId } = useSelect( ( select ) => {
+		const helpCenterSelect: HelpCenterSelect = select( HELP_CENTER_STORE );
+		return {
+			zendeskClientId: helpCenterSelect.getZendeskClientId(),
+		};
+	}, [] );
+	const inferredClientId = chat.clientId ? chat.clientId : zendeskClientId;
+
+	const { isPending: isAttachingFile, mutateAsync: attachFileToConversation } =
+		useAttachFileToConversation();
+
+	const handleFileUpload = useCallback(
+		async ( file: File ) => {
+			if ( file.type.startsWith( 'image/' ) ) {
+				if ( authData && chat.conversationId && inferredClientId && file ) {
+					attachFileToConversation( {
+						authData,
+						file,
+						conversationId: chat.conversationId,
+						clientId: inferredClientId,
+					} ).then( () => {
+						addMessage( getPlaceholderAttachmentMessage( file ) );
+						trackEvent( 'send_message_attachment', { type: file.type } );
+					} );
+				}
+			} else {
+				addMessage( ODIE_WRONG_FILE_TYPE_MESSAGE );
+			}
+		},
+		[
+			authData,
+			chat.conversationId,
+			inferredClientId,
+			attachFileToConversation,
+			addMessage,
+			trackEvent,
+		]
+	);
+
+	const onFilesDrop = ( files: File[] ) => {
+		const file = files?.[ 0 ];
+		if ( file ) {
+			handleFileUpload( file );
+		}
+	};
+
+	const onPaste = ( event: React.ClipboardEvent ) => {
+		const items = event.clipboardData.items;
+		const file = items?.[ 0 ]?.getAsFile();
+		if ( file ) {
+			event.preventDefault();
+			handleFileUpload( file );
+		}
+	};
+
 	const onKeyUp = useCallback( () => {
 		// Only triggered when the message is empty
 		// used to remove validation message.
@@ -82,6 +171,8 @@ export const OdieSendMessageButton = () => {
 		'odie-send-message-inner-button__flag'
 	);
 
+	const showAttachmentButton = chat.conversationId && inferredClientId;
+
 	return (
 		<>
 			{ ! isMessageSizeValid && (
@@ -98,20 +189,33 @@ export const OdieSendMessageButton = () => {
 					className="odie-send-message-input-container"
 				>
 					<ResizableTextarea
-						shouldDisableInputField={ isChatBusy }
+						shouldDisableInputField={ isChatBusy || isAttachingFile }
 						sendMessageHandler={ sendMessageHandler }
 						className="odie-send-message-input"
 						inputRef={ inputRef }
 						setSubmitDisabled={ setSubmitDisabled }
 						keyUpHandle={ onKeyUp }
+						onPasteHandle={ onPaste }
 					/>
 					{ isChatBusy && <Spinner className="odie-send-message-input-spinner" /> }
-					<AttachmentButton attachmentButtonRef={ attachmentButtonRef } />
+					{ showAttachmentButton && (
+						<AttachmentButton
+							attachmentButtonRef={ attachmentButtonRef }
+							onFileUpload={ handleFileUpload }
+							isAttachingFile={ isAttachingFile }
+						/>
+					) }
 					<button type="submit" className={ buttonClasses } disabled={ submitDisabled }>
 						<SendMessageIcon />
 					</button>
 				</form>
 			</div>
+			{ showAttachmentButton && (
+				<DropZone
+					onFilesDrop={ onFilesDrop }
+					label={ __( 'Share this image with our Happiness Engineers', __i18n_text_domain__ ) }
+				/>
+			) }
 		</>
 	);
 };
diff --git a/packages/odie-client/src/components/send-message-input/resizable-textarea.tsx b/packages/odie-client/src/components/send-message-input/resizable-textarea.tsx
index b1c700bda995..ee3067f3feca 100644
--- a/packages/odie-client/src/components/send-message-input/resizable-textarea.tsx
+++ b/packages/odie-client/src/components/send-message-input/resizable-textarea.tsx
@@ -8,6 +8,7 @@ export const ResizableTextarea: React.FC< {
 	inputRef: React.RefObject< HTMLTextAreaElement >;
 	keyUpHandle: () => void;
 	sendMessageHandler: () => Promise< void >;
+	onPasteHandle: ( event: React.ClipboardEvent ) => void;
 	setSubmitDisabled: ( shouldBeDisabled: boolean ) => void;
 	shouldDisableInputField: boolean;
 } > = ( {
@@ -17,6 +18,7 @@ export const ResizableTextarea: React.FC< {
 	keyUpHandle,
 	setSubmitDisabled,
 	shouldDisableInputField = false,
+	onPasteHandle,
 } ) => {
 	const textAreaPlaceholder = shouldDisableInputField
 		? __( 'Just a moment…', __i18n_text_domain__ )
@@ -81,6 +83,7 @@ export const ResizableTextarea: React.FC< {
 			rows={ 1 }
 			className={ className }
 			onKeyUp={ onKeyUp }
+			onPaste={ onPasteHandle }
 			placeholder={ textAreaPlaceholder }
 			onKeyDown={ onKeyDown }
 			style={ { transition: 'none' } }
diff --git a/packages/odie-client/src/constants.ts b/packages/odie-client/src/constants.ts
index af3695eda399..a6dafee8917b 100644
--- a/packages/odie-client/src/constants.ts
+++ b/packages/odie-client/src/constants.ts
@@ -46,6 +46,22 @@ export const ODIE_TRANSFER_MESSAGE: Message[] = [
 	},
 ];
 
+export const ODIE_WRONG_FILE_TYPE_MESSAGE: Message = {
+	content: __(
+		'Sorry! The file you are trying to upload is not supported. Please upload a .jpg, .png, or .gif file.',
+		__i18n_text_domain__
+	),
+	role: 'bot',
+	type: 'message',
+	context: {
+		flags: {
+			hide_disclaimer_content: true,
+			show_contact_support_msg: false,
+		},
+		site_id: null,
+	},
+};
+
 export const ODIE_THUMBS_DOWN_RATING_VALUE = 0;
 export const ODIE_THUMBS_UP_RATING_VALUE = 1;
 export const ODIE_ALLOWED_BOTS = [ 'wpcom-support-chat', 'wpcom-plan-support' ];


Reviews:
user: login: alshakero
body: Left a few comments. I know I'm pushing for some refactors but this package is giving me nightmares. I'm trying to make it slightly better every time we touch it.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-06T12:44:16Z
user: login: escapemanuele
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T20:09:07Z
user: login: escapemanuele
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T20:09:39Z
user: login: alshakero
body: Oh it's BEAUTIFUL. Code and functionality looks good. 

https://github.com/user-attachments/assets/7678cd15-4a38-476e-adab-1293a03b96e7

state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-06T20:27:48Z
user: login: heavyweight
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T10:28:15Z
user: login: heavyweight
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T10:29:23Z
user: login: alshakero
body: Found two tiny issues.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-07T16:23:45Z
user: login: escapemanuele
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T17:49:19Z
user: login: escapemanuele
body: Perfect now!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T10:18:53Z
user: login: alshakero
body: Just perfect. I tried my best to break it and it always worked as expected.

1. Pasting text ✅ 
2. Pasting compatible file ✅ 
3. Pasting multiple files, only the first is taken ✅ 
4. Dragging compatible file ✅ 
5. Dragging multiple files, only the first is taken ✅ 
6. Dragging multiple files, the first item is taken ✅ 

state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T11:25:39Z

Review comments:
diff_hunk: @@ -81,12 +66,20 @@ export const AttachmentButton: React.FC< {
 		]
 	);
 
-	if ( ! chat.conversationId || ! inferredClientId ) {
-		return null;
-	}
+	useEffect( () => {
+		if ( externalFile ) {
+			onFileUpload( externalFile );
+		}
+	}, [ externalFile ] );
path: packages/odie-client/src/components/send-message-input/attachment-button.tsx
user: login: alshakero
body: This really feels like code smell. React is very finicky with effects, and this will be called more than you want it to.
created_at: 2025-03-06T12:31:09Z
author_association: MEMBER
diff_hunk: @@ -20,6 +25,35 @@ export const OdieSendMessageButton = () => {
 	const isChatBusy = chat.status === 'loading' || chat.status === 'sending';
 	const [ isMessageSizeValid, setIsMessageSizeValid ] = useState( true );
 	const [ submitDisabled, setSubmitDisabled ] = useState( true );
+	const [ fileToUpload, setFileToUpload ] = useState< File | undefined >();
+	const onFilesDrop = ( files: File[] ) => {
+		const file = files?.[ 0 ];
+		if ( file && file.type.startsWith( 'image/' ) ) {
+			setFileToUpload( file );
+		}
+	};
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: alshakero
body: We should consume `useAttachFileToConversation` directly here. Passing around all these props is much dirtier imo.
created_at: 2025-03-06T12:32:57Z
author_association: MEMBER
diff_hunk: @@ -20,6 +25,35 @@ export const OdieSendMessageButton = () => {
 	const isChatBusy = chat.status === 'loading' || chat.status === 'sending';
 	const [ isMessageSizeValid, setIsMessageSizeValid ] = useState( true );
 	const [ submitDisabled, setSubmitDisabled ] = useState( true );
+	const [ fileToUpload, setFileToUpload ] = useState< File | undefined >();
+	const onFilesDrop = ( files: File[] ) => {
+		const file = files?.[ 0 ];
+		if ( file && file.type.startsWith( 'image/' ) ) {
+			setFileToUpload( file );
+		}
+	};
+
+	const { zendeskClientId } = useSelect( ( select ) => {
+		const helpCenterSelect: HelpCenterSelect = select( HELP_CENTER_STORE );
+		return {
+			zendeskClientId: helpCenterSelect.getZendeskClientId(),
+		};
+	}, [] );
+	const onPaste = ( event: React.ClipboardEvent ) => {
+		const items = event.clipboardData.items;
+
+		for ( const item of items ) {
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: alshakero
body: Do we allow single files on drag and many on paste?
created_at: 2025-03-06T12:33:34Z
author_association: MEMBER
diff_hunk: @@ -1,7 +1,12 @@
+import { HelpCenterSelect } from '@automattic/data-stores';
+import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores';
 import { Spinner } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
 import { useCallback, useRef, useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
 import clsx from 'clsx';
+// eslint-disable-next-line no-restricted-imports
+import DropZone from 'calypso/components/drop-zone';
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: alshakero
body: [The one from WP](https://developer.wordpress.org/block-editor/reference-guides/components/drop-zone/) doesn't work? 
created_at: 2025-03-06T12:34:31Z
author_association: MEMBER
diff_hunk: @@ -35,31 +32,19 @@ const getPlaceholderAttachmentMessage = ( file: File ) => {
 
 export const AttachmentButton: React.FC< {
 	attachmentButtonRef?: React.RefObject< HTMLElement >;
-} > = ( { attachmentButtonRef } ) => {
+	externalFile?: File | null;
+	inferredClientId?: string;
+} > = ( { attachmentButtonRef, externalFile, inferredClientId } ) => {
 	const { chat, addMessage, trackEvent, isUserEligibleForPaidSupport } = useOdieAssistantContext();
 	const { data: authData } = useAuthenticateZendeskMessaging(
 		isUserEligibleForPaidSupport,
 		'messenger'
 	);
 	const { isPending: isAttachingFile, mutateAsync: attachFileToConversation } =
 		useAttachFileToConversation();
-	const { zendeskClientId } = useSelect( ( select ) => {
-		const helpCenterSelect: HelpCenterSelect = select( HELP_CENTER_STORE );
-		return {
-			zendeskClientId: helpCenterSelect.getZendeskClientId(),
-		};
-	}, [] );
-
-	const inferredClientId = chat.clientId ? chat.clientId : zendeskClientId;
 	const onFileUpload = useCallback(
-		async ( event: React.ChangeEvent< HTMLInputElement > ) => {
-			if (
-				authData &&
-				chat.conversationId &&
-				inferredClientId &&
-				event.currentTarget.files?.length
-			) {
-				const file = event.currentTarget.files[ 0 ];
+		async ( file: File | undefined ) => {
+			if ( authData && chat.conversationId && inferredClientId && file ) {
path: packages/odie-client/src/components/send-message-input/attachment-button.tsx
user: login: alshakero
body: Man, Odie's code is so damn convoluted. Since this is the only place it is used, let's please change `useAttachFileToConversation` to accept whatever params we need and get rid of the `useCallback`. We can then add the following to the mutation.
```js
onSuccess() {
   addMessage( getPlaceholderAttachmentMessage( file ) );
   trackEvent( 'send_message_attachment', { type: file.type } );
}
```
created_at: 2025-03-06T12:41:12Z
author_association: MEMBER
diff_hunk: @@ -1,7 +1,12 @@
+import { HelpCenterSelect } from '@automattic/data-stores';
+import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores';
 import { Spinner } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
 import { useCallback, useRef, useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
 import clsx from 'clsx';
+// eslint-disable-next-line no-restricted-imports
+import DropZone from 'calypso/components/drop-zone';
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: escapemanuele
body: Works now!
created_at: 2025-03-06T20:09:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -20,6 +25,35 @@ export const OdieSendMessageButton = () => {
 	const isChatBusy = chat.status === 'loading' || chat.status === 'sending';
 	const [ isMessageSizeValid, setIsMessageSizeValid ] = useState( true );
 	const [ submitDisabled, setSubmitDisabled ] = useState( true );
+	const [ fileToUpload, setFileToUpload ] = useState< File | undefined >();
+	const onFilesDrop = ( files: File[] ) => {
+		const file = files?.[ 0 ];
+		if ( file && file.type.startsWith( 'image/' ) ) {
+			setFileToUpload( file );
+		}
+	};
+
+	const { zendeskClientId } = useSelect( ( select ) => {
+		const helpCenterSelect: HelpCenterSelect = select( HELP_CENTER_STORE );
+		return {
+			zendeskClientId: helpCenterSelect.getZendeskClientId(),
+		};
+	}, [] );
+	const onPaste = ( event: React.ClipboardEvent ) => {
+		const items = event.clipboardData.items;
+
+		for ( const item of items ) {
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: escapemanuele
body: No only one in both cases, I'll try to understand what we want.
created_at: 2025-03-06T20:09:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,44 +1,101 @@
 import { HelpCenterSelect } from '@automattic/data-stores';
 import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores';
-import { Spinner } from '@wordpress/components';
+import {
+	useAttachFileToConversation,
+	useAuthenticateZendeskMessaging,
+} from '@automattic/zendesk-client';
+import { DropZone, Spinner } from '@wordpress/components';
 import { useSelect } from '@wordpress/data';
 import { useCallback, useRef, useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
 import clsx from 'clsx';
-// eslint-disable-next-line no-restricted-imports
-import DropZone from 'calypso/components/drop-zone';
 import { SendMessageIcon } from '../../assets/send-message-icon';
 import { useOdieAssistantContext } from '../../context';
 import { useSendChatMessage } from '../../hooks';
 import { Message } from '../../types';
+import { zendeskMessageConverter } from '../../utils';
 import { AttachmentButton } from './attachment-button';
 import { ResizableTextarea } from './resizable-textarea';
 
 import './style.scss';
 
+const getFileType = ( file: File ) => {
+	if ( file.type.includes( 'image' ) ) {
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: alshakero
body: We should probably use `startsWith( 'image/' )`. [Some MIME types contain 'image' even though they're not images.](https://mimetype.io/all-types#:~:text=application/x%2Diso9660%2Dimage)
created_at: 2025-03-06T20:14:51Z
author_association: MEMBER
diff_hunk: @@ -104,14 +197,22 @@ export const OdieSendMessageButton = () => {
 						inputRef={ inputRef }
 						setSubmitDisabled={ setSubmitDisabled }
 						keyUpHandle={ onKeyUp }
+						onPasteHandle={ onPaste }
 					/>
 					{ isChatBusy && <Spinner className="odie-send-message-input-spinner" /> }
-					<AttachmentButton attachmentButtonRef={ attachmentButtonRef } />
+					{ showAttachmentButton && (
+						<AttachmentButton
+							attachmentButtonRef={ attachmentButtonRef }
+							onFileUpload={ handleFileUpload }
+							isAttachingFile={ isAttachingFile }
+						/>
+					) }
 					<button type="submit" className={ buttonClasses } disabled={ submitDisabled }>
 						<SendMessageIcon />
 					</button>
 				</form>
 			</div>
+			{ showAttachmentButton && <DropZone onFilesDrop={ onFilesDrop } /> }
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: alshakero
body: Let's pass a label to dropzone so it's accurate, currently it says "Drop files to upload". It should say something like "Drop here to share the image with our happiness engineers". 
```suggestion
			{ showAttachmentButton && <DropZone onFilesDrop={ onFilesDrop } label={ __( 'Share this image with our happiness engineers' ) }  /> }
```
created_at: 2025-03-06T20:22:34Z
author_association: MEMBER
diff_hunk: @@ -1,7 +1,12 @@
+import { HelpCenterSelect } from '@automattic/data-stores';
+import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores';
 import { Spinner } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
 import { useCallback, useRef, useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
 import clsx from 'clsx';
+// eslint-disable-next-line no-restricted-imports
+import DropZone from 'calypso/components/drop-zone';
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: heavyweight
body: I was searching if we have one there but I guess I missed it 🙂
created_at: 2025-03-07T10:28:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,44 +1,101 @@
 import { HelpCenterSelect } from '@automattic/data-stores';
 import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores';
-import { Spinner } from '@wordpress/components';
+import {
+	useAttachFileToConversation,
+	useAuthenticateZendeskMessaging,
+} from '@automattic/zendesk-client';
+import { DropZone, Spinner } from '@wordpress/components';
 import { useSelect } from '@wordpress/data';
 import { useCallback, useRef, useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
 import clsx from 'clsx';
-// eslint-disable-next-line no-restricted-imports
-import DropZone from 'calypso/components/drop-zone';
 import { SendMessageIcon } from '../../assets/send-message-icon';
 import { useOdieAssistantContext } from '../../context';
 import { useSendChatMessage } from '../../hooks';
 import { Message } from '../../types';
+import { zendeskMessageConverter } from '../../utils';
 import { AttachmentButton } from './attachment-button';
 import { ResizableTextarea } from './resizable-textarea';
 
 import './style.scss';
 
+const getFileType = ( file: File ) => {
+	if ( file.type.includes( 'image' ) ) {
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: heavyweight
body: wow great suggestion I missed this
created_at: 2025-03-07T10:29:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,26 +1,115 @@
-import { Spinner } from '@wordpress/components';
+import { HelpCenterSelect } from '@automattic/data-stores';
+import { HELP_CENTER_STORE } from '@automattic/help-center/src/stores';
+import {
+	useAttachFileToConversation,
+	useAuthenticateZendeskMessaging,
+} from '@automattic/zendesk-client';
+import { DropZone, Spinner } from '@wordpress/components';
+import { useSelect } from '@wordpress/data';
 import { useCallback, useRef, useState } from '@wordpress/element';
 import { __ } from '@wordpress/i18n';
 import clsx from 'clsx';
 import { SendMessageIcon } from '../../assets/send-message-icon';
+import { ODIE_WRONG_FILE_TYPE_MESSAGE } from '../../constants';
 import { useOdieAssistantContext } from '../../context';
 import { useSendChatMessage } from '../../hooks';
 import { Message } from '../../types';
+import { zendeskMessageConverter } from '../../utils';
 import { AttachmentButton } from './attachment-button';
 import { ResizableTextarea } from './resizable-textarea';
 
 import './style.scss';
 
+const getFileType = ( file: File ) => {
+	if ( file.type.startsWith( 'image/' ) ) {
+		return 'image-placeholder';
+	}
+
+	return 'text';
+};
+
+const getPlaceholderAttachmentMessage = ( file: File ) => {
+	return zendeskMessageConverter( {
+		role: 'user',
+		type: getFileType( file ),
+		displayName: '',
+		text: '',
+		id: String( new Date().getTime() ),
+		received: new Date().getTime(),
+		source: { type: 'web', id: '', integrationId: '' },
+		mediaUrl: URL.createObjectURL( file ),
+	} );
+};
+
 export const OdieSendMessageButton = () => {
 	const divContainerRef = useRef< HTMLDivElement >( null );
 	const inputRef = useRef< HTMLTextAreaElement >( null );
 	const attachmentButtonRef = useRef< HTMLElement >( null );
-	const { trackEvent, chat } = useOdieAssistantContext();
+	const { trackEvent, chat, addMessage, isUserEligibleForPaidSupport } = useOdieAssistantContext();
 	const sendMessage = useSendChatMessage();
 	const isChatBusy = chat.status === 'loading' || chat.status === 'sending';
 	const [ isMessageSizeValid, setIsMessageSizeValid ] = useState( true );
 	const [ submitDisabled, setSubmitDisabled ] = useState( true );
 
+	const { data: authData } = useAuthenticateZendeskMessaging(
+		isUserEligibleForPaidSupport,
+		'messenger'
+	);
+	const { zendeskClientId } = useSelect( ( select ) => {
+		const helpCenterSelect: HelpCenterSelect = select( HELP_CENTER_STORE );
+		return {
+			zendeskClientId: helpCenterSelect.getZendeskClientId(),
+		};
+	}, [] );
+	const inferredClientId = chat.clientId ? chat.clientId : zendeskClientId;
+
+	const { isPending: isAttachingFile, mutateAsync: attachFileToConversation } =
+		useAttachFileToConversation();
+
+	const handleFileUpload = useCallback(
+		async ( file: File ) => {
+			if ( file.type.startsWith( 'image/' ) ) {
+				if ( authData && chat.conversationId && inferredClientId && file ) {
+					attachFileToConversation( {
+						authData,
+						file,
+						conversationId: chat.conversationId,
+						clientId: inferredClientId,
+					} ).then( () => {
+						addMessage( getPlaceholderAttachmentMessage( file ) );
+						trackEvent( 'send_message_attachment', { type: file.type } );
+					} );
+				}
+			} else {
+				addMessage( ODIE_WRONG_FILE_TYPE_MESSAGE );
+			}
+		},
+		[
+			authData,
+			chat.conversationId,
+			inferredClientId,
+			attachFileToConversation,
+			addMessage,
+			trackEvent,
+		]
+	);
+
+	const onFilesDrop = ( files: File[] ) => {
+		const file = files?.[ 0 ];
+		if ( file && file.type.startsWith( 'image/' ) ) {
+			handleFileUpload( file );
+		}
+	};
+
+	const onPaste = ( event: React.ClipboardEvent ) => {
+		const items = event.clipboardData.items;
+		const file = items?.[ 0 ]?.getAsFile();
+		if ( file && file.type.startsWith( 'image/' ) ) {
+			event.preventDefault();
+			handleFileUpload( file );
+		}
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: alshakero
body: I noticed that pasting a file of the wrong format works but pastes the file name, which can easily be confused for the file itself. 

We should pass the file to `handleFileUpload` without checking its type here.
created_at: 2025-03-07T16:22:41Z
author_association: MEMBER
diff_hunk: @@ -98,20 +189,33 @@ export const OdieSendMessageButton = () => {
 					className="odie-send-message-input-container"
 				>
 					<ResizableTextarea
-						shouldDisableInputField={ isChatBusy }
+						shouldDisableInputField={ isChatBusy || isAttachingFile }
 						sendMessageHandler={ sendMessageHandler }
 						className="odie-send-message-input"
 						inputRef={ inputRef }
 						setSubmitDisabled={ setSubmitDisabled }
 						keyUpHandle={ onKeyUp }
+						onPasteHandle={ onPaste }
 					/>
 					{ isChatBusy && <Spinner className="odie-send-message-input-spinner" /> }
-					<AttachmentButton attachmentButtonRef={ attachmentButtonRef } />
+					{ showAttachmentButton && (
+						<AttachmentButton
+							attachmentButtonRef={ attachmentButtonRef }
+							onFileUpload={ handleFileUpload }
+							isAttachingFile={ isAttachingFile }
+						/>
+					) }
 					<button type="submit" className={ buttonClasses } disabled={ submitDisabled }>
 						<SendMessageIcon />
 					</button>
 				</form>
 			</div>
+			{ showAttachmentButton && (
+				<DropZone
+					onFilesDrop={ onFilesDrop }
+					label={ __( 'Share this image with our Happiness Engineers' ) }
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: alshakero
body: Missting text domain (`__i18n_text_domain__`).
created_at: 2025-03-07T16:23:33Z
author_association: MEMBER
diff_hunk: @@ -98,20 +189,33 @@ export const OdieSendMessageButton = () => {
 					className="odie-send-message-input-container"
 				>
 					<ResizableTextarea
-						shouldDisableInputField={ isChatBusy }
+						shouldDisableInputField={ isChatBusy || isAttachingFile }
 						sendMessageHandler={ sendMessageHandler }
 						className="odie-send-message-input"
 						inputRef={ inputRef }
 						setSubmitDisabled={ setSubmitDisabled }
 						keyUpHandle={ onKeyUp }
+						onPasteHandle={ onPaste }
 					/>
 					{ isChatBusy && <Spinner className="odie-send-message-input-spinner" /> }
-					<AttachmentButton attachmentButtonRef={ attachmentButtonRef } />
+					{ showAttachmentButton && (
+						<AttachmentButton
+							attachmentButtonRef={ attachmentButtonRef }
+							onFileUpload={ handleFileUpload }
+							isAttachingFile={ isAttachingFile }
+						/>
+					) }
 					<button type="submit" className={ buttonClasses } disabled={ submitDisabled }>
 						<SendMessageIcon />
 					</button>
 				</form>
 			</div>
+			{ showAttachmentButton && (
+				<DropZone
+					onFilesDrop={ onFilesDrop }
+					label={ __( 'Share this image with our Happiness Engineers' ) }
path: packages/odie-client/src/components/send-message-input/index.tsx
user: login: escapemanuele
body: Added!
created_at: 2025-03-07T17:49:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-02-25T16:53:31Z
body: <details>
<summary>Calypso Live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-135977">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-135977%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-135977">https://calypso.live?image=registry.a8c.com/calypso/app:build-135977</a>
</td>
</tr>
</table>
</details>
<details>
<summary>Jetpack Cloud live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-135977&env=jetpack">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-135977%26env%3Djetpack%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-135977&env=jetpack">https://calypso.live?image=registry.a8c.com/calypso/app:build-135977&env=jetpack</a>
</td>
</tr>
</table>
</details>
<details>
<summary>Automattic for Agencies live <a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-135977&env=a8c-for-agencies">(direct link)</a></summary>
<table>
<tr>
<td>
<img src="https://chart.googleapis.com/chart?chs=150x150&cht=qr&chl=https%3A%2F%2Fcalypso.live%3Fimage%3Dregistry.a8c.com%2Fcalypso%2Fapp%3Abuild-135977%26env%3Da8c-for-agencies%26flags%3Doauth&choe=UTF-8" />
</td>
<td>
<a href="https://calypso.live?image=registry.a8c.com/calypso/app:build-135977&env=a8c-for-agencies">https://calypso.live?image=registry.a8c.com/calypso/app:build-135977&env=a8c-for-agencies</a>
</td>
</tr>
</table>
</details>
<!--calypso-live-watermark:apr@v1-->
author_association: NONE
user: login: matticbot
created_at: 2025-02-25T17:03:56Z
body: <!-- icfy-watermark: c52822 -->
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

**Sections** (~2245 bytes added 📈 [gzipped])
<details>

```
name                     parsed_size           gzip_size
theme                          +13 B  (+0.0%)      +59 B  (+0.0%)
reader                         +13 B  (+0.0%)      +20 B  (+0.0%)
patterns                       +13 B  (+0.0%)      +24 B  (+0.0%)
overview                       +13 B  (+0.0%)      +10 B  (+0.0%)
domains                        +13 B  (+0.0%)      +10 B  (+0.0%)
a8c-for-agencies-sites         +13 B  (+0.0%)      +60 B  (+0.0%)
a8c-for-agencies-client        +13 B  (+0.0%)      +37 B  (+0.0%)
```

Sections contain code specific for a given set of routes. Is downloaded and parsed only when a particular route is navigated to.
</details>

**Async-loaded Components** (~1703 bytes removed 📉 [gzipped])
<details>

```
name                                       parsed_size           gzip_size
async-load-automattic-help-center-stepper      +5192 B  (+0.5%)    +1382 B  (+0.5%)
async-load-automattic-help-center              +5192 B  (+0.8%)    +1382 B  (+0.7%)
async-load-comment-block-editor                  +13 B  (+0.0%)      +20 B  (+0.0%)
```

React components that are loaded lazily, when a certain part of UI is displayed for the first time.
</details>

**Legend**
<details>
<summary>What is parsed and gzip size?</summary>

**Parsed Size:** Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
**Gzip Size:** Compressed size of the JS and CSS files. This much data needs to be downloaded over network.
</details>

Generated by performance advisor bot at [iscalypsofastyet.com](http://iscalypsofastyet.com).
author_association: CONTRIBUTOR
user: login: escapemanuele
created_at: 2025-03-05T10:49:41Z
body: What are we missing from opening this? @heavyweight 
author_association: CONTRIBUTOR
user: login: heavyweight
created_at: 2025-03-05T14:50:07Z
body: > What are we missing from opening this? @heavyweight

Applying the design feedback
author_association: CONTRIBUTOR
user: login: escapemanuele
created_at: 2025-03-05T16:58:53Z
body: @heavyweight I added a couple of commits.
I believe the only thing left is to show the message, or a notice, if the user adds a wrong type of file.
author_association: CONTRIBUTOR
user: login: escapemanuele
created_at: 2025-03-06T20:10:02Z
body: Addressed some of your feedback Omar, but not the last one, I'll return to this.
author_association: CONTRIBUTOR
user: login: matticbot
created_at: 2025-03-06T20:18:10Z
body: **This PR modifies the release build for the following Calypso Apps:**

_For info about this notification, see here: PCYsg-OT6-p2_

* blaze-dashboard
* command-palette-wp-admin
* help-center
* notifications
* odyssey-stats
* whats-new
* wpcom-block-editor

To test WordPress.com changes, run `install-plugin.sh $pluginSlug add/hc-drop-paste-attachment` on your sandbox.
<!--calypso-app-artifacts-watermark:apr@v1-->
author_association: CONTRIBUTOR
user: login: a8ci18n
created_at: 2025-03-07T17:24:47Z
body: This Pull Request is now available for translation here: https://translate.wordpress.com/deliverables/17287599

Some locales (Hebrew, Japanese) have been temporarily machine-translated due to translator availability. All other translations are usually ready within a few days. Untranslated and machine-translated strings will be sent for translation next Monday and are expected to be completed by the following Friday.

Hi @heavyweight, could you please edit the description of this PR and add a screenshot for our translators? Ideally it'd include all of the following strings:
- `Share this image with our Happiness Engineers`
- `Sorry! The file you are trying to upload is not supported. Please upload a .jpg, .png, or .gif file.`

Thank you in advance!
author_association: NONE
user: login: alshakero
created_at: 2025-03-10T10:20:35Z
body: @escapemanuele I think we missed this https://github.com/Automattic/wp-calypso/pull/100404#discussion_r1985352013
author_association: MEMBER
user: login: escapemanuele
created_at: 2025-03-10T10:40:16Z
body: Thanks @alshakero , should be good now!
author_association: CONTRIBUTOR