diff --git a/app.json b/app.json index e42885b..c633dce 100644 --- a/app.json +++ b/app.json @@ -155,7 +155,14 @@ ], "./plugins/withHuaweiPush", "expo-callkit-telecom", - "expo-splash-screen", + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + } + ], "expo-status-bar" ], "extra": { diff --git a/app/(app)/(tabs)/_layout.tsx b/app/(app)/(tabs)/_layout.tsx index 5774588..e8d6d61 100644 --- a/app/(app)/(tabs)/_layout.tsx +++ b/app/(app)/(tabs)/_layout.tsx @@ -50,6 +50,18 @@ export default function TabsLayout() { [isHomeStackRoute, pathname, triggerHomeTabPress, router] ); + const handleAppsTabPress = useCallback(() => { + if (pathname.startsWith('/apps/')) { + router.replace('/apps'); + } + }, [pathname, router]); + + const handleProfileTabPress = useCallback(() => { + if (pathname.startsWith('/profile/')) { + router.replace('/profile'); + } + }, [pathname, router]); + const tabBarStyle = useMemo(() => { if (hideTabBar) { return { display: 'none' as const, height: 0, overflow: 'hidden' as const }; @@ -125,6 +137,9 @@ export default function TabsLayout() { /> ), }} + listeners={{ + tabPress: handleAppsTabPress, + }} /> ), }} + listeners={{ + tabPress: handleProfileTabPress, + }} /> ); diff --git a/src/components/business/BlockEditor/BlockEditor.tsx b/src/components/business/BlockEditor/BlockEditor.tsx index db9cde5..c1ef82a 100644 --- a/src/components/business/BlockEditor/BlockEditor.tsx +++ b/src/components/business/BlockEditor/BlockEditor.tsx @@ -14,7 +14,8 @@ export interface BlockEditorHandle { insertTextAtCursor: (text: string) => void; getSegments: () => MessageSegment[]; getContent: () => string; - hasUploadingImages: () => boolean; + getBlocks: () => EditorBlock[]; + uploadPendingImages: () => Promise; focus: () => void; } @@ -38,7 +39,8 @@ const BlockEditor = React.forwardRef( insertTextAtCursor: editor.insertTextAtCursor, getSegments: editor.getSegments, getContent: editor.getContent, - hasUploadingImages: editor.hasUploadingImages, + getBlocks: editor.getBlocks, + uploadPendingImages: editor.uploadPendingImages, focus: () => { const firstTextBlock = editor.blocks.find(b => b.type === 'text'); if (firstTextBlock) { diff --git a/src/components/business/BlockEditor/ImageBlockView.tsx b/src/components/business/BlockEditor/ImageBlockView.tsx index aff2f39..a462102 100644 --- a/src/components/business/BlockEditor/ImageBlockView.tsx +++ b/src/components/business/BlockEditor/ImageBlockView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native'; +import { View, TouchableOpacity, StyleSheet, Dimensions } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -41,11 +41,6 @@ const ImageBlockView: React.FC = ({ block, onRemove, style cachePolicy="memory-disk" transition={200} /> - {block.uploading && ( - - - - )} onRemove(block.id)} @@ -66,13 +61,6 @@ const styles = StyleSheet.create({ position: 'relative', alignItems: 'center', }, - uploadingOverlay: { - ...StyleSheet.absoluteFill, - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: borderRadius.md, - }, removeButton: { position: 'absolute', top: 8, diff --git a/src/components/business/BlockEditor/blockEditorTypes.ts b/src/components/business/BlockEditor/blockEditorTypes.ts index 240071e..66e1bd8 100644 --- a/src/components/business/BlockEditor/blockEditorTypes.ts +++ b/src/components/business/BlockEditor/blockEditorTypes.ts @@ -12,7 +12,7 @@ export interface ImageBlock { type: 'image'; localUri: string; remoteUrl?: string; - uploading: boolean; + mimeType?: string; width?: number; height?: number; } diff --git a/src/components/business/BlockEditor/blocksToSegments.ts b/src/components/business/BlockEditor/blocksToSegments.ts index 2c9d0e0..c93b7b8 100644 --- a/src/components/business/BlockEditor/blocksToSegments.ts +++ b/src/components/business/BlockEditor/blocksToSegments.ts @@ -13,7 +13,6 @@ export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] { type: 'image', localUri: seg.data.url, remoteUrl: seg.data.url, - uploading: false, width: seg.data.width, height: seg.data.height, }; diff --git a/src/components/business/BlockEditor/useBlockEditor.ts b/src/components/business/BlockEditor/useBlockEditor.ts index 62eef8e..592ec09 100644 --- a/src/components/business/BlockEditor/useBlockEditor.ts +++ b/src/components/business/BlockEditor/useBlockEditor.ts @@ -24,6 +24,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { const [activeBlockId, setActiveBlockId] = useState(null); const inputRefs = useRef>(new Map()); const blockCursors = useRef>(new Map()); + const blocksRef = useRef(blocks); + blocksRef.current = blocks; const updateTextBlock = useCallback( (blockId: string, text: string, mentions: Map) => { @@ -87,51 +89,47 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { }); }, [focusBlock]); - const uploadImageByLocalUri = useCallback( - async (asset: ImagePicker.ImagePickerAsset) => { - try { - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); + const uploadPendingImages = useCallback(async (): Promise => { + let allSuccess = true; - if (uploadResult) { - setBlocks(prev => - prev.map(b => - b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl - ? { - ...b, - remoteUrl: uploadResult.url, - uploading: false, - width: uploadResult.width ?? b.width, - height: uploadResult.height ?? b.height, - } - : b - ) - ); - } else { - // Upload failed - setBlocks(prev => - prev.map(b => - b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl - ? { ...b, uploading: false } - : b - ) - ); - Alert.alert('上传失败', '图片上传失败,请重试'); + const uploadBlock = async () => { + const currentBlocks = blocksRef.current; + const pendingBlocks = currentBlocks.filter( + (b): b is ImageBlock => b.type === 'image' && !b.remoteUrl, + ); + + for (const block of pendingBlocks) { + try { + const uploadResult = await uploadService.uploadImage({ + uri: block.localUri, + type: block.mimeType || 'image/jpeg', + }); + + if (uploadResult) { + setBlocks(prev => + prev.map(b => + b.id === block.id && b.type === 'image' + ? { + ...b, + remoteUrl: uploadResult.url, + width: uploadResult.width ?? b.width, + height: uploadResult.height ?? b.height, + } + : b, + ), + ); + } else { + allSuccess = false; + } + } catch { + allSuccess = false; } - } catch { - setBlocks(prev => - prev.map(b => - b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl - ? { ...b, uploading: false } - : b - ) - ); } - }, - [], - ); + }; + + await uploadBlock(); + return allSuccess; + }, []); const insertImagesFromAssets = useCallback( (assets: ImagePicker.ImagePickerAsset[]) => { @@ -152,13 +150,12 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { ? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length) : 0; - // Create image block const imageBlockId = generateBlockId(); const imageBlock: ImageBlock = { id: imageBlockId, type: 'image', localUri: asset.uri, - uploading: true, + mimeType: asset.mimeType, width: asset.width, height: asset.height, }; @@ -169,7 +166,6 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos); const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length); - // Create new text block for text after cursor const newTextBlockId = generateBlockId(); const newTextBlock: TextBlock = { id: newTextBlockId, @@ -178,24 +174,19 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { activeMentions: mentionsAfter, }; - // Update current block to text before cursor updated[insertAfterIndex] = { ...activeBlock, text: textBefore, activeMentions: mentionsBefore, }; - // Insert image block and new text block updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock); - // Update index for next image insertion insertAfterIndex = insertAfterIndex + 2; - // Focus the new text block after the image setActiveBlockId(newTextBlockId); requestAnimationFrame(() => focusBlock(newTextBlockId)); } else { - // Insert after the current block (which is an image) updated.splice(insertAfterIndex + 1, 0, imageBlock); insertAfterIndex = insertAfterIndex + 1; } @@ -203,13 +194,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { return updated; }); - - // Start async uploads - for (const asset of assets) { - uploadImageByLocalUri(asset); - } }, - [activeBlockId, focusBlock, uploadImageByLocalUri], + [activeBlockId, focusBlock], ); const insertImage = useCallback(async () => { @@ -282,7 +268,7 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]); const getContent = useCallback(() => blocksToContent(blocks), [blocks]); - const hasUploadingImages = useCallback(() => blocks.some(b => b.type === 'image' && b.uploading), [blocks]); + const getBlocks = useCallback(() => blocks, [blocks]); const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => { if (ref) { @@ -308,7 +294,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) { focusBlock, getSegments, getContent, - hasUploadingImages, + getBlocks, + uploadPendingImages, registerInputRef, updateCursor, }; diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 9e2a58c..35927c5 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -662,7 +662,7 @@ const PostCardInner: React.FC = (normalizedProps) => { ) : ( - {highlightKeyword ? : contentPreview} + {highlightKeyword ? : contentPreview} )} @@ -676,7 +676,7 @@ const PostCardInner: React.FC = (normalizedProps) => { {!!post.title && ( - {highlightKeyword ? : post.title} + {highlightKeyword ? : post.title} )} @@ -748,7 +748,7 @@ const PostCardInner: React.FC = (normalizedProps) => { {!!post.title && ( - {highlightKeyword ? : post.title} + {highlightKeyword ? : post.title} )} @@ -758,7 +758,7 @@ const PostCardInner: React.FC = (normalizedProps) => { style={styles.content} numberOfLines={isExpanded ? undefined : contentNumberOfLines} > - {highlightKeyword ? : content} + {highlightKeyword ? : content} {shouldTruncate && ( setIsExpanded((prev) => !prev)} style={styles.expandBtn}> diff --git a/src/components/business/PostContentRenderer.tsx b/src/components/business/PostContentRenderer.tsx index 9f6d106..bc5d74a 100644 --- a/src/components/business/PostContentRenderer.tsx +++ b/src/components/business/PostContentRenderer.tsx @@ -61,7 +61,7 @@ const PostContentRenderer: React.FC = ({ return ( {highlightKeyword && content ? ( - + ) : ( content || '' )} @@ -85,7 +85,7 @@ const PostContentRenderer: React.FC = ({ elements.push( {highlightKeyword ? ( - + ) : ( text )} @@ -125,7 +125,7 @@ const PostContentRenderer: React.FC = ({ return ( {highlightKeyword && content ? ( - + ) : ( content || '' )} diff --git a/src/components/business/UserProfileHeader.tsx b/src/components/business/UserProfileHeader.tsx index 52e7417..8ec38e0 100644 --- a/src/components/business/UserProfileHeader.tsx +++ b/src/components/business/UserProfileHeader.tsx @@ -551,7 +551,6 @@ function createUserProfileHeaderStyles(colors: AppColors) { minWidth: 160, backgroundColor: colors.background.paper, borderRadius: borderRadius.lg, - ...shadows.lg, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.divider, overflow: 'hidden', diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 6ee176d..9dbda2e 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -38,15 +38,26 @@ import { import { Text, ResponsiveContainer } from '../../components/common'; import { channelService, postService, showPrompt, voteService } from '../../services'; import { ApiError } from '@/services/core'; -import { uploadService } from '@/services/upload'; import VoteEditor from '../../components/business/VoteEditor'; import PostMentionInput from '../../components/business/PostMentionInput'; import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor'; import { segmentsToBlocks } from '../../components/business/BlockEditor/blocksToSegments'; -import type { EditorBlock } from '../../components/business/BlockEditor/blockEditorTypes'; +import { + generateBlockId, + type EditorBlock, + type ImageBlock, +} from '../../components/business/BlockEditor/blockEditorTypes'; import { useResponsive, useResponsiveValue } from '../../hooks'; import * as hrefs from '../../navigation/hrefs'; import { MessageSegment } from '../../types'; +import { + type PendingOrRemoteImage, + type RemoteImage, + generatePendingImageId, + makePendingImageFromAsset, + getImageDisplayUri, + uploadAllPendingImages, +} from '../../utils/pendingImages'; // Props 接口 interface CreatePostScreenProps { @@ -130,7 +141,8 @@ export const CreatePostScreen: React.FC = (props) => { const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [segments, setSegments] = useState([]); - const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]); + // 图片状态:pending 表示本地待上传,remote 表示已是服务器 URL(编辑模式下的已存在图片) + const [images, setImages] = useState([]); const [channelOptions, setChannelOptions] = useState([]); const [selectedChannelId, setSelectedChannelId] = useState(null); const [showChannelPicker, setShowChannelPicker] = useState(false); @@ -213,7 +225,7 @@ export const CreatePostScreen: React.FC = (props) => { } setTitle(existingPost.title || ''); setContent(existingPost.content || ''); - setImages((existingPost.images || []).map((img) => ({ uri: img.url, uploading: false }))); + setImages((existingPost.images || []).map((img): RemoteImage => ({ id: generatePendingImageId(), kind: 'remote', url: img.url }))); // 自动判断长文模式:segments 中包含图片块即为长文帖子 const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image'); @@ -233,7 +245,7 @@ export const CreatePostScreen: React.FC = (props) => { loadPostForEdit(); }, [isEditMode, editPostID, router]); - // 选择图片(普通模式) + // 选择图片(普通模式)—— 仅选择,不立即上传 const handlePickImage = async () => { const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -250,35 +262,12 @@ export const CreatePostScreen: React.FC = (props) => { }); if (!result.canceled && result.assets) { - const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); + const newImages = result.assets.map(asset => makePendingImageFromAsset(asset)); setImages(prev => [...prev, ...newImages]); - - // 上传图片 - for (let i = 0; i < newImages.length; i++) { - const asset = result.assets[i]; - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - setImages(prev => { - const updated = [...prev]; - const index = prev.findIndex(img => img.uri === asset.uri); - if (index !== -1) { - updated[index] = { uri: uploadResult.url, uploading: false }; - } - return updated; - }); - } else { - Alert.alert('上传失败', '图片上传失败,请重试'); - setImages(prev => prev.filter(img => img.uri !== asset.uri)); - } - } } }; - // 拍照 + // 拍照 —— 仅添加,不立即上传 const handleTakePhoto = async () => { const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); @@ -294,33 +283,13 @@ export const CreatePostScreen: React.FC = (props) => { if (!result.canceled && result.assets[0]) { const asset = result.assets[0]; - setImages(prev => [...prev, { uri: asset.uri, uploading: true }]); - - // 上传图片 - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - setImages(prev => { - const updated = [...prev]; - const index = prev.findIndex(img => img.uri === asset.uri); - if (index !== -1) { - updated[index] = { uri: uploadResult.url, uploading: false }; - } - return updated; - }); - } else { - Alert.alert('上传失败', '图片上传失败,请重试'); - setImages(prev => prev.filter(img => img.uri !== asset.uri)); - } + setImages(prev => [...prev, makePendingImageFromAsset(asset)]); } }; // 删除图片 const handleRemoveImage = (index: number) => { - setImages(images.filter((_, i) => i !== index)); + setImages(prev => prev.filter((_, i) => i !== index)); }; // 选择频道(单选,可再次点击取消) @@ -350,26 +319,44 @@ export const CreatePostScreen: React.FC = (props) => { // 切换长文/非长文模式时保留数据 const handleToggleLongPostMode = useCallback(() => { if (!isLongPostMode) { - // 非长文 -> 长文:将当前文本和图片转为 blocks - const segs: MessageSegment[] = []; + // 非长文 -> 长文:将当前文本和图片(包括 pending)转为 blocks + const blocks: EditorBlock[] = []; if (content.trim()) { - segs.push({ type: 'text', data: { text: content } }); + blocks.push({ + id: generateBlockId(), + type: 'text', + text: content, + activeMentions: new Map(), + }); } for (const img of images) { - if (img.uri && !img.uploading) { - segs.push({ type: 'image', data: { url: img.uri } }); + const imageBlock: ImageBlock = img.kind === 'remote' + ? { id: generateBlockId(), type: 'image', localUri: img.url, remoteUrl: img.url } + : { id: generateBlockId(), type: 'image', localUri: img.localUri, mimeType: img.mimeType }; + blocks.push(imageBlock); + } + setInitialBlocks(blocks.length > 0 ? blocks : undefined); + } else { + // 长文 -> 非长文:从 BlockEditor 提取文本和所有图片(包括 pending) + const editorContent = blockEditorRef.current?.getContent() || ''; + const editorBlocks = blockEditorRef.current?.getBlocks() || []; + if (editorContent) setContent(editorContent); + + const editorImages: PendingOrRemoteImage[] = []; + for (const block of editorBlocks) { + if (block.type !== 'image') continue; + if (block.remoteUrl) { + editorImages.push({ id: generatePendingImageId(), kind: 'remote', url: block.remoteUrl }); + } else { + editorImages.push({ + id: generatePendingImageId(), + kind: 'pending', + localUri: block.localUri, + mimeType: block.mimeType, + }); } } - setInitialBlocks(segs.length > 0 ? segmentsToBlocks(segs) : undefined); - } else { - // 长文 -> 非长文:从 BlockEditor 提取文本和图片 - const editorContent = blockEditorRef.current?.getContent() || ''; - const editorSegments = blockEditorRef.current?.getSegments() || []; - if (editorContent) setContent(editorContent); - const editorImages = editorSegments - .filter((s): s is Extract => s.type === 'image') - .map(s => ({ uri: s.data.url, uploading: false })); - if (editorImages.length > 0) setImages(editorImages); + setImages(editorImages); } setIsLongPostMode(prev => !prev); }, [isLongPostMode, content, images]); @@ -436,25 +423,37 @@ export const CreatePostScreen: React.FC = (props) => { return; } - // 检查是否有图片正在上传 - if (isLongPostMode) { - if (blockEditorRef.current?.hasUploadingImages()) { - Alert.alert('请稍候', '图片正在上传中,请稍后再试'); - return; - } - } else { - const uploadingImages = images.filter(img => img.uploading); - if (uploadingImages.length > 0) { - Alert.alert('请稍候', '图片正在上传中,请稍后再试'); - return; - } - } + // 检查 BlockEditor 中是否有正在上传的图片 + // (已无此状态,发布时会统一上传,无需检查) setPosting(true); try { + // 普通模式下,发布前上传所有待上传的图片 + let uploadedImageUrls: string[] = []; + if (!isLongPostMode && images.length > 0) { + const uploadResult = await uploadAllPendingImages(images, (id, url) => { + setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i)); + }); + if (!uploadResult.success) { + setPosting(false); + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; + } + uploadedImageUrls = uploadResult.urls; + } + + // 长文模式下,发布前上传 BlockEditor 中所有 pending 图片 + if (isLongPostMode) { + const uploadOk = await blockEditorRef.current?.uploadPendingImages(); + if (uploadOk === false) { + setPosting(false); + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; + } + } + if (isVotePost) { - const imageUrls = images.map(img => img.uri); // 验证投票选项 const validOptions = voteOptions.filter(opt => opt.trim() !== ''); if (validOptions.length < 2) { @@ -467,7 +466,7 @@ export const CreatePostScreen: React.FC = (props) => { await voteService.createVotePost({ title: title.trim(), content: content.trim(), - images: imageUrls.length > 0 ? imageUrls : undefined, + images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, channel_id: selectedChannelId || undefined, vote_options: validOptions.map(opt => ({ content: opt })), }); @@ -506,13 +505,12 @@ export const CreatePostScreen: React.FC = (props) => { } } else { // 普通模式 - const imageUrls = images.map(img => img.uri); if (isEditMode && editPostID) { const updated = await postService.updatePost(editPostID, { title: title.trim(), content: content.trim(), segments: segments.some(s => s.type !== 'text') ? segments : undefined, - images: imageUrls.length > 0 ? imageUrls : undefined, + images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, }); if (!updated) { throw new Error('更新帖子失败'); @@ -524,7 +522,7 @@ export const CreatePostScreen: React.FC = (props) => { title: title.trim(), content: content.trim(), segments: segments.some(s => s.type !== 'text') ? segments : undefined, - images: imageUrls.length > 0 ? imageUrls : undefined, + images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, channel_id: selectedChannelId || undefined, }); showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 }); @@ -547,27 +545,25 @@ export const CreatePostScreen: React.FC = (props) => { {images.map((img, index) => ( - - {img.uploading && ( - - - - )} + handleRemoveImage(index)} - disabled={img.uploading} hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} > @@ -1044,12 +1040,6 @@ function createCreatePostStyles(colors: AppColors) { width: '100%', height: '100%', }, - uploadingOverlay: { - ...StyleSheet.absoluteFill, - backgroundColor: 'rgba(0, 0, 0, 0.5)', - justifyContent: 'center', - alignItems: 'center', - }, removeImageButton: { position: 'absolute', top: 4, diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 8b4ebf4..382bb0d 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -364,7 +364,7 @@ export const HomeScreen: React.FC = () => { useEffect(() => { if (homeTabPressCount > 0) { // 滚动 FlashList 到顶部 - flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); + flashListRef.current?.scrollToOffset({ offset: 0, animated: false }); // 滚动 ScrollView 到顶部(网格模式) scrollViewRef.current?.scrollTo({ y: 0, animated: true }); // 重置底部 Tab 栏状态 diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 0d880b2..a95de51 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -38,12 +38,18 @@ import { import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types'; import { useUserStore } from '../../stores'; import { useCurrentUser } from '../../stores/auth'; -import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; +import { postService, commentService, authService, showPrompt, voteService } from '../../services'; import { postSyncService } from '@/services/post'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks'; +import { + type PendingOrRemoteImage, + makePendingImageFromAsset, + getImageDisplayUri, + uploadAllPendingImages, +} from '../../utils/pendingImages'; import { handleError } from '@/services/core'; import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime'; import * as hrefs from '../../navigation/hrefs'; @@ -251,7 +257,7 @@ export const PostDetailScreen: React.FC = () => { const [keyboardHeight, setKeyboardHeight] = useState(0); const [isDeleting, setIsDeleting] = useState(false); // 评论图片相关状态 - const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]); + const [commentImages, setCommentImages] = useState([]); const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [isFollowing, setIsFollowing] = useState(false); const [isFollowingMe, setIsFollowingMe] = useState(false); @@ -688,31 +694,8 @@ export const PostDetailScreen: React.FC = () => { }); if (!result.canceled && result.assets) { - const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); + const newImages = result.assets.map(asset => makePendingImageFromAsset(asset, 'ci')); setCommentImages(prev => [...prev, ...newImages]); - - // 上传图片 - for (let i = 0; i < newImages.length; i++) { - const asset = result.assets[i]; - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - setCommentImages(prev => { - const updated = [...prev]; - const index = prev.findIndex(img => img.uri === asset.uri); - if (index !== -1) { - updated[index] = { uri: uploadResult.url, uploading: false }; - } - return updated; - }); - } else { - Alert.alert('上传失败', '图片上传失败,请重试'); - setCommentImages(prev => prev.filter(img => img.uri !== asset.uri)); - } - } } }; @@ -727,11 +710,17 @@ export const PostDetailScreen: React.FC = () => { const hasContent = commentText.trim() || commentImages.length > 0; if (!hasContent || !post) return; - // 检查是否有图片正在上传 - const uploadingImages = commentImages.filter(img => img.uploading); - if (uploadingImages.length > 0) { - Alert.alert('请稍候', '图片正在上传中,请稍后再试'); - return; + // 上传所有 pending 图片 + let uploadedImageUrls: string[] = []; + if (commentImages.length > 0) { + const uploadResult = await uploadAllPendingImages(commentImages, (id, url) => { + setCommentImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i)); + }); + if (!uploadResult.success) { + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; + } + uploadedImageUrls = uploadResult.urls; } const tempId = `new-${Date.now()}`; @@ -741,9 +730,6 @@ export const PostDetailScreen: React.FC = () => { // 获取根评论 ID:如果被回复评论有 root_id,则使用 root_id;否则使用 parent_id(即被回复的是顶级评论) const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null; - // 获取已上传图片的URL列表 - const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri); - // 先创建临时评论显示在列表中 const tempComment: Comment = { id: tempId, @@ -1501,13 +1487,12 @@ export const PostDetailScreen: React.FC = () => { {commentImages.length > 0 && ( {commentImages.map((image, index) => ( - - - {image.uploading && ( - - - - )} + + handleRemoveCommentImage(index)} @@ -2185,17 +2170,6 @@ function createPostDetailStyles(colors: AppColors) { height: 60, borderRadius: borderRadius.sm, }, - uploadingOverlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.4)', - justifyContent: 'center', - alignItems: 'center', - borderRadius: borderRadius.sm, - }, removeImageButton: { position: 'absolute', top: -6, diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 54c7925..609d4c7 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -208,11 +208,6 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: { backgroundColor: 'rgba(255, 255, 255, 0.96)', borderWidth: StyleSheet.hairlineWidth, borderColor: 'rgba(0, 0, 0, 0.08)', - shadowColor: colors.chat.shadow, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.12, - shadowRadius: 6, - elevation: 4, }, jumpToLatestFabText: { fontSize: 13, diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 5e5a231..f64ab6a 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -1104,7 +1104,10 @@ export const useChatScreen = (props?: ChatScreenProps) => { setSending(true); try { - const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)]; + // 有文本或回复时才构建文本段,纯图片时不塞空 text + const segments: MessageSegment[] = (trimmedText || replyingTo) + ? [...buildTextSegments(trimmedText, replyingTo)] + : []; for (const url of uploadedUrls) { segments.push({ type: 'image', diff --git a/src/screens/profile/UserScreen.tsx b/src/screens/profile/UserScreen.tsx index af8a6fb..89051dd 100644 --- a/src/screens/profile/UserScreen.tsx +++ b/src/screens/profile/UserScreen.tsx @@ -24,7 +24,7 @@ export const UserScreen: React.FC = () => { ); diff --git a/src/screens/trade/CreateTradeScreen.tsx b/src/screens/trade/CreateTradeScreen.tsx index 58b7236..0621260 100644 --- a/src/screens/trade/CreateTradeScreen.tsx +++ b/src/screens/trade/CreateTradeScreen.tsx @@ -24,15 +24,21 @@ import { ActivityIndicator, } from 'react-native'; import * as ImagePicker from 'expo-image-picker'; -import { Loading } from '../../components/common'; import { SafeAreaView } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useAppColors, type AppColors } from '../../theme'; import { Text } from '../../components/common'; import { tradeService } from '../../services/trade/tradeService'; -import { uploadService } from '../../services'; import type { TradeType, TradeCategory, TradeCondition, TradeItemDetailDTO } from '../../types/trade'; import { ALL_TRADE_CATEGORIES, ALL_TRADE_CONDITIONS } from '../../types/trade'; +import { + type PendingOrRemoteImage, + type RemoteImage, + generatePendingImageId, + makePendingImageFromAsset, + getImageDisplayUri, + uploadAllPendingImages, +} from '../../utils/pendingImages'; interface CreateTradeScreenProps { onClose: () => void; @@ -258,13 +264,6 @@ function createStyles(colors: AppColors) { justifyContent: 'center', backgroundColor: colors.background.default, }, - uploadingOverlay: { - ...StyleSheet.absoluteFill, - backgroundColor: 'rgba(0,0,0,0.3)', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 14, - }, // 提交按钮 submitBtn: { @@ -323,8 +322,8 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS const [condition, setCondition] = useState( editItem?.condition as TradeCondition | undefined ); - const [images, setImages] = useState<{ uri: string; uploading: boolean }[]>( - editItem?.images?.map(img => ({ uri: img.url, uploading: false })) || [] + const [images, setImages] = useState( + editItem?.images?.map((img): RemoteImage => ({ id: generatePendingImageId('ti'), kind: 'remote', url: img.url })) || [] ); const [submitting, setSubmitting] = useState(false); @@ -352,19 +351,29 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS Alert.alert('提示', '请输入标题'); return; } - if (images.some(img => img.uploading)) { - Alert.alert('提示', '请等待图片上传完成'); - return; - } setSubmitting(true); try { + // 上传所有 pending 图片 + let uploadedImageUrls: string[] = []; + if (images.length > 0) { + const uploadResult = await uploadAllPendingImages(images, (id, url) => { + setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i)); + }); + if (!uploadResult.success) { + setSubmitting(false); + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; + } + uploadedImageUrls = uploadResult.urls; + } + const payload = { trade_type: tradeType, category, title: title.trim(), content: content.trim() || title.trim(), - images: images.map(img => img.uri), + images: uploadedImageUrls, price: price ? parseFloat(price) : null, condition, }; @@ -403,37 +412,16 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS }); if (!result.canceled && result.assets) { - const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true })); + const newImages = result.assets.map(asset => makePendingImageFromAsset(asset, 'ti')); setImages(prev => [...prev, ...newImages]); - - for (let i = 0; i < newImages.length; i++) { - const asset = result.assets[i]; - const uploadResult = await uploadService.uploadImage({ - uri: asset.uri, - type: asset.mimeType || 'image/jpeg', - }); - - if (uploadResult) { - setImages(prev => { - const updated = [...prev]; - const index = prev.findIndex(img => img.uri === asset.uri); - if (index !== -1) { - updated[index] = { uri: uploadResult.url, uploading: false }; - } - return updated; - }); - } else { - setImages(prev => prev.filter(img => img.uri !== asset.uri)); - } - } } }, [images.length]); - const removeImage = useCallback((uri: string) => { - setImages(prev => prev.filter(img => img.uri !== uri)); + const removeImage = useCallback((id: string) => { + setImages(prev => prev.filter(img => img.id !== id)); }, []); - const canSubmit = title.trim().length > 0 && !submitting && !images.some(i => i.uploading); + const canSubmit = title.trim().length > 0 && !submitting; return ( @@ -558,14 +546,9 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS 图片 ({images.length}/9) {images.map((img) => ( - - - {img.uploading && ( - - - - )} - removeImage(img.uri)}> + + + removeImage(img.id)}> diff --git a/src/services/core/api.ts b/src/services/core/api.ts index 732d0db..7279fb0 100644 --- a/src/services/core/api.ts +++ b/src/services/core/api.ts @@ -7,6 +7,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import Constants from 'expo-constants'; +import { File as FSFile, UploadType } from 'expo-file-system'; import { Platform } from 'react-native'; import { eventBus } from '@/core/events/EventBus'; @@ -437,35 +438,10 @@ class ApiClient { file: { uri: string; name: string; type: string }, additionalData?: Record ): Promise> { - const formData = new FormData(); - - // 添加文件(使用 image 字段名) - // Web 端需要 append Blob/File;原生端可以直接传 { uri, name, type }。 - if (Platform.OS === 'web') { - const fileResponse = await fetch(file.uri); - const blob = await fileResponse.blob(); - const uploadFile = new File([blob], file.name, { type: file.type || blob.type || 'application/octet-stream' }); - formData.append('image', uploadFile); - } else { - formData.append('image', { - uri: file.uri, - name: file.name, - type: file.type, - } as any); - } - - // 添加额外数据 - if (additionalData) { - Object.keys(additionalData).forEach(key => { - formData.append(key, additionalData[key]); - }); - } - // 获取 token 并检查是否需要刷新 const token = await this.getToken(); - const headers: HeadersInit = {}; + const headers: Record = {}; if (token) { - // 检查 token 是否快过期 if (this.isTokenExpiringSoon(token)) { const refreshed = await this.refreshToken(); if (refreshed) { @@ -479,14 +455,45 @@ class ApiClient { } } - const response = await fetch(`${this.baseUrl}${path}`, { - method: 'POST', - headers, - body: formData, - }); + let data: ApiResponse; + + if (Platform.OS === 'web') { + // Web 端:fetch + Blob/File + FormData + const formData = new FormData(); + const fileResponse = await fetch(file.uri); + const blob = await fileResponse.blob(); + const uploadFile = new File([blob], file.name, { + type: file.type || blob.type || 'application/octet-stream', + }); + formData.append('image', uploadFile); + + if (additionalData) { + Object.keys(additionalData).forEach(key => { + formData.append(key, additionalData[key]); + }); + } + + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers, + body: formData, + }); + data = await response.json(); + } else { + // 原生端:使用 expo-file-system 原生 multipart 上传 + // RN 0.85 的 FormData/Blob 不支持从 URI 创建文件 part + const fsFile = new FSFile(file.uri); + const result = await fsFile.upload(`${this.baseUrl}${path}`, { + httpMethod: 'POST', + uploadType: UploadType.MULTIPART, + fieldName: 'image', + mimeType: file.type, + headers, + parameters: additionalData, + }); + data = JSON.parse(result.body) as ApiResponse; + } - const data: ApiResponse = await response.json(); - if (data.code !== 0) { throw new ApiError(data.code, data.message); } diff --git a/src/services/core/wsService.ts b/src/services/core/wsService.ts index 04392e4..e6deb02 100644 --- a/src/services/core/wsService.ts +++ b/src/services/core/wsService.ts @@ -357,7 +357,7 @@ class WebSocketService { this.markActivity(); this.startHeartbeat(); this.connectionHandlers.forEach(h => h()); - + // 发送队列中的消息 this.flushPendingMessages(); }; @@ -776,10 +776,19 @@ class WebSocketService { } private handleMessageSent(payload: any): void { - // 找到对应的发送请求并resolve - const pendingMsg = this.pendingMessages.find(p => - p.type === 'chat' && p.payload.conversation_id === payload.conversation_id - ); + // 使用后端回带的 client_msg_id 精确匹配单条消息 + // 避免同会话连续发送时,第一条 ACK 误清掉第二条的 pending 记录 + const clientId = payload?.client_msg_id; + let pendingMsg: PendingMessage | undefined; + if (clientId) { + pendingMsg = this.pendingMessages.find(p => p.id === clientId); + } + // 兜底:旧版后端不回带 client_msg_id 时,按 conversation_id + 类型匹配最旧一条 + if (!pendingMsg) { + pendingMsg = this.pendingMessages.find(p => + p.type === 'chat' && p.payload.conversation_id === payload.conversation_id + ); + } if (pendingMsg) { pendingMsg.resolve(payload); this.removePendingMessage(pendingMsg.id); @@ -787,9 +796,16 @@ class WebSocketService { } private handleMessageRecalled(payload: any): void { - const pendingMsg = this.pendingMessages.find(p => - p.type === 'recall' && p.payload.message_id === payload.message_id - ); + const clientId = payload?.client_msg_id; + let pendingMsg: PendingMessage | undefined; + if (clientId) { + pendingMsg = this.pendingMessages.find(p => p.id === clientId); + } + if (!pendingMsg) { + pendingMsg = this.pendingMessages.find(p => + p.type === 'recall' && p.payload.message_id === payload.message_id + ); + } if (pendingMsg) { pendingMsg.resolve(payload); this.removePendingMessage(pendingMsg.id); @@ -880,16 +896,18 @@ class WebSocketService { } const messageId = `msg_${++this.messageIdCounter}_${Date.now()}`; + // 携带 client_msg_id:用于服务器回 ACK / message_sent 时精确匹配单条消息 + const payloadWithId = { ...payload, client_msg_id: messageId }; const message = { type, - payload, + payload: payloadWithId, }; // 添加到待处理队列 const pendingMsg: PendingMessage = { id: messageId, type, - payload, + payload: payloadWithId, resolve, reject, timestamp: Date.now(), @@ -929,6 +947,11 @@ class WebSocketService { } // 刷新待发送消息队列 + // 过期判定: + // - 单条 sendViaWebSocket 自带 10s 超时,正常情况下 pending 消息不会超过这么久 + // - 重连后链路刚恢复时,距离"刚入队"的消息一律视为有效(避免把刚刚挂起的消息误清) + // - 兜底:超过 STALE_PENDING_MS(60s)一定视为过期丢弃 + private static readonly STALE_PENDING_MS = 60000; private flushPendingMessages(): void { if (this.pendingMessages.length === 0) return; @@ -938,7 +961,9 @@ class WebSocketService { // 分离过期和有效的消息 for (const msg of this.pendingMessages) { - if (now - msg.timestamp > 30000) { + const ageMs = now - msg.timestamp; + const isStale = ageMs > WebSocketService.STALE_PENDING_MS; + if (isStale) { expiredMessages.push(msg); } else { validMessages.push(msg); @@ -950,13 +975,32 @@ class WebSocketService { msg.reject(new Error('Message expired')); }); - // 重新发送有效消息 - this.pendingMessages = []; - validMessages.forEach(msg => { - this.sendViaWebSocket(msg.type, msg.payload) - .then(msg.resolve) - .catch(msg.reject); - }); + // 重新发送有效消息:直接复用原 client_msg_id,否则重连后服务端 ACK 无法回带到原 pending 记录 + // 注意:不能调 sendViaWebSocket(会重新生成 id 并入队),必须直接 ws.send + this.pendingMessages = validMessages; + for (let i = 0; i < validMessages.length; i++) { + const msg = validMessages[i]; + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + // 链路断开,剩余消息留在队列等下次重连 + break; + } + try { + this.ws.send(JSON.stringify({ type: msg.type, payload: msg.payload })); + } catch { + // ws.send 抛异常时 reject 并移出队列,避免无限残留 + msg.reject(new Error('WebSocket send failed on flush')); + this.removePendingMessage(msg.id); + continue; + } + // 重置 10s 超时 + setTimeout(() => { + const index = this.pendingMessages.findIndex(p => p.id === msg.id); + if (index >= 0) { + this.pendingMessages[index].reject(new Error('Message timeout')); + this.removePendingMessage(msg.id); + } + }, 10000); + } } private removePendingMessage(id: string): void { diff --git a/src/stores/message/MessageManager.ts b/src/stores/message/MessageManager.ts index be9f678..ea8a5b1 100644 --- a/src/stores/message/MessageManager.ts +++ b/src/stores/message/MessageManager.ts @@ -154,10 +154,10 @@ class MessageManager { useMessageStore.getState().setInitialized(true); - // 处理缓冲的 SSE 事件 - await this.wsHandler.flushBufferedSSEEvents(); - + // 先关闭 bootstrap(事件流转入正常处理路径),再做尾部 flush + // 避免 flush 期间新到的事件被压入 buffer 后无人消费 this.wsHandler.setBootstrapping(false); + await this.wsHandler.flushBufferedSSEEvents(); if (useMessageStore.getState().currentConversationId) { await this.fetchMessages(useMessageStore.getState().currentConversationId!); diff --git a/src/stores/message/hooks.ts b/src/stores/message/hooks.ts index d949c59..ace59b8 100644 --- a/src/stores/message/hooks.ts +++ b/src/stores/message/hooks.ts @@ -100,9 +100,14 @@ interface UseMessagesReturn { * * 核心修复: * 1. useFocusEffect:屏幕重新获得焦点时强制同步消息,解决"需要退出重进才能看到新消息"的问题 - * 2. 清理 isLoadingMessages:组件卸载时清除残留的 loading 锁,防止下次进入被阻断 + * 2. 引用计数清理 isLoadingMessages:仅在最后一个订阅者卸载时清除 loading 锁, + * 避免 StrictMode 双调用或快速切换会话时,第一次 fetch 还没完成就被清锁 * 3. 重入时 forceSync:相同 conversationId 重复进入时强制拉取最新消息 */ + +// 记录每个 conversationId 上的活跃订阅数,仅在归零时清锁 +const loadingLockRefCount: Map = new Map(); + export function useMessages(conversationId: string | null): UseMessagesReturn { const normalizedConversationId = conversationId ? String(conversationId) : null; @@ -128,6 +133,10 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { return; } + // 引用计数 +1 + const prevCount = loadingLockRefCount.get(normalizedConversationId) ?? 0; + loadingLockRefCount.set(normalizedConversationId, prevCount + 1); + lastActivatedAtRef.current = Date.now(); const isReentry = lastActivatedIdRef.current === normalizedConversationId; @@ -141,11 +150,17 @@ export function useMessages(conversationId: string | null): UseMessagesReturn { lastActivatedIdRef.current = normalizedConversationId; return () => { - // 清理活动会话 + 清除残留的 loading 锁 - if (messageManager.getActiveConversation() === normalizedConversationId) { - messageManager.setActiveConversation(null); + // 引用计数 -1,仅在最后一个订阅者卸载时清活动会话 + 清 loading 锁 + const nextCount = (loadingLockRefCount.get(normalizedConversationId) ?? 1) - 1; + if (nextCount <= 0) { + loadingLockRefCount.delete(normalizedConversationId); + if (messageManager.getActiveConversation() === normalizedConversationId) { + messageManager.setActiveConversation(null); + } + useMessageStore.getState().setLoadingMessages(normalizedConversationId, false); + } else { + loadingLockRefCount.set(normalizedConversationId, nextCount); } - useMessageStore.getState().setLoadingMessages(normalizedConversationId, false); }; }, [normalizedConversationId]); diff --git a/src/stores/message/services/MessageSyncService.ts b/src/stores/message/services/MessageSyncService.ts index 366b651..1c80137 100644 --- a/src/stores/message/services/MessageSyncService.ts +++ b/src/stores/message/services/MessageSyncService.ts @@ -197,9 +197,10 @@ export class MessageSyncService implements IMessageSyncService { /** * 获取会话消息(增量同步)— 去重入口 + * key 包含 force 维度,强制刷新会跳过非强制的 in-flight 合并 */ async fetchMessages(conversationId: string, afterSeq?: number, force?: boolean): Promise { - const key = `${conversationId}:${afterSeq ?? 'all'}`; + const key = `${conversationId}:${afterSeq ?? 'all'}:${force ? 'force' : 'normal'}`; const existing = this.inflightFetches.get(key); if (existing) return existing; diff --git a/src/stores/message/services/ReadReceiptManager.ts b/src/stores/message/services/ReadReceiptManager.ts index 5e18605..f9e6d0d 100644 --- a/src/stores/message/services/ReadReceiptManager.ts +++ b/src/stores/message/services/ReadReceiptManager.ts @@ -30,7 +30,7 @@ export class ReadReceiptManager implements IReadReceiptManager { const normalizedId = normalizeConversationId(conversationId); const store = useMessageStore.getState(); const conversation = store.getConversation(normalizedId); - + if (!conversation) { console.warn('[ReadReceiptManager] 会话不存在:', normalizedId); return; @@ -53,6 +53,9 @@ export class ReadReceiptManager implements IReadReceiptManager { this.readStateVersion++; const currentVersion = this.readStateVersion; + // 记录本次写入的 my_last_read_seq,用于失败时条件回滚 + const writtenReadSeq = Math.max(Number(conversation.my_last_read_seq || 0), seq); + // 1. 标记此会话有进行中的已读请求 this.pendingReadMap.set(normalizedId, { timestamp: Date.now(), @@ -64,7 +67,7 @@ export class ReadReceiptManager implements IReadReceiptManager { const updatedConv: ConversationResponse = { ...conversation, unread_count: 0, - my_last_read_seq: Math.max(Number(conversation.my_last_read_seq || 0), seq), + my_last_read_seq: writtenReadSeq, }; const currentUnread = store.getUnreadCount(); @@ -79,11 +82,11 @@ export class ReadReceiptManager implements IReadReceiptManager { await messageService.markAsRead(normalizedId, seq); } catch (error) { console.error('[ReadReceiptManager] 标记已读API失败:', error); - - // 失败时回滚状态 - store.updateConversation(conversation); - store.setUnreadCount(currentUnread.total, currentUnread.system); - + + // 条件回滚(含 totalUnreadCount):仅在 my_last_read_seq 仍是本次写入值时回滚, + // 避免覆盖并发 markAsRead 已写入的更大读游标或更小未读 + store.rollbackReadIfUnchanged(normalizedId, writtenReadSeq, prevUnreadCount); + // API 失败时立即清除保护 this.pendingReadMap.delete(normalizedId); return; @@ -137,6 +140,9 @@ export class ReadReceiptManager implements IReadReceiptManager { }); store.setConversationsWithUnread(newConversations, 0, currentSystem); + // 记录本次乐观写入的"目标快照",用于失败时条件回滚 + const optimisticSnapshot = new Map(newConversations); + try { // 标记系统消息已读 await messageService.markAllSystemMessagesRead(); @@ -155,8 +161,22 @@ export class ReadReceiptManager implements IReadReceiptManager { } } catch (error) { console.error('[ReadReceiptManager] 标记所有已读失败:', error); - // 回滚:恢复原始会话和未读数 - store.setConversationsWithUnread(new Map(conversationsMap), prevTotalUnread, currentSystem); + // 条件回滚:仅在 conversations 的关键字段未被其他操作修改时还原, + // 避免覆盖期间到达的 markAsRead / WS 推送造成的新未读 + const current = useMessageStore.getState().conversations; + let stillMatches = current.size === optimisticSnapshot.size; + if (stillMatches) { + for (const [id, conv] of optimisticSnapshot.entries()) { + const cur = current.get(id); + if (!cur || cur.unread_count !== conv.unread_count || cur.my_last_read_seq !== conv.my_last_read_seq) { + stillMatches = false; + break; + } + } + } + if (stillMatches) { + store.setConversationsWithUnread(new Map(conversationsMap), prevTotalUnread, currentSystem); + } } } diff --git a/src/stores/message/services/UserCacheService.ts b/src/stores/message/services/UserCacheService.ts index 9b95cb1..8c0f774 100644 --- a/src/stores/message/services/UserCacheService.ts +++ b/src/stores/message/services/UserCacheService.ts @@ -6,6 +6,7 @@ import type { MessageResponse, UserDTO } from '../../../types/dto'; import { api } from '@/services/core'; import { userCacheRepository } from '@/database'; +import { useMessageStore } from '../store'; import type { IUserCacheService } from '../types'; export class UserCacheService implements IUserCacheService { @@ -61,16 +62,20 @@ export class UserCacheService implements IUserCacheService { /** * 批量异步填充消息的 sender 信息 - * 填充完成后调用 notifyUpdate 更新内存并通知订阅者 + * 注意:fetch 期间新消息可能已到达,回调内必须重读最新 messages 列表, + * 否则新消息的 sender 字段不会被填充。 */ enrichMessagesWithSenderInfo( conversationId: string, - messages: MessageResponse[], + _messages: MessageResponse[], notifyUpdate: (conversationId: string, messages: MessageResponse[]) => void ): void { + // 取一次最新列表作为本次填充目标 + const latestMessages = useMessageStore.getState().getMessages(conversationId); + // 收集所有需要查询的唯一 sender_id(排除系统用户) const senderIds = [...new Set( - messages + latestMessages .filter(m => m.sender_id && m.sender_id !== '10000' && !m.sender) .map(m => m.sender_id) )]; @@ -87,8 +92,10 @@ export class UserCacheService implements IUserCacheService { if (senderMap.size === 0) return; + // 重新读取最新消息列表(期间可能又有新消息到达) + const currentMessages = useMessageStore.getState().getMessages(conversationId); let changed = false; - const updated = messages.map(m => { + const updated = currentMessages.map(m => { if (!m.sender && m.sender_id && senderMap.has(m.sender_id)) { changed = true; return { ...m, sender: senderMap.get(m.sender_id) }; diff --git a/src/stores/message/services/WSMessageHandler.ts b/src/stores/message/services/WSMessageHandler.ts index 67ec2cc..ee17cfa 100644 --- a/src/stores/message/services/WSMessageHandler.ts +++ b/src/stores/message/services/WSMessageHandler.ts @@ -212,10 +212,13 @@ export class WSMessageHandler implements IWSMessageHandler { /** * 统一同步入口 — 节流 + 去重 * onConnect 和 sync_required 共用此入口,防止并发触发重复同步 + * 启动阶段(isBootstrapping=true)跳过,由 MessageManager.initialize 完成后统一触发 */ private async triggerSync(source: string): Promise { + if (this.isBootstrapping) return; + const now = Date.now(); - // 最小间隔节流 + // 最小间隔节流:避免连续 sync_required 事件短时间内重复触发全量同步 if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return; if (this.syncInProgress) return; @@ -246,14 +249,22 @@ export class WSMessageHandler implements IWSMessageHandler { /** * 初始化完成后,处理缓冲的 SSE 事件 + * 每次循环:取走当前 buffer 后立即清空(避免在 fetch 期间新事件再被覆盖) + * 退出条件:连续一轮没有新事件,确保 flush 期间到达的事件也被消费 */ async flushBufferedSSEEvents(): Promise { + let lastChatLen = -1; + let lastReadLen = -1; for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) { - const hasBuffered = this.bufferedChatMessages.length > 0 || this.bufferedReadReceipts.length > 0; - if (!hasBuffered) return; - const chatEvents = this.bufferedChatMessages; const readEvents = this.bufferedReadReceipts; + // 上一轮长度未变且都已清空,说明已稳定 + if (chatEvents.length === lastChatLen && readEvents.length === lastReadLen) { + return; + } + lastChatLen = chatEvents.length; + lastReadLen = readEvents.length; + // 立即清空 buffer;flush 期间新到的事件将走正常处理路径(isBootstrapping 已是 false) this.bufferedChatMessages = []; this.bufferedReadReceipts = []; @@ -528,9 +539,8 @@ export class WSMessageHandler implements IWSMessageHandler { } private incrementSystemUnread(): void { - const store = useMessageStore.getState(); - const currentUnread = store.getUnreadCount(); - store.setUnreadCount(currentUnread.total + 1, currentUnread.system + 1); + // 原子递增:避免外部 read-modify-write 造成丢计数 + useMessageStore.getState().incrementSystemUnreadAtomic(); } private markMessageAsRecalled(conversationId: string, messageId: string): void { diff --git a/src/stores/message/store.ts b/src/stores/message/store.ts index 00a1252..cfaf840 100644 --- a/src/stores/message/store.ts +++ b/src/stores/message/store.ts @@ -88,6 +88,10 @@ export interface MessageActions { removeMessage: (conversationId: string, messageId: string) => void; patchMessages: (conversationId: string, patches: Map>) => void; setUnreadCount: (total: number, system: number) => void; + /** + * 原子递增系统未读数:避免外部 RMW 造成丢失 + */ + incrementSystemUnreadAtomic: () => void; setLastSystemMessageAt: (time: string | null) => void; setSSEConnected: (connected: boolean) => void; setCurrentConversation: (conversationId: string | null) => void; @@ -107,6 +111,16 @@ export interface MessageActions { ) => void; incrementUnreadAtomic: (conversationId: string) => void; + /** + * 原子回滚乐观已读:仅在当前会话的 my_last_read_seq 仍等于 expectedReadSeq 时才回滚 + * 避免覆盖后续并发 markAsRead 已更新的更大值 + */ + rollbackReadIfUnchanged: ( + conversationId: string, + expectedReadSeq: number, + prevUnreadCount: number, + ) => void; + // 重置 reset: () => void; } @@ -393,6 +407,13 @@ export const useMessageStore = create((set, get) => ({ set({ totalUnreadCount: total, systemUnreadCount: system }); }, + incrementSystemUnreadAtomic: () => { + set(state => ({ + totalUnreadCount: state.totalUnreadCount + 1, + systemUnreadCount: state.systemUnreadCount + 1, + })); + }, + setLastSystemMessageAt: (time: string | null) => { set({ lastSystemMessageAt: time }); if (time) { @@ -506,6 +527,36 @@ export const useMessageStore = create((set, get) => ({ }); }, + rollbackReadIfUnchanged: ( + conversationId: string, + expectedReadSeq: number, + prevUnreadCount: number, + ) => { + const normalizedId = normalizeConversationId(conversationId); + set(state => { + const conversation = state.conversations.get(normalizedId); + if (!conversation) return state; + // 仅当本地乐观已读游标仍是本次写入的值时,才回滚未读 + // 否则说明已被更新的 markAsRead 覆盖,不应回滚 + if (Number(conversation.my_last_read_seq || 0) !== expectedReadSeq) { + return state; + } + const newConversations = new Map(state.conversations); + newConversations.set(normalizedId, { + ...conversation, + unread_count: prevUnreadCount, + }); + const conversationList = sortConversationList(newConversations); + // 同步回滚 totalUnreadCount:加回本次扣减的未读数 + const unreadDelta = prevUnreadCount - (conversation.unread_count || 0); + return { + conversations: newConversations, + conversationList, + totalUnreadCount: state.totalUnreadCount + unreadDelta, + }; + }); + }, + // ==================== 重置 ==================== reset: () => { diff --git a/src/utils/pendingImages.ts b/src/utils/pendingImages.ts new file mode 100644 index 0000000..b4cc002 --- /dev/null +++ b/src/utils/pendingImages.ts @@ -0,0 +1,89 @@ +/** + * 通用「待上传图片」抽象 + * + * 用于发帖、评论、发布商品等场景: + * - 用户选择图片时仅保存本地 URI(pending) + * - 提交时统一上传,失败可重试已上传的不会丢失 + */ + +import type * as ImagePicker from 'expo-image-picker'; +import { uploadService } from '@/services/upload'; + +export type PendingImage = { + id: string; + kind: 'pending'; + localUri: string; + mimeType?: string; +}; + +export type RemoteImage = { + id: string; + kind: 'remote'; + url: string; +}; + +export type PendingOrRemoteImage = PendingImage | RemoteImage; + +let _imgIdCounter = 0; +export const generatePendingImageId = (prefix = 'img'): string => + `${prefix}-${++_imgIdCounter}-${Date.now()}`; + +/** 从 ImagePicker 选择结果构造 pending 图片 */ +export const makePendingImageFromAsset = ( + asset: ImagePicker.ImagePickerAsset, + idPrefix?: string, +): PendingImage => ({ + id: generatePendingImageId(idPrefix), + kind: 'pending', + localUri: asset.uri, + mimeType: asset.mimeType, +}); + +/** 获取图片的展示 URI(pending 用本地,remote 用远程) */ +export const getImageDisplayUri = (img: PendingOrRemoteImage): string => + img.kind === 'pending' ? img.localUri : img.url; + +/** 转换为 remote 图片(保持 id 不变) */ +export const toRemoteImage = (id: string, url: string): RemoteImage => ({ + id, + kind: 'remote', + url, +}); + +export type UploadAllResult = + | { success: true; urls: string[] } + | { success: false; urls: string[]; failedId: string }; + +/** + * 批量上传 pending 图片,已是 remote 的会跳过。 + * 顺序串行执行,遇到失败立即中断并返回。 + * + * @param images 图片列表 + * @param onItemUploaded 单张图片上传成功后的回调(用于增量更新 state, + * 外部可据此将该 id 的图片标记为 remote 以便重试时跳过) + */ +export const uploadAllPendingImages = async ( + images: PendingOrRemoteImage[], + onItemUploaded?: (id: string, url: string) => void, +): Promise => { + const urls: string[] = []; + + for (const img of images) { + if (img.kind === 'remote') { + urls.push(img.url); + continue; + } + const result = await uploadService.uploadImage({ + uri: img.localUri, + type: img.mimeType || 'image/jpeg', + }); + if (result) { + urls.push(result.url); + onItemUploaded?.(img.id, result.url); + } else { + return { success: false, urls, failedId: img.id }; + } + } + + return { success: true, urls }; +};