diff --git a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx index 100b55590..a0f0fc846 100644 --- a/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx +++ b/examples/vite/src/AppSettings/ActionsMenu/ActionsMenu.tsx @@ -13,6 +13,10 @@ import { NotificationPromptDialog, notificationPromptDialogId, } from './NotificationPromptDialog'; +import { + AttachmentPromptDialog, + attachmentPromptDialogId, +} from './AttachmentPromptDialog'; const actionsMenuDialogId = 'app-actions-menu'; @@ -72,6 +76,20 @@ function TriggerNotificationAction({ onTrigger }: { onTrigger: () => void }) { ); } +function TriggerAttachmentAction({ onTrigger }: { onTrigger: () => void }) { + const { closeMenu } = useContextMenuContext(); + + return ( + { + closeMenu(); + onTrigger(); + }} + /> + ); +} + const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => { const [menuButtonElement, setMenuButtonElement] = useState( null, @@ -82,7 +100,9 @@ const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => { const { dialog: notificationDialog } = useDialogOnNearestManager({ id: notificationPromptDialogId, }); - + const { dialog: attachmentDialog } = useDialogOnNearestManager({ + id: attachmentPromptDialogId, + }); const menuIsOpen = useDialogIsOpen(actionsMenuDialogId, dialogManager?.id); return ( @@ -105,8 +125,10 @@ const ActionsMenuInner = ({ iconOnly }: { iconOnly: boolean }) => { trapFocus > + + ); }; diff --git a/examples/vite/src/AppSettings/ActionsMenu/AttachmentPromptDialog.tsx b/examples/vite/src/AppSettings/ActionsMenu/AttachmentPromptDialog.tsx new file mode 100644 index 000000000..c095c4a4b --- /dev/null +++ b/examples/vite/src/AppSettings/ActionsMenu/AttachmentPromptDialog.tsx @@ -0,0 +1,303 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; +import type { LocalAttachment } from 'stream-chat'; +import { + DialogAnchor, + Prompt, + useChatContext, + useDialogIsOpen, + useDialogOnNearestManager, +} from 'stream-chat-react'; + +export const attachmentPromptDialogId = 'app-attachment-prompt-dialog'; +type AttachmentEditorTab = 'unsupported-file' | 'unsupported-object'; + +const VIEWPORT_MARGIN = 8; +const defaultUnsupportedAttachment = { + asset_url: 'https://example.com/unsupported.bin', + file_size: 128000, + localMetadata: { + id: 'unsupported-attachment-1', + uploadProgress: 100, + uploadState: 'finished', + }, + mime_type: 'application/octet-stream', + title: 'unsupported.bin', + type: 'unsupported', +}; +const defaultUnsupportedObjectAttachment = { + localMetadata: { + id: 'unsupported-object-1', + uploadProgress: 100, + uploadState: 'finished', + }, + debug: true, + metadata: { randomNumber: 7, source: 'vite-preview' }, + title: 'custom payload', + type: 'custom', +}; +const initialUnsupportedFileValue = JSON.stringify(defaultUnsupportedAttachment, null, 2); +const initialUnsupportedObjectValue = JSON.stringify( + defaultUnsupportedObjectAttachment, + null, + 2, +); + +const clamp = (value: number, min: number, max: number) => { + if (max < min) return min; + return Math.min(Math.max(value, min), max); +}; + +export const AttachmentPromptDialog = ({ + referenceElement, +}: { + referenceElement: HTMLElement | null; +}) => { + const [activeTab, setActiveTab] = useState('unsupported-file'); + const [unsupportedFileInput, setUnsupportedFileInput] = useState( + initialUnsupportedFileValue, + ); + const [unsupportedObjectInput, setUnsupportedObjectInput] = useState( + initialUnsupportedObjectValue, + ); + const [errorMessage, setErrorMessage] = useState(null); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const shellRef = useRef(null); + const { channel } = useChatContext(); + const { dialog, dialogManager } = useDialogOnNearestManager({ + id: attachmentPromptDialogId, + }); + const dialogIsOpen = useDialogIsOpen(attachmentPromptDialogId, dialogManager?.id); + + useEffect(() => { + if (dialogIsOpen) return; + setActiveTab('unsupported-file'); + setUnsupportedFileInput(initialUnsupportedFileValue); + setUnsupportedObjectInput(initialUnsupportedObjectValue); + setErrorMessage(null); + setDragOffset({ x: 0, y: 0 }); + }, [dialogIsOpen]); + + useEffect(() => { + if (!dialogIsOpen) return; + + const clampToViewport = () => { + const shell = shellRef.current; + if (!shell) return; + + const rect = shell.getBoundingClientRect(); + const nextLeft = clamp( + rect.left, + VIEWPORT_MARGIN, + window.innerWidth - rect.width - VIEWPORT_MARGIN, + ); + const nextTop = clamp( + rect.top, + VIEWPORT_MARGIN, + window.innerHeight - rect.height - VIEWPORT_MARGIN, + ); + + if (nextLeft === rect.left && nextTop === rect.top) return; + + setDragOffset((current) => ({ + x: current.x + (nextLeft - rect.left), + y: current.y + (nextTop - rect.top), + })); + }; + + window.addEventListener('resize', clampToViewport); + + return () => { + window.removeEventListener('resize', clampToViewport); + }; + }, [dialogIsOpen]); + + const closeDialog = useCallback(() => { + dialog.close(); + }, [dialog]); + + const attachToComposer = useCallback( + (tab: AttachmentEditorTab) => { + if (!channel?.messageComposer) { + setErrorMessage('No active channel selected'); + return; + } + + let parsedAttachment: LocalAttachment; + const attachmentInput = + tab === 'unsupported-file' ? unsupportedFileInput : unsupportedObjectInput; + try { + parsedAttachment = JSON.parse(attachmentInput); + } catch { + setErrorMessage('Attachment is not valid JSON'); + return; + } + + const currentAttachments = + channel.messageComposer.attachmentManager.state.getLatestValue().attachments; + + channel.messageComposer.attachmentManager.upsertAttachments([ + ...currentAttachments, + parsedAttachment, + ]); + closeDialog(); + }, + [channel, closeDialog, unsupportedFileInput, unsupportedObjectInput], + ); + + const handleHeaderPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (event.button !== 0) return; + if (!(event.target instanceof HTMLElement)) return; + if (event.target.closest('button')) return; + + const shell = shellRef.current; + if (!shell) return; + + event.preventDefault(); + + const startClientX = event.clientX; + const startClientY = event.clientY; + const startOffset = dragOffset; + const startRect = shell.getBoundingClientRect(); + + const handlePointerMove = (moveEvent: PointerEvent) => { + const nextLeft = clamp( + startRect.left + (moveEvent.clientX - startClientX), + VIEWPORT_MARGIN, + window.innerWidth - startRect.width - VIEWPORT_MARGIN, + ); + const nextTop = clamp( + startRect.top + (moveEvent.clientY - startClientY), + VIEWPORT_MARGIN, + window.innerHeight - startRect.height - VIEWPORT_MARGIN, + ); + + setDragOffset({ + x: startOffset.x + (nextLeft - startRect.left), + y: startOffset.y + (nextTop - startRect.top), + }); + }; + + const handlePointerUp = () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + }, + [dragOffset], + ); + + const shellStyle = { + transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`, + }; + + return ( + +
+ +
+ +
+ +
+

+ Attach Unsupported Attachment +

+
+ + +
+