dev #5

Merged
lan merged 38 commits from dev into master 2026-03-25 20:54:10 +08:00
7 changed files with 338 additions and 95 deletions
Showing only changes of commit dc8f9061ab - Show all commits

View File

@@ -136,9 +136,11 @@ export const ChatScreen: React.FC = () => {
currentUserId, currentUserId,
keyboardHeight, keyboardHeight,
loading, loading,
sending, isComposerBusy,
activePanel, activePanel,
sendingImage, sendingImage,
uploadingAttachments,
pendingAttachments,
replyingTo, replyingTo,
longPressMenuVisible, longPressMenuVisible,
selectedMessage, selectedMessage,
@@ -172,6 +174,7 @@ export const ChatScreen: React.FC = () => {
shouldShowTime, shouldShowTime,
handleInputChange, handleInputChange,
handleSend, handleSend,
removePendingAttachment,
handleMoreAction, handleMoreAction,
handleInsertEmoji, handleInsertEmoji,
handleSendSticker, handleSendSticker,
@@ -487,12 +490,14 @@ export const ChatScreen: React.FC = () => {
onToggleEmoji={toggleEmojiPanel} onToggleEmoji={toggleEmojiPanel}
onToggleMore={toggleMorePanel} onToggleMore={toggleMorePanel}
activePanel={activePanel} activePanel={activePanel}
sending={sending} isComposerBusy={isComposerBusy}
isMuted={isMuted} isMuted={isMuted}
isGroupChat={isGroupChat} isGroupChat={isGroupChat}
muteAll={muteAll} muteAll={muteAll}
restrictionHint={followRestrictionHint} restrictionHint={followRestrictionHint}
replyingTo={replyingTo} replyingTo={replyingTo}
pendingAttachments={pendingAttachments}
onRemovePendingAttachment={removePendingAttachment}
onCancelReply={handleCancelReply} onCancelReply={handleCancelReply}
onFocus={() => { onFocus={() => {
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘) // 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
@@ -529,11 +534,13 @@ export const ChatScreen: React.FC = () => {
</View> </View>
{/* 发送图片加载遮罩 */} {/* 发送图片加载遮罩 */}
{sendingImage && ( {(sendingImage || uploadingAttachments) && (
<View style={styles.overlay}> <View style={styles.overlay}>
<View style={styles.overlayContent}> <View style={styles.overlayContent}>
<ActivityIndicator size="large" color={colors.primary.main} /> <ActivityIndicator size="large" color={colors.primary.main} />
<Text style={styles.overlayText}>...</Text> <Text style={styles.overlayText}>
{uploadingAttachments ? '正在上传图片…' : '发送图片中…'}
</Text>
</View> </View>
</View> </View>
)} )}

View File

@@ -4,7 +4,7 @@
*/ */
import React, { useMemo } from 'react'; 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 { MaterialCommunityIcons } from '@expo/vector-icons';
import { Text } from '../../../../components/common'; import { Text } from '../../../../components/common';
import { colors } from '../../../../theme'; import { colors } from '../../../../theme';
@@ -24,7 +24,7 @@ export const ChatInput: React.FC<ChatInputProps & {
onToggleEmoji, onToggleEmoji,
onToggleMore, onToggleMore,
activePanel, activePanel,
sending, isComposerBusy,
isMuted, isMuted,
isGroupChat, isGroupChat,
muteAll, muteAll,
@@ -33,6 +33,8 @@ export const ChatInput: React.FC<ChatInputProps & {
onFocus, onFocus,
restrictionHint, restrictionHint,
disableMore = false, disableMore = false,
pendingAttachments = [],
onRemovePendingAttachment,
currentUser, currentUser,
otherUser, otherUser,
getSenderInfo, getSenderInfo,
@@ -51,6 +53,11 @@ export const ChatInput: React.FC<ChatInputProps & {
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null, isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
]), [isWideScreen, styles.inputContainer]); ]), [isWideScreen, styles.inputContainer]);
const canSend =
(!!inputText.trim() || pendingAttachments.length > 0) &&
!isComposerBusy &&
!isDisabled;
// 回复预览组件 - 定义在内部以访问 styles // 回复预览组件 - 定义在内部以访问 styles
const ReplyPreview: React.FC<{ const ReplyPreview: React.FC<{
replyingTo: GroupMessage; replyingTo: GroupMessage;
@@ -69,7 +76,16 @@ export const ChatInput: React.FC<ChatInputProps & {
<View style={styles.replyPreviewText}> <View style={styles.replyPreviewText}>
<Text style={styles.replyPreviewName}> {senderInfo.nickname}</Text> <Text style={styles.replyPreviewName}> {senderInfo.nickname}</Text>
<Text style={styles.replyPreviewMessage} numberOfLines={1}> <Text style={styles.replyPreviewMessage} numberOfLines={1}>
{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;
})()}
</Text> </Text>
</View> </View>
</View> </View>
@@ -107,6 +123,30 @@ export const ChatInput: React.FC<ChatInputProps & {
</View> </View>
)} )}
{pendingAttachments.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={attachmentStyles.stripScroll}
contentContainerStyle={attachmentStyles.stripContent}
>
{pendingAttachments.map(item => (
<View key={item.id} style={attachmentStyles.thumbWrap}>
<RNImage source={{ uri: item.uri }} style={attachmentStyles.thumb} />
{!isDisabled && onRemovePendingAttachment ? (
<TouchableOpacity
style={attachmentStyles.removeBtn}
onPress={() => onRemovePendingAttachment(item.id)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<MaterialCommunityIcons name="close-circle" size={22} color="rgba(0,0,0,0.55)" />
</TouchableOpacity>
) : null}
</View>
))}
</ScrollView>
)}
<View style={[styles.inputInner, isDisabled && styles.inputInnerMuted]}> <View style={[styles.inputInner, isDisabled && styles.inputInnerMuted]}>
<TouchableOpacity <TouchableOpacity
style={[styles.iconButton, activePanel === 'emoji' && styles.iconButtonActive]} style={[styles.iconButton, activePanel === 'emoji' && styles.iconButtonActive]}
@@ -140,7 +180,7 @@ export const ChatInput: React.FC<ChatInputProps & {
/> />
</View> </View>
{inputText.trim() && !sending && !isDisabled ? ( {canSend ? (
<TouchableOpacity <TouchableOpacity
style={styles.sendButtonActive} style={styles.sendButtonActive}
onPress={onSend} onPress={onSend}
@@ -148,7 +188,7 @@ export const ChatInput: React.FC<ChatInputProps & {
> >
<MaterialCommunityIcons name="send" size={20} color="#FFF" /> <MaterialCommunityIcons name="send" size={20} color="#FFF" />
</TouchableOpacity> </TouchableOpacity>
) : sending ? ( ) : isComposerBusy && !isDisabled ? (
<TouchableOpacity <TouchableOpacity
style={[styles.sendButtonActive, styles.sendButtonDisabled]} style={[styles.sendButtonActive, styles.sendButtonDisabled]}
disabled disabled
@@ -173,4 +213,34 @@ export const ChatInput: React.FC<ChatInputProps & {
); );
}; };
const attachmentStyles = StyleSheet.create({
stripScroll: {
maxHeight: 88,
marginBottom: 6,
paddingHorizontal: 4,
},
stripContent: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 4,
},
thumbWrap: {
position: 'relative',
marginRight: 8,
},
thumb: {
width: 72,
height: 72,
borderRadius: 10,
backgroundColor: '#E8EAED',
},
removeBtn: {
position: 'absolute',
top: -6,
right: -6,
backgroundColor: '#FFF',
borderRadius: 12,
},
});
export default ChatInput; export default ChatInput;

View File

@@ -108,10 +108,13 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
// 安全检查:确保 segments 是数组 // 安全检查:确保 segments 是数组
const segments = Array.isArray(message.segments) ? message.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'); const isImage = Array.isArray(segments) && segments.some(s => s.type === 'image');
// 检查是否是纯图片消息只有图片segment没有文本等其他内容 // 纯图片:除 reply 外均为 image支持多图同条
const isPureImageMessage = isImage && segments.every(s => s.type === 'image' || !s.type); const segmentsWithoutReply = segments.filter(s => s.type !== 'reply');
const isPureImageMessage =
segmentsWithoutReply.length > 0 &&
segmentsWithoutReply.every(s => s.type === 'image');
// 提取所有图片 segments // 提取所有图片 segments
const imageSegments = segments const imageSegments = segments

View File

@@ -38,6 +38,44 @@ const IMAGE_MAX_WIDTH = 200;
const IMAGE_MAX_HEIGHT = 260; const IMAGE_MAX_HEIGHT = 260;
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 }; 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 // Segment 渲染器 Props
export interface SegmentRendererProps { export interface SegmentRendererProps {
segment: MessageSegment; segment: MessageSegment;
@@ -600,7 +638,8 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
const hasFile = replyMessage.segments.some(s => s.type === 'file'); const hasFile = replyMessage.segments.some(s => s.type === 'file');
if (hasImage) { if (hasImage) {
previewContent = '[图片]'; const imageCount = replyMessage.segments.filter(seg => seg.type === 'image').length;
previewContent = imageCount > 1 ? `[${imageCount}张图片]` : '[图片]';
} else if (hasVoice) { } else if (hasVoice) {
previewContent = '[语音]'; previewContent = '[语音]';
} else if (hasVideo) { } else if (hasVideo) {
@@ -666,6 +705,7 @@ export const MessageSegmentsRenderer: React.FC<{
// 找出 reply segment如果有 // 找出 reply segment如果有
const replySegment = segments.find(s => s.type === 'reply'); const replySegment = segments.find(s => s.type === 'reply');
const otherSegments = segments.filter(s => s.type !== 'reply'); const otherSegments = segments.filter(s => s.type !== 'reply');
const chunks = partitionMessageSegments(otherSegments);
const renderProps = { const renderProps = {
isMe, isMe,
@@ -691,15 +731,42 @@ export const MessageSegmentsRenderer: React.FC<{
/> />
)} )}
{/* 其他 Segment 内容 */} {/* 其他 Segment:行内文案与块级图片/媒体分行展示,多图纵向排列 */}
<View style={styles.segmentsContent}> <View style={styles.segmentsColumn}>
{otherSegments.map((segment, index) => ( {chunks.map((chunk, i) => {
<React.Fragment if (chunk.kind === 'inline') {
key={`segment-${segment.type}-${(segment as any)?.data?.url || (segment as any)?.data?.id || (segment as any)?.data?.user_id || index}`} return (
> <View key={`inl-${i}`} style={styles.inlineChunk}>
{renderSegment(segment, renderProps)} {chunk.parts.map((segment, j) => (
</React.Fragment> <React.Fragment
))} key={`segment-${segment.type}-${(segment as any)?.data?.url || (segment as any)?.data?.id || (segment as any)?.data?.user_id || j}`}
>
{renderSegment(segment, renderProps)}
</React.Fragment>
))}
</View>
);
}
if (chunk.kind === 'images') {
return (
<View key={`imgs-${i}`} style={styles.imagesChunk}>
{chunk.parts.map((segment, j) => (
<View
key={`img-${(segment.data as ImageSegmentData)?.url || j}`}
style={j < chunk.parts.length - 1 ? styles.imageStackItem : styles.imageStackItemLast}
>
{renderSegment(segment, renderProps)}
</View>
))}
</View>
);
}
return (
<View key={`blk-${i}`} style={styles.blockChunk}>
{renderSegment(chunk.segment, renderProps)}
</View>
);
})}
</View> </View>
</View> </View>
); );
@@ -726,10 +793,29 @@ const styles = StyleSheet.create({
flexDirection: 'column', flexDirection: 'column',
width: '100%', width: '100%',
}, },
segmentsContent: { segmentsColumn: {
flexDirection: 'column',
width: '100%',
},
inlineChunk: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
alignItems: 'flex-end', alignItems: 'flex-end',
width: '100%',
},
imagesChunk: {
width: '100%',
},
imageStackItem: {
width: '100%',
marginBottom: 6,
},
imageStackItemLast: {
width: '100%',
},
blockChunk: {
width: '100%',
marginTop: 2,
}, },
// 文本 - Telegram风格统一深色字体 // 文本 - Telegram风格统一深色字体

View File

@@ -165,5 +165,8 @@ export const PANEL_HEIGHTS = {
// 输入框最大长度 // 输入框最大长度
export const MAX_INPUT_LENGTH = 500; export const MAX_INPUT_LENGTH = 500;
// 单条消息最多附带本地待发送图片数(相册多选 + 拍摄累加)
export const MAX_PENDING_CHAT_IMAGES = 9;
// 群成员加载每页数量 // 群成员加载每页数量
export const MEMBERS_PAGE_SIZE = 100; export const MEMBERS_PAGE_SIZE = 100;

View File

@@ -90,6 +90,13 @@ export interface MessageBubbleProps {
onReplyPress?: (messageId: string) => void; onReplyPress?: (messageId: string) => void;
} }
/** 输入框中待发送的本地图片(未上传) */
export interface PendingChatAttachment {
id: string;
uri: string;
mimeType?: string;
}
// 输入框 Props // 输入框 Props
export interface ChatInputProps { export interface ChatInputProps {
inputText: string; inputText: string;
@@ -98,7 +105,8 @@ export interface ChatInputProps {
onToggleEmoji: () => void; onToggleEmoji: () => void;
onToggleMore: () => void; onToggleMore: () => void;
activePanel: PanelType; activePanel: PanelType;
sending: boolean; /** 发送消息或上传附件进行中,用于禁用发送按钮 */
isComposerBusy: boolean;
isMuted: boolean; isMuted: boolean;
isGroupChat: boolean; isGroupChat: boolean;
muteAll: boolean; muteAll: boolean;
@@ -107,6 +115,8 @@ export interface ChatInputProps {
onFocus: () => void; onFocus: () => void;
restrictionHint?: string | null; restrictionHint?: string | null;
disableMore?: boolean; disableMore?: boolean;
pendingAttachments?: PendingChatAttachment[];
onRemovePendingAttachment?: (id: string) => void;
} }
// 表情面板 Props // 表情面板 Props

View File

@@ -39,8 +39,9 @@ import {
SenderInfo, SenderInfo,
ChatRouteParams, ChatRouteParams,
MenuPosition, MenuPosition,
PendingChatAttachment,
} from './types'; } 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 { import {
deleteMessage as deleteMessageFromDb, deleteMessage as deleteMessageFromDb,
clearConversationMessages, clearConversationMessages,
@@ -120,6 +121,8 @@ export const useChatScreen = () => {
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0); const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
const [activePanel, setActivePanel] = useState<PanelType>('none'); const [activePanel, setActivePanel] = useState<PanelType>('none');
const [sendingImage, setSendingImage] = useState(false); const [sendingImage, setSendingImage] = useState(false);
const [uploadingAttachments, setUploadingAttachments] = useState(false);
const [pendingAttachments, setPendingAttachments] = useState<PendingChatAttachment[]>([]);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [hasMoreHistory, setHasMoreHistory] = useState(true); const [hasMoreHistory, setHasMoreHistory] = useState(true);
const flatListRef = useRef<any>(null); const flatListRef = useRef<any>(null);
@@ -305,6 +308,19 @@ export const useChatScreen = () => {
historyLoadingLockUntilRef.current = 0; historyLoadingLockUntilRef.current = 0;
}, [conversationId]); }, [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(() => { const isHistoryLoadingLocked = useCallback(() => {
return Date.now() < historyLoadingLockUntilRef.current; return Date.now() < historyLoadingLockUntilRef.current;
}, []); }, []);
@@ -833,10 +849,11 @@ export const useChatScreen = () => {
return segments; return segments;
}, []); }, []);
// 【新架构】发送消息 // 【新架构】发送消息(支持纯文字、纯多图、图文同条)
const handleSend = useCallback(async () => { const handleSend = useCallback(async () => {
const trimmedText = inputText.trim(); const trimmedText = inputText.trim();
if (!trimmedText || !conversationId) return; const hasPending = pendingAttachments.length > 0;
if ((!trimmedText && !hasPending) || !conversationId) return;
if (isGroupChat && isMuted) { if (isGroupChat && isMuted) {
if (muteAll) { if (muteAll) {
@@ -847,15 +864,61 @@ export const useChatScreen = () => {
return; return;
} }
if (!isGroupChat && !canSendFirstPrivateText) { if (!isGroupChat) {
Alert.alert('无法发送', '对方未关注你前,仅允许发送一条消息'); if (trimmedText && !canSendFirstPrivateText) {
return; 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); setSending(true);
try { 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) { if (isGroupChat && routeGroupId) {
await messageService.sendMessageByAction('group', conversationId, segments); await messageService.sendMessageByAction('group', conversationId, segments);
@@ -864,15 +927,18 @@ export const useChatScreen = () => {
setSelectedMentions([]); setSelectedMentions([]);
setMentionAll(false); setMentionAll(false);
setReplyingTo(null); setReplyingTo(null);
setPendingAttachments([]);
} else { } else {
// 【新架构】私聊消息通过 MessageManager 发送
await sendMessageViaManager(segments); await sendMessageViaManager(segments);
setInputText(''); setInputText('');
setSelectedMentions([]);
setMentionAll(false);
setReplyingTo(null); setReplyingTo(null);
setPendingAttachments([]);
} }
setTimeout(() => { setTimeout(() => {
scrollToLatest(false, false, 'send-text'); scrollToLatest(false, false, hasPending ? 'send-mixed' : 'send-text');
}, 100); }, 100);
} catch (error) { } catch (error) {
console.error('发送消息失败:', error); console.error('发送消息失败:', error);
@@ -880,60 +946,31 @@ export const useChatScreen = () => {
} finally { } finally {
setSending(false); 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 () => { const handlePickImage = useCallback(async () => {
if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) {
Alert.alert('提示', `最多添加 ${MAX_PENDING_CHAT_IMAGES} 张图片`);
setActivePanel('none');
return;
}
try { try {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -942,16 +979,23 @@ export const useChatScreen = () => {
return; return;
} }
const remaining = MAX_PENDING_CHAT_IMAGES - pendingAttachments.length;
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images', mediaTypes: 'images',
allowsEditing: false, allowsEditing: false,
quality: 1, quality: 1,
allowsMultipleSelection: false, allowsMultipleSelection: true,
selectionLimit: remaining,
}); });
if (!result.canceled && result.assets && result.assets.length > 0) { if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0]; const newItems: PendingChatAttachment[] = result.assets.map((asset, i) => ({
await handleSendImage(asset.uri, asset.mimeType ?? undefined); 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) { } catch (error) {
console.error('选择图片失败:', error); console.error('选择图片失败:', error);
@@ -959,10 +1003,16 @@ export const useChatScreen = () => {
} }
setActivePanel('none'); setActivePanel('none');
}, [handleSendImage]); }, [pendingAttachments.length]);
// 拍摄照片 // 拍摄照片(加入待发送队列)
const handleTakePhoto = useCallback(async () => { const handleTakePhoto = useCallback(async () => {
if (pendingAttachments.length >= MAX_PENDING_CHAT_IMAGES) {
Alert.alert('提示', `最多添加 ${MAX_PENDING_CHAT_IMAGES} 张图片`);
setActivePanel('none');
return;
}
try { try {
const { status } = await ImagePicker.requestCameraPermissionsAsync(); const { status } = await ImagePicker.requestCameraPermissionsAsync();
@@ -979,7 +1029,18 @@ export const useChatScreen = () => {
if (!result.canceled && result.assets && result.assets.length > 0) { if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[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) { } catch (error) {
console.error('拍摄照片失败:', error); console.error('拍摄照片失败:', error);
@@ -987,7 +1048,7 @@ export const useChatScreen = () => {
} }
setActivePanel('none'); setActivePanel('none');
}, [handleSendImage]); }, [pendingAttachments.length]);
// 处理更多功能 // 处理更多功能
const handleMoreAction = useCallback((actionId: string) => { const handleMoreAction = useCallback((actionId: string) => {
@@ -1266,6 +1327,9 @@ export const useChatScreen = () => {
sending, sending,
activePanel, activePanel,
sendingImage, sendingImage,
uploadingAttachments,
pendingAttachments,
isComposerBusy,
replyingTo, replyingTo,
longPressMenuVisible, longPressMenuVisible,
selectedMessage, selectedMessage,
@@ -1301,8 +1365,8 @@ export const useChatScreen = () => {
shouldShowTime, shouldShowTime,
handleInputChange, handleInputChange,
handleSend, handleSend,
handleSendImage,
handlePickImage, handlePickImage,
removePendingAttachment,
handleTakePhoto, handleTakePhoto,
handleMoreAction, handleMoreAction,
handleInsertEmoji, handleInsertEmoji,