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,