From dc8f9061ab03ceac1e131f9baadcb8da36bf75fb Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Wed, 25 Mar 2026 03:44:16 +0800 Subject: [PATCH] feat(ChatScreen): enhance message input and attachment handling - Updated ChatInput to manage pending attachments, allowing users to upload multiple images before sending. - Introduced functionality to remove pending attachments and display their thumbnails in the input area. - Enhanced ChatScreen to reflect changes in attachment handling, including dynamic messaging during uploads. - Refactored message segment rendering to support inline text and grouped images, improving visual organization. - Added constants for maximum pending images to enforce limits on user uploads. --- src/screens/message/ChatScreen.tsx | 15 +- .../components/ChatScreen/ChatInput.tsx | 80 ++++++- .../components/ChatScreen/MessageBubble.tsx | 11 +- .../components/ChatScreen/SegmentRenderer.tsx | 110 ++++++++-- .../components/ChatScreen/constants.ts | 3 + .../message/components/ChatScreen/types.ts | 12 +- .../components/ChatScreen/useChatScreen.ts | 202 ++++++++++++------ 7 files changed, 338 insertions(+), 95 deletions(-) diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 00a1c85..6c9f4ea 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -136,9 +136,11 @@ export const ChatScreen: React.FC = () => { currentUserId, keyboardHeight, loading, - sending, + isComposerBusy, activePanel, sendingImage, + uploadingAttachments, + pendingAttachments, replyingTo, longPressMenuVisible, selectedMessage, @@ -172,6 +174,7 @@ export const ChatScreen: React.FC = () => { shouldShowTime, handleInputChange, handleSend, + removePendingAttachment, handleMoreAction, handleInsertEmoji, handleSendSticker, @@ -487,12 +490,14 @@ export const ChatScreen: React.FC = () => { onToggleEmoji={toggleEmojiPanel} onToggleMore={toggleMorePanel} activePanel={activePanel} - sending={sending} + isComposerBusy={isComposerBusy} isMuted={isMuted} isGroupChat={isGroupChat} muteAll={muteAll} restrictionHint={followRestrictionHint} replyingTo={replyingTo} + pendingAttachments={pendingAttachments} + onRemovePendingAttachment={removePendingAttachment} onCancelReply={handleCancelReply} onFocus={() => { // 输入框获得焦点时,关闭其他面板(但不要关闭键盘) @@ -529,11 +534,13 @@ export const ChatScreen: React.FC = () => { {/* 发送图片加载遮罩 */} - {sendingImage && ( + {(sendingImage || uploadingAttachments) && ( - 发送图片中... + + {uploadingAttachments ? '正在上传图片…' : '发送图片中…'} + )} diff --git a/src/screens/message/components/ChatScreen/ChatInput.tsx b/src/screens/message/components/ChatScreen/ChatInput.tsx index 79858c8..e6b313b 100644 --- a/src/screens/message/components/ChatScreen/ChatInput.tsx +++ b/src/screens/message/components/ChatScreen/ChatInput.tsx @@ -4,7 +4,7 @@ */ import React, { useMemo } from 'react'; -import { View, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native'; +import { View, TouchableOpacity, TextInput, ActivityIndicator, ScrollView, StyleSheet, Image as RNImage } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Text } from '../../../../components/common'; import { colors } from '../../../../theme'; @@ -24,7 +24,7 @@ export const ChatInput: React.FC 0) && + !isComposerBusy && + !isDisabled; + // 回复预览组件 - 定义在内部以访问 styles const ReplyPreview: React.FC<{ replyingTo: GroupMessage; @@ -69,7 +76,16 @@ export const ChatInput: React.FC 回复 {senderInfo.nickname} - {replyingTo.segments?.some(seg => seg.type === 'image') ? '[图片]' : extractTextFromSegments(replyingTo.segments)} + {(() => { + const segs = replyingTo.segments; + const imgCount = segs?.filter(seg => seg.type === 'image').length ?? 0; + const raw = extractTextFromSegments(segs); + if (imgCount > 1) { + const textPart = raw.replace(/\[图片\]/g, '').replace(/\s+/g, ' ').trim(); + return textPart ? `${textPart} [${imgCount}张图片]` : `[${imgCount}张图片]`; + } + return raw; + })()} @@ -106,6 +122,30 @@ export const ChatInput: React.FC{restrictionHint} )} + + {pendingAttachments.length > 0 && ( + + {pendingAttachments.map(item => ( + + + {!isDisabled && onRemovePendingAttachment ? ( + onRemovePendingAttachment(item.id)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ) : null} + + ))} + + )} - {inputText.trim() && !sending && !isDisabled ? ( + {canSend ? ( - ) : sending ? ( + ) : isComposerBusy && !isDisabled ? ( = ({ // 检查是否有消息链(必须使用 segments 格式) // 安全检查:确保 segments 是数组 const segments = Array.isArray(message.segments) ? message.segments : []; - const hasSegments = segments.length > 0; - // 检查是否是图片消息 + const hasSegments = segments.length > 0; + // 检查是否是图片消息 const isImage = Array.isArray(segments) && segments.some(s => s.type === 'image'); - // 检查是否是纯图片消息(只有图片segment,没有文本等其他内容) - const isPureImageMessage = isImage && segments.every(s => s.type === 'image' || !s.type); + // 纯图片:除 reply 外均为 image(支持多图同条) + const segmentsWithoutReply = segments.filter(s => s.type !== 'reply'); + const isPureImageMessage = + segmentsWithoutReply.length > 0 && + segmentsWithoutReply.every(s => s.type === 'image'); // 提取所有图片 segments const imageSegments = segments diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index 502024e..b382fb6 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -38,6 +38,44 @@ const IMAGE_MAX_WIDTH = 200; const IMAGE_MAX_HEIGHT = 260; const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 }; +/** 将非 reply 的 segments 分成:行内文案/@、连续图片组、其它块级(音视频文件等) */ +type ContentChunk = + | { kind: 'inline'; parts: MessageSegment[] } + | { kind: 'images'; parts: MessageSegment[] } + | { kind: 'block'; segment: MessageSegment }; + +function partitionMessageSegments(segments: MessageSegment[]): ContentChunk[] { + const out: ContentChunk[] = []; + let inlineBuf: MessageSegment[] = []; + const flushInline = () => { + if (inlineBuf.length) { + out.push({ kind: 'inline', parts: [...inlineBuf] }); + inlineBuf = []; + } + }; + + for (const s of segments) { + if (s.type === 'image') { + flushInline(); + const last = out[out.length - 1]; + if (last?.kind === 'images') { + last.parts.push(s); + } else { + out.push({ kind: 'images', parts: [s] }); + } + } else if (s.type === 'video' || s.type === 'file' || s.type === 'link' || s.type === 'voice') { + flushInline(); + out.push({ kind: 'block', segment: s }); + } else if (s.type === 'text' || s.type === 'at' || s.type === 'face') { + inlineBuf.push(s); + } else { + inlineBuf.push(s); + } + } + flushInline(); + return out; +} + // Segment 渲染器 Props export interface SegmentRendererProps { segment: MessageSegment; @@ -600,7 +638,8 @@ export const ReplyPreviewSegment: React.FC = ({ const hasFile = replyMessage.segments.some(s => s.type === 'file'); if (hasImage) { - previewContent = '[图片]'; + const imageCount = replyMessage.segments.filter(seg => seg.type === 'image').length; + previewContent = imageCount > 1 ? `[${imageCount}张图片]` : '[图片]'; } else if (hasVoice) { previewContent = '[语音]'; } else if (hasVideo) { @@ -666,7 +705,8 @@ export const MessageSegmentsRenderer: React.FC<{ // 找出 reply segment(如果有) const replySegment = segments.find(s => s.type === 'reply'); const otherSegments = segments.filter(s => s.type !== 'reply'); - + const chunks = partitionMessageSegments(otherSegments); + const renderProps = { isMe, currentUserId, @@ -691,15 +731,42 @@ export const MessageSegmentsRenderer: React.FC<{ /> )} - {/* 其他 Segment 内容 */} - - {otherSegments.map((segment, index) => ( - - {renderSegment(segment, renderProps)} - - ))} + {/* 其他 Segment:行内文案与块级图片/媒体分行展示,多图纵向排列 */} + + {chunks.map((chunk, i) => { + if (chunk.kind === 'inline') { + return ( + + {chunk.parts.map((segment, j) => ( + + {renderSegment(segment, renderProps)} + + ))} + + ); + } + if (chunk.kind === 'images') { + return ( + + {chunk.parts.map((segment, j) => ( + + {renderSegment(segment, renderProps)} + + ))} + + ); + } + return ( + + {renderSegment(chunk.segment, renderProps)} + + ); + })} ); @@ -726,10 +793,29 @@ const styles = StyleSheet.create({ flexDirection: 'column', width: '100%', }, - segmentsContent: { + segmentsColumn: { + flexDirection: 'column', + width: '100%', + }, + inlineChunk: { flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-end', + width: '100%', + }, + imagesChunk: { + width: '100%', + }, + imageStackItem: { + width: '100%', + marginBottom: 6, + }, + imageStackItemLast: { + width: '100%', + }, + blockChunk: { + width: '100%', + marginTop: 2, }, // 文本 - Telegram风格:统一深色字体 diff --git a/src/screens/message/components/ChatScreen/constants.ts b/src/screens/message/components/ChatScreen/constants.ts index 9130636..1d57c2d 100644 --- a/src/screens/message/components/ChatScreen/constants.ts +++ b/src/screens/message/components/ChatScreen/constants.ts @@ -165,5 +165,8 @@ export const PANEL_HEIGHTS = { // 输入框最大长度 export const MAX_INPUT_LENGTH = 500; +// 单条消息最多附带本地待发送图片数(相册多选 + 拍摄累加) +export const MAX_PENDING_CHAT_IMAGES = 9; + // 群成员加载每页数量 export const MEMBERS_PAGE_SIZE = 100; diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 2fc3385..866f6bd 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -90,6 +90,13 @@ export interface MessageBubbleProps { onReplyPress?: (messageId: string) => void; } +/** 输入框中待发送的本地图片(未上传) */ +export interface PendingChatAttachment { + id: string; + uri: string; + mimeType?: string; +} + // 输入框 Props export interface ChatInputProps { inputText: string; @@ -98,7 +105,8 @@ export interface ChatInputProps { onToggleEmoji: () => void; onToggleMore: () => void; activePanel: PanelType; - sending: boolean; + /** 发送消息或上传附件进行中,用于禁用发送按钮 */ + isComposerBusy: boolean; isMuted: boolean; isGroupChat: boolean; muteAll: boolean; @@ -107,6 +115,8 @@ export interface ChatInputProps { onFocus: () => void; restrictionHint?: string | null; disableMore?: boolean; + pendingAttachments?: PendingChatAttachment[]; + onRemovePendingAttachment?: (id: string) => void; } // 表情面板 Props diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index f5fecc4..2208de9 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -39,8 +39,9 @@ import { SenderInfo, ChatRouteParams, MenuPosition, + PendingChatAttachment, } from './types'; -import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE } from './constants'; +import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants'; import { deleteMessage as deleteMessageFromDb, clearConversationMessages, @@ -120,6 +121,8 @@ export const useChatScreen = () => { const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState(0); const [activePanel, setActivePanel] = useState('none'); const [sendingImage, setSendingImage] = useState(false); + const [uploadingAttachments, setUploadingAttachments] = useState(false); + const [pendingAttachments, setPendingAttachments] = useState([]); const [loadingMore, setLoadingMore] = useState(false); const [hasMoreHistory, setHasMoreHistory] = useState(true); const flatListRef = useRef(null); @@ -305,6 +308,19 @@ export const useChatScreen = () => { historyLoadingLockUntilRef.current = 0; }, [conversationId]); + useEffect(() => { + setPendingAttachments([]); + }, [conversationId]); + + const removePendingAttachment = useCallback((id: string) => { + setPendingAttachments(prev => prev.filter(p => p.id !== id)); + }, []); + + const isComposerBusy = useMemo( + () => sending || sendingImage || uploadingAttachments, + [sending, sendingImage, uploadingAttachments] + ); + const isHistoryLoadingLocked = useCallback(() => { return Date.now() < historyLoadingLockUntilRef.current; }, []); @@ -833,10 +849,11 @@ export const useChatScreen = () => { return segments; }, []); - // 【新架构】发送消息 + // 【新架构】发送消息(支持纯文字、纯多图、图文同条) const handleSend = useCallback(async () => { const trimmedText = inputText.trim(); - if (!trimmedText || !conversationId) return; + const hasPending = pendingAttachments.length > 0; + if ((!trimmedText && !hasPending) || !conversationId) return; if (isGroupChat && isMuted) { if (muteAll) { @@ -847,15 +864,61 @@ export const useChatScreen = () => { return; } - if (!isGroupChat && !canSendFirstPrivateText) { - Alert.alert('无法发送', '对方未关注你前,仅允许发送一条消息'); - return; + if (!isGroupChat) { + if (trimmedText && !canSendFirstPrivateText) { + Alert.alert('无法发送', '对方未关注你前,仅允许发送一条消息'); + return; + } + if (hasPending && !canSendPrivateImage) { + Alert.alert('无法发送', '对方未关注你前,暂不支持发送图片'); + return; + } + } + + const uploadedUrls: string[] = []; + if (hasPending) { + setUploadingAttachments(true); + try { + const results = await Promise.all( + pendingAttachments.map(p => + uploadService.uploadChatImage({ uri: p.uri, type: p.mimeType }) + ) + ); + for (let i = 0; i < results.length; i++) { + const r = results[i]; + if (!r?.url) { + Alert.alert('上传失败', `第 ${i + 1} 张图片上传失败,请重试`); + return; + } + uploadedUrls.push(r.url); + } + } catch (error) { + console.error('上传图片失败:', error); + Alert.alert('上传失败', getSendErrorMessage(error, '图片上传失败,请重试')); + return; + } finally { + setUploadingAttachments(false); + } } setSending(true); try { - const segments = buildTextSegments(trimmedText, replyingTo); + const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)]; + for (const url of uploadedUrls) { + segments.push({ + type: 'image', + data: { + url, + thumbnail_url: url, + } as ImageSegmentData, + }); + } + + if (segments.length === 0) { + Alert.alert('无法发送', '消息内容为空'); + return; + } if (isGroupChat && routeGroupId) { await messageService.sendMessageByAction('group', conversationId, segments); @@ -864,15 +927,18 @@ export const useChatScreen = () => { setSelectedMentions([]); setMentionAll(false); setReplyingTo(null); + setPendingAttachments([]); } else { - // 【新架构】私聊消息通过 MessageManager 发送 await sendMessageViaManager(segments); setInputText(''); + setSelectedMentions([]); + setMentionAll(false); setReplyingTo(null); + setPendingAttachments([]); } setTimeout(() => { - scrollToLatest(false, false, 'send-text'); + scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text'); }, 100); } catch (error) { console.error('发送消息失败:', error); @@ -880,60 +946,31 @@ export const useChatScreen = () => { } finally { setSending(false); } - }, [inputText, conversationId, isGroupChat, routeGroupId, selectedMentions, mentionAll, sendMessageViaManager, isMuted, muteAll, buildTextSegments, replyingTo, getSendErrorMessage, canSendFirstPrivateText, scrollToLatest]); + }, [ + inputText, + pendingAttachments, + conversationId, + isGroupChat, + routeGroupId, + sendMessageViaManager, + isMuted, + muteAll, + buildTextSegments, + replyingTo, + getSendErrorMessage, + canSendFirstPrivateText, + canSendPrivateImage, + scrollToLatest, + ]); - // 发送图片消息 - const handleSendImage = useCallback(async (imageUri: string, mimeType?: string) => { - if (!conversationId) return; - - if (isGroupChat && isMuted) { - if (muteAll) { - Alert.alert('无法发送', '当前群组已开启全员禁言'); - } else { - Alert.alert('无法发送', '你已被管理员禁言'); - } - return; - } - - if (!isGroupChat && !canSendPrivateImage) { - Alert.alert('无法发送', '对方未关注你前,暂不支持发送图片'); - return; - } - - setSendingImage(true); - - try { - const uploadResult = await uploadService.uploadChatImage({ uri: imageUri, type: mimeType }); - - if (!uploadResult) { - Alert.alert('上传失败', '图片上传失败,请重试'); - return; - } - - const segments = buildImageSegments(uploadResult.url, uploadResult.url, undefined, undefined, replyingTo); - - if (isGroupChat && routeGroupId) { - await messageService.sendMessageByAction('group', conversationId, segments); - setReplyingTo(null); - } else { - // 【新架构】私聊图片通过 MessageManager 发送 - await sendMessageViaManager(segments); - setReplyingTo(null); - } - - setTimeout(() => { - scrollToLatest(false, false, 'send-image'); - }, 100); - } catch (error) { - console.error('发送图片失败:', error); - Alert.alert('发送失败', getSendErrorMessage(error, '图片发送失败,请重试')); - } finally { - setSendingImage(false); - } - }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, getSendErrorMessage, canSendPrivateImage, scrollToLatest]); - - // 选择图片 + // 选择图片(多选进入输入框待发送,点发送时与文字一并发出) const handlePickImage = useCallback(async () => { + if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) { + Alert.alert('提示', `最多添加 ${MAX_PENDING_CHAT_IMAGES} 张图片`); + setActivePanel('none'); + return; + } + try { const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -942,16 +979,23 @@ export const useChatScreen = () => { return; } + const remaining = MAX_PENDING_CHAT_IMAGES - pendingAttachments.length; + const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: 'images', allowsEditing: false, quality: 1, - allowsMultipleSelection: false, + allowsMultipleSelection: true, + selectionLimit: remaining, }); if (!result.canceled && result.assets && result.assets.length > 0) { - const asset = result.assets[0]; - await handleSendImage(asset.uri, asset.mimeType ?? undefined); + const newItems: PendingChatAttachment[] = result.assets.map((asset, i) => ({ + id: `${Date.now()}-${i}-${Math.random().toString(36).slice(2, 9)}`, + uri: asset.uri, + mimeType: asset.mimeType ?? undefined, + })); + setPendingAttachments(prev => [...prev, ...newItems].slice(0, MAX_PENDING_CHAT_IMAGES)); } } catch (error) { console.error('选择图片失败:', error); @@ -959,10 +1003,16 @@ export const useChatScreen = () => { } setActivePanel('none'); - }, [handleSendImage]); + }, [pendingAttachments.length]); - // 拍摄照片 + // 拍摄照片(加入待发送队列) const handleTakePhoto = useCallback(async () => { + if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) { + Alert.alert('提示', `最多添加 ${MAX_PENDING_CHAT_IMAGES} 张图片`); + setActivePanel('none'); + return; + } + try { const { status } = await ImagePicker.requestCameraPermissionsAsync(); @@ -979,7 +1029,18 @@ export const useChatScreen = () => { if (!result.canceled && result.assets && result.assets.length > 0) { const asset = result.assets[0]; - await handleSendImage(asset.uri, asset.mimeType ?? undefined); + setPendingAttachments(prev => + prev.length >= MAX_PENDING_CHAT_IMAGES + ? prev + : [ + ...prev, + { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + uri: asset.uri, + mimeType: asset.mimeType ?? undefined, + }, + ] + ); } } catch (error) { console.error('拍摄照片失败:', error); @@ -987,7 +1048,7 @@ export const useChatScreen = () => { } setActivePanel('none'); - }, [handleSendImage]); + }, [pendingAttachments.length]); // 处理更多功能 const handleMoreAction = useCallback((actionId: string) => { @@ -1266,6 +1327,9 @@ export const useChatScreen = () => { sending, activePanel, sendingImage, + uploadingAttachments, + pendingAttachments, + isComposerBusy, replyingTo, longPressMenuVisible, selectedMessage, @@ -1301,8 +1365,8 @@ export const useChatScreen = () => { shouldShowTime, handleInputChange, handleSend, - handleSendImage, handlePickImage, + removePendingAttachment, handleTakePhoto, handleMoreAction, handleInsertEmoji,