|
|
@@ -29,6 +29,41 @@ interface ChatPageProps {
|
|
|
onLoadMoreMessages: (contactId: number) => void
|
|
|
}
|
|
|
|
|
|
+const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024
|
|
|
+
|
|
|
+function isFileSizeAllowed(file: File): boolean {
|
|
|
+ if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
|
+ alert('文件大小不能超过 50MB')
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+function getFileFromClipboardEvent(e: React.ClipboardEvent): File | null {
|
|
|
+ const dt = e.clipboardData
|
|
|
+ if (!dt) return null
|
|
|
+ if (dt.files.length > 0) {
|
|
|
+ return dt.files[0]
|
|
|
+ }
|
|
|
+ for (let i = 0; i < dt.items.length; i++) {
|
|
|
+ const item = dt.items[i]
|
|
|
+ if (item.kind === 'file') {
|
|
|
+ const f = item.getAsFile()
|
|
|
+ if (f) return f
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+function dragEventHasFilePayload(e: React.DragEvent): boolean {
|
|
|
+ const types = e.dataTransfer?.types
|
|
|
+ if (!types) return false
|
|
|
+ for (let i = 0; i < types.length; i++) {
|
|
|
+ if (types[i] === 'Files') return true
|
|
|
+ }
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
export const ChatPage: React.FC<ChatPageProps> = ({
|
|
|
contacts,
|
|
|
activeContact,
|
|
|
@@ -55,6 +90,7 @@ export const ChatPage: React.FC<ChatPageProps> = ({
|
|
|
const isFirstLoadRef = useRef(true)
|
|
|
const prevScrollHeightRef = useRef(0)
|
|
|
const isLoadingMoreRef = useRef(false)
|
|
|
+ const [isFileDragOver, setIsFileDragOver] = useState(false)
|
|
|
|
|
|
useEffect(() => {
|
|
|
const container = chatContainerRef.current
|
|
|
@@ -117,17 +153,70 @@ export const ChatPage: React.FC<ChatPageProps> = ({
|
|
|
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
const file = event.target.files?.[0]
|
|
|
if (!file) return
|
|
|
-
|
|
|
- const maxSize = 50 * 1024 * 1024
|
|
|
- if (file.size > maxSize) {
|
|
|
- alert('文件大小不能超过 50MB')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
+ if (!isFileSizeAllowed(file)) return
|
|
|
onFileSelect(file)
|
|
|
event.target.value = ''
|
|
|
}
|
|
|
|
|
|
+ const handlePaste = useCallback(
|
|
|
+ (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
|
+ const file = getFileFromClipboardEvent(e)
|
|
|
+ if (!file) return
|
|
|
+ e.preventDefault()
|
|
|
+ if (!isFileSizeAllowed(file)) return
|
|
|
+ onFileSelect(file)
|
|
|
+ },
|
|
|
+ [onFileSelect]
|
|
|
+ )
|
|
|
+
|
|
|
+ const isAppNotificationSession = activeContactId != null && activeContactId < 0
|
|
|
+
|
|
|
+ const handleChatDragEnter = useCallback(
|
|
|
+ (e: React.DragEvent) => {
|
|
|
+ if (isAppNotificationSession) return
|
|
|
+ if (!dragEventHasFilePayload(e)) return
|
|
|
+ e.preventDefault()
|
|
|
+ setIsFileDragOver(true)
|
|
|
+ },
|
|
|
+ [isAppNotificationSession]
|
|
|
+ )
|
|
|
+
|
|
|
+ const handleChatDragLeave = useCallback(
|
|
|
+ (e: React.DragEvent) => {
|
|
|
+ if (isAppNotificationSession) return
|
|
|
+ if (!dragEventHasFilePayload(e)) return
|
|
|
+ e.preventDefault()
|
|
|
+ const el = e.currentTarget as HTMLElement
|
|
|
+ const rel = e.relatedTarget as Node | null
|
|
|
+ if (rel && el.contains(rel)) return
|
|
|
+ setIsFileDragOver(false)
|
|
|
+ },
|
|
|
+ [isAppNotificationSession]
|
|
|
+ )
|
|
|
+
|
|
|
+ const handleChatDragOver = useCallback(
|
|
|
+ (e: React.DragEvent) => {
|
|
|
+ if (isAppNotificationSession) return
|
|
|
+ if (!dragEventHasFilePayload(e)) return
|
|
|
+ e.preventDefault()
|
|
|
+ e.dataTransfer.dropEffect = 'copy'
|
|
|
+ },
|
|
|
+ [isAppNotificationSession]
|
|
|
+ )
|
|
|
+
|
|
|
+ const handleChatDrop = useCallback(
|
|
|
+ (e: React.DragEvent) => {
|
|
|
+ e.preventDefault()
|
|
|
+ setIsFileDragOver(false)
|
|
|
+ if (isAppNotificationSession) return
|
|
|
+ const file = e.dataTransfer.files[0]
|
|
|
+ if (!file) return
|
|
|
+ if (!isFileSizeAllowed(file)) return
|
|
|
+ onFileSelect(file)
|
|
|
+ },
|
|
|
+ [isAppNotificationSession, onFileSelect]
|
|
|
+ )
|
|
|
+
|
|
|
if (contacts.length === 0) {
|
|
|
return (
|
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999', flexDirection: 'column' }}>
|
|
|
@@ -146,12 +235,26 @@ export const ChatPage: React.FC<ChatPageProps> = ({
|
|
|
)
|
|
|
}
|
|
|
|
|
|
- /** 负 id:应用系统通知会话(-app_id),只读,不展示发消息/发文件 */
|
|
|
- const isAppNotificationSession = activeContactId != null && activeContactId < 0
|
|
|
const headerRemark = formatConversationRemark(activeContact.remarks)
|
|
|
|
|
|
return (
|
|
|
- <>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ minHeight: 0,
|
|
|
+ minWidth: 0,
|
|
|
+ position: 'relative',
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ outline: isFileDragOver ? '2px dashed #1aad19' : 'none',
|
|
|
+ outlineOffset: isFileDragOver ? -2 : 0
|
|
|
+ }}
|
|
|
+ onDragEnter={handleChatDragEnter}
|
|
|
+ onDragLeave={handleChatDragLeave}
|
|
|
+ onDragOver={handleChatDragOver}
|
|
|
+ onDrop={handleChatDrop}
|
|
|
+ >
|
|
|
<div style={{ padding: '0 20px', height: '60px', display: 'flex', alignItems: 'center', borderBottom: '1px solid #e7e7e7', WebkitAppRegion: 'drag' } as React.CSSProperties}>
|
|
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
|
|
<span style={{ fontWeight: 'bold', fontSize: '16px' }}>
|
|
|
@@ -405,6 +508,7 @@ export const ChatPage: React.FC<ChatPageProps> = ({
|
|
|
}}
|
|
|
value={inputValue}
|
|
|
onChange={(e) => setInputValue(e.target.value)}
|
|
|
+ onPaste={handlePaste}
|
|
|
onKeyDown={(e) => {
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
e.preventDefault()
|
|
|
@@ -442,6 +546,6 @@ export const ChatPage: React.FC<ChatPageProps> = ({
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
- </>
|
|
|
+ </div>
|
|
|
)
|
|
|
}
|