dev #5
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -106,6 +122,30 @@ export const ChatInput: React.FC<ChatInputProps & {
|
|||||||
<Text style={styles.mutedBannerText}>{restrictionHint}</Text>
|
<Text style={styles.mutedBannerText}>{restrictionHint}</Text>
|
||||||
</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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -107,11 +107,14 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
// 检查是否有消息链(必须使用 segments 格式)
|
// 检查是否有消息链(必须使用 segments 格式)
|
||||||
// 安全检查:确保 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
|
||||||
|
|||||||
@@ -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,7 +705,8 @@ 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,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
@@ -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风格:统一深色字体
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user