Merge branch 'dev' of https://code.littlelan.cn/carrot_bbs/frontend into dev
This commit is contained in:
9
app.json
9
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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface ImageBlock {
|
||||
type: 'image';
|
||||
localUri: string;
|
||||
remoteUrl?: string;
|
||||
uploading: boolean;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 || ''
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 栏状态
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -24,7 +24,7 @@ export const UserScreen: React.FC = () => {
|
||||
<UserProfileScreen
|
||||
mode={isSelfProfile ? 'self' : 'other'}
|
||||
userId={isSelfProfile ? undefined : userId}
|
||||
hasHeader={false}
|
||||
hasHeader
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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;
|
||||
// 立即清空 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 {
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
89
src/utils/pendingImages.ts
Normal file
89
src/utils/pendingImages.ts
Normal file
@@ -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<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 };
|
||||
};
|
||||
Reference in New Issue
Block a user