Merge branch 'dev' of https://code.littlelan.cn/carrot_bbs/frontend into dev
Some checks failed
Frontend CI / ota-android (push) Successful in 2m33s
Frontend CI / ota-ios (push) Successful in 8m2s
Frontend CI / build-and-push-web (push) Failing after 15m26s
Frontend CI / build-android-apk (push) Successful in 1h45m49s

This commit is contained in:
lafay
2026-06-12 23:42:11 +08:00
27 changed files with 565 additions and 376 deletions

View File

@@ -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": {

View File

@@ -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,
}}
/>
<Tabs.Screen
name="messages"
@@ -152,6 +167,9 @@ export default function TabsLayout() {
/>
),
}}
listeners={{
tabPress: handleProfileTabPress,
}}
/>
</Tabs>
);

View File

@@ -14,7 +14,8 @@ export interface BlockEditorHandle {
insertTextAtCursor: (text: string) => void;
getSegments: () => MessageSegment[];
getContent: () => string;
hasUploadingImages: () => boolean;
getBlocks: () => EditorBlock[];
uploadPendingImages: () => Promise<boolean>;
focus: () => void;
}
@@ -38,7 +39,8 @@ const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
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) {

View File

@@ -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<ImageBlockViewProps> = ({ block, onRemove, style
cachePolicy="memory-disk"
transition={200}
/>
{block.uploading && (
<View style={styles.uploadingOverlay}>
<ActivityIndicator size="small" color={colors.text.inverse} />
</View>
)}
<TouchableOpacity
style={styles.removeButton}
onPress={() => 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,

View File

@@ -12,7 +12,7 @@ export interface ImageBlock {
type: 'image';
localUri: string;
remoteUrl?: string;
uploading: boolean;
mimeType?: string;
width?: number;
height?: number;
}

View File

@@ -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,
};

View File

@@ -24,6 +24,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
const inputRefs = useRef<Map<string, TextInput>>(new Map());
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
const blocksRef = useRef<EditorBlock[]>(blocks);
blocksRef.current = blocks;
const updateTextBlock = useCallback(
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
@@ -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<boolean> => {
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,
};

View File

@@ -662,7 +662,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
) : (
<View style={styles.gridNoImagePreview}>
<Text style={styles.gridNoImageText} numberOfLines={6}>
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} style={styles.highlight} /> : contentPreview}
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : contentPreview}
</Text>
</View>
)}
@@ -676,7 +676,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!!post.title && (
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
</Text>
)}
@@ -748,7 +748,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!!post.title && (
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : post.title}
</Text>
)}
@@ -758,7 +758,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
style={styles.content}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
>
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} highlightStyle={styles.highlight} /> : content}
</Text>
{shouldTruncate && (
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>

View File

@@ -61,7 +61,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
<HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : (
content || ''
)}
@@ -85,7 +85,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
elements.push(
<Text key={key} selectable style={textStyle}>
{highlightKeyword ? (
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
<HighlightText text={text} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : (
text
)}
@@ -125,7 +125,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
return (
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{highlightKeyword && content ? (
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
<HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
) : (
content || ''
)}

View File

@@ -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',

View File

@@ -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<CreatePostScreenProps> = (props) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [segments, setSegments] = useState<MessageSegment[]>([]);
const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
// 图片状态pending 表示本地待上传remote 表示已是服务器 URL编辑模式下的已存在图片
const [images, setImages] = useState<PendingOrRemoteImage[]>([]);
const [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const [showChannelPicker, setShowChannelPicker] = useState(false);
@@ -213,7 +225,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (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<CreatePostScreenProps> = (props) => {
loadPostForEdit();
}, [isEditMode, editPostID, router]);
// 选择图片(普通模式)
// 选择图片(普通模式)—— 仅选择,不立即上传
const handlePickImage = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -250,35 +262,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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<typeof s, { type: 'image' }> => 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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (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<CreatePostScreenProps> = (props) => {
<View style={styles.imageGrid}>
{images.map((img, index) => (
<Animated.View
key={`${img.uri}-${index}`}
key={img.id}
style={[
styles.imageGridItem,
{
width: imageSize,
{
width: imageSize,
height: imageSize,
opacity: fadeAnim,
transform: [{ scale: fadeAnim }]
opacity: fadeAnim,
transform: [{ scale: fadeAnim }]
}
]}
>
<Image source={{ uri: img.uri }} style={styles.gridImage} resizeMode="cover" />
{img.uploading && (
<View style={styles.uploadingOverlay}>
<MaterialCommunityIcons name="loading" size={20} color={colors.text.inverse} />
</View>
)}
<Image
source={{ uri: getImageDisplayUri(img) }}
style={styles.gridImage}
resizeMode="cover"
/>
<TouchableOpacity
style={styles.removeImageButton}
onPress={() => handleRemoveImage(index)}
disabled={img.uploading}
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
>
<View style={styles.removeImageButtonInner}>
@@ -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,

View File

@@ -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 栏状态

View File

@@ -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<PendingOrRemoteImage[]>([]);
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 && (
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
{commentImages.map((image, index) => (
<View key={index} style={styles.commentImageWrapper}>
<Image source={{ uri: image.uri }} style={styles.commentImageThumbnail} resizeMode="cover" />
{image.uploading && (
<View style={styles.uploadingOverlay}>
<Loading size="sm" />
</View>
)}
<View key={image.id} style={styles.commentImageWrapper}>
<Image
source={{ uri: getImageDisplayUri(image) }}
style={styles.commentImageThumbnail}
resizeMode="cover"
/>
<TouchableOpacity
style={styles.removeImageButton}
onPress={() => 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,

View File

@@ -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,

View File

@@ -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',

View File

@@ -24,7 +24,7 @@ export const UserScreen: React.FC = () => {
<UserProfileScreen
mode={isSelfProfile ? 'self' : 'other'}
userId={isSelfProfile ? undefined : userId}
hasHeader={false}
hasHeader
/>
</SafeAreaView>
);

View File

@@ -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<TradeCondition | undefined>(
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<PendingOrRemoteImage[]>(
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 (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
@@ -558,14 +546,9 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS
<Text style={styles.inputLabel}> ({images.length}/9)</Text>
<View style={styles.imageGrid}>
{images.map((img) => (
<View key={img.uri} style={styles.imageItem}>
<Image source={{ uri: img.uri.startsWith('http') ? img.uri : img.uri }} style={styles.imagePreview} />
{img.uploading && (
<View style={styles.uploadingOverlay}>
<Loading size="sm" />
</View>
)}
<Pressable style={styles.removeImageBtn} onPress={() => removeImage(img.uri)}>
<View key={img.id} style={styles.imageItem}>
<Image source={{ uri: getImageDisplayUri(img) }} style={styles.imagePreview} />
<Pressable style={styles.removeImageBtn} onPress={() => removeImage(img.id)}>
<MaterialCommunityIcons name="close" size={16} color="#fff" />
</Pressable>
</View>

View File

@@ -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<string, string>
): Promise<ApiResponse<T>> {
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<string, string> = {};
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<T>;
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<T>;
}
const data: ApiResponse<T> = await response.json();
if (data.code !== 0) {
throw new ApiError(data.code, data.message);
}

View File

@@ -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_MS60s一定视为过期丢弃
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 {

View File

@@ -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!);

View File

@@ -100,9 +100,14 @@ interface UseMessagesReturn {
*
* 核心修复:
* 1. useFocusEffect屏幕重新获得焦点时强制同步消息解决"需要退出重进才能看到新消息"的问题
* 2. 清理 isLoadingMessages组件卸载时清除残留的 loading 锁,防止下次进入被阻断
* 2. 引用计数清理 isLoadingMessages仅在最后一个订阅者卸载时清除 loading 锁,
* 避免 StrictMode 双调用或快速切换会话时,第一次 fetch 还没完成就被清锁
* 3. 重入时 forceSync相同 conversationId 重复进入时强制拉取最新消息
*/
// 记录每个 conversationId 上的活跃订阅数,仅在归零时清锁
const loadingLockRefCount: Map<string, number> = 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]);

View File

@@ -197,9 +197,10 @@ export class MessageSyncService implements IMessageSyncService {
/**
* 获取会话消息(增量同步)— 去重入口
* key 包含 force 维度,强制刷新会跳过非强制的 in-flight 合并
*/
async fetchMessages(conversationId: string, afterSeq?: number, force?: boolean): Promise<void> {
const key = `${conversationId}:${afterSeq ?? 'all'}`;
const key = `${conversationId}:${afterSeq ?? 'all'}:${force ? 'force' : 'normal'}`;
const existing = this.inflightFetches.get(key);
if (existing) return existing;

View File

@@ -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);
}
}
}

View File

@@ -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) };

View File

@@ -212,10 +212,13 @@ export class WSMessageHandler implements IWSMessageHandler {
/**
* 统一同步入口 — 节流 + 去重
* onConnect 和 sync_required 共用此入口,防止并发触发重复同步
* 启动阶段isBootstrapping=true跳过由 MessageManager.initialize 完成后统一触发
*/
private async triggerSync(source: string): Promise<void> {
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<void> {
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;
// 立即清空 bufferflush 期间新到的事件将走正常处理路径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 {

View File

@@ -88,6 +88,10 @@ export interface MessageActions {
removeMessage: (conversationId: string, messageId: string) => void;
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => 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<MessageStore>((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<MessageStore>((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: () => {

View File

@@ -0,0 +1,89 @@
/**
* 通用「待上传图片」抽象
*
* 用于发帖、评论、发布商品等场景:
* - 用户选择图片时仅保存本地 URIpending
* - 提交时统一上传,失败可重试已上传的不会丢失
*/
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,
});
/** 获取图片的展示 URIpending 用本地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<UploadAllResult> => {
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 };
};