dev #5
@@ -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 = () => {
|
||||
</View>
|
||||
|
||||
{/* 发送图片加载遮罩 */}
|
||||
{sendingImage && (
|
||||
{(sendingImage || uploadingAttachments) && (
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.overlayContent}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
<Text style={styles.overlayText}>发送图片中...</Text>
|
||||
<Text style={styles.overlayText}>
|
||||
{uploadingAttachments ? '正在上传图片…' : '发送图片中…'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -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<ChatInputProps & {
|
||||
onToggleEmoji,
|
||||
onToggleMore,
|
||||
activePanel,
|
||||
sending,
|
||||
isComposerBusy,
|
||||
isMuted,
|
||||
isGroupChat,
|
||||
muteAll,
|
||||
@@ -33,6 +33,8 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
onFocus,
|
||||
restrictionHint,
|
||||
disableMore = false,
|
||||
pendingAttachments = [],
|
||||
onRemovePendingAttachment,
|
||||
currentUser,
|
||||
otherUser,
|
||||
getSenderInfo,
|
||||
@@ -51,6 +53,11 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.inputContainer]);
|
||||
|
||||
const canSend =
|
||||
(!!inputText.trim() || pendingAttachments.length > 0) &&
|
||||
!isComposerBusy &&
|
||||
!isDisabled;
|
||||
|
||||
// 回复预览组件 - 定义在内部以访问 styles
|
||||
const ReplyPreview: React.FC<{
|
||||
replyingTo: GroupMessage;
|
||||
@@ -69,7 +76,16 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
<View style={styles.replyPreviewText}>
|
||||
<Text style={styles.replyPreviewName}>回复 {senderInfo.nickname}</Text>
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
@@ -106,6 +122,30 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
<Text style={styles.mutedBannerText}>{restrictionHint}</Text>
|
||||
</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]}>
|
||||
<TouchableOpacity
|
||||
@@ -140,7 +180,7 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{inputText.trim() && !sending && !isDisabled ? (
|
||||
{canSend ? (
|
||||
<TouchableOpacity
|
||||
style={styles.sendButtonActive}
|
||||
onPress={onSend}
|
||||
@@ -148,7 +188,7 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
>
|
||||
<MaterialCommunityIcons name="send" size={20} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
) : sending ? (
|
||||
) : isComposerBusy && !isDisabled ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButtonActive, styles.sendButtonDisabled]}
|
||||
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;
|
||||
|
||||
@@ -107,11 +107,14 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
// 检查是否有消息链(必须使用 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
|
||||
|
||||
@@ -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<ReplyPreviewSegmentProps> = ({
|
||||
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 内容 */}
|
||||
<View style={styles.segmentsContent}>
|
||||
{otherSegments.map((segment, index) => (
|
||||
<React.Fragment
|
||||
key={`segment-${segment.type}-${(segment as any)?.data?.url || (segment as any)?.data?.id || (segment as any)?.data?.user_id || index}`}
|
||||
>
|
||||
{renderSegment(segment, renderProps)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{/* 其他 Segment:行内文案与块级图片/媒体分行展示,多图纵向排列 */}
|
||||
<View style={styles.segmentsColumn}>
|
||||
{chunks.map((chunk, i) => {
|
||||
if (chunk.kind === 'inline') {
|
||||
return (
|
||||
<View key={`inl-${i}`} style={styles.inlineChunk}>
|
||||
{chunk.parts.map((segment, j) => (
|
||||
<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>
|
||||
);
|
||||
@@ -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风格:统一深色字体
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number>(0);
|
||||
const [activePanel, setActivePanel] = useState<PanelType>('none');
|
||||
const [sendingImage, setSendingImage] = useState(false);
|
||||
const [uploadingAttachments, setUploadingAttachments] = useState(false);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingChatAttachment[]>([]);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMoreHistory, setHasMoreHistory] = useState(true);
|
||||
const flatListRef = useRef<any>(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,
|
||||
|
||||
Reference in New Issue
Block a user