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",
|
"./plugins/withHuaweiPush",
|
||||||
"expo-callkit-telecom",
|
"expo-callkit-telecom",
|
||||||
"expo-splash-screen",
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./assets/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
}
|
||||||
|
],
|
||||||
"expo-status-bar"
|
"expo-status-bar"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|||||||
@@ -50,6 +50,18 @@ export default function TabsLayout() {
|
|||||||
[isHomeStackRoute, pathname, triggerHomeTabPress, router]
|
[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(() => {
|
const tabBarStyle = useMemo(() => {
|
||||||
if (hideTabBar) {
|
if (hideTabBar) {
|
||||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||||
@@ -125,6 +137,9 @@ export default function TabsLayout() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
listeners={{
|
||||||
|
tabPress: handleAppsTabPress,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="messages"
|
name="messages"
|
||||||
@@ -152,6 +167,9 @@ export default function TabsLayout() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
listeners={{
|
||||||
|
tabPress: handleProfileTabPress,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export interface BlockEditorHandle {
|
|||||||
insertTextAtCursor: (text: string) => void;
|
insertTextAtCursor: (text: string) => void;
|
||||||
getSegments: () => MessageSegment[];
|
getSegments: () => MessageSegment[];
|
||||||
getContent: () => string;
|
getContent: () => string;
|
||||||
hasUploadingImages: () => boolean;
|
getBlocks: () => EditorBlock[];
|
||||||
|
uploadPendingImages: () => Promise<boolean>;
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,8 @@ const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
|
|||||||
insertTextAtCursor: editor.insertTextAtCursor,
|
insertTextAtCursor: editor.insertTextAtCursor,
|
||||||
getSegments: editor.getSegments,
|
getSegments: editor.getSegments,
|
||||||
getContent: editor.getContent,
|
getContent: editor.getContent,
|
||||||
hasUploadingImages: editor.hasUploadingImages,
|
getBlocks: editor.getBlocks,
|
||||||
|
uploadPendingImages: editor.uploadPendingImages,
|
||||||
focus: () => {
|
focus: () => {
|
||||||
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
|
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
|
||||||
if (firstTextBlock) {
|
if (firstTextBlock) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { Image as ExpoImage } from 'expo-image';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
@@ -41,11 +41,6 @@ const ImageBlockView: React.FC<ImageBlockViewProps> = ({ block, onRemove, style
|
|||||||
cachePolicy="memory-disk"
|
cachePolicy="memory-disk"
|
||||||
transition={200}
|
transition={200}
|
||||||
/>
|
/>
|
||||||
{block.uploading && (
|
|
||||||
<View style={styles.uploadingOverlay}>
|
|
||||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.removeButton}
|
style={styles.removeButton}
|
||||||
onPress={() => onRemove(block.id)}
|
onPress={() => onRemove(block.id)}
|
||||||
@@ -66,13 +61,6 @@ const styles = StyleSheet.create({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
uploadingOverlay: {
|
|
||||||
...StyleSheet.absoluteFill,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
},
|
|
||||||
removeButton: {
|
removeButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 8,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface ImageBlock {
|
|||||||
type: 'image';
|
type: 'image';
|
||||||
localUri: string;
|
localUri: string;
|
||||||
remoteUrl?: string;
|
remoteUrl?: string;
|
||||||
uploading: boolean;
|
mimeType?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] {
|
|||||||
type: 'image',
|
type: 'image',
|
||||||
localUri: seg.data.url,
|
localUri: seg.data.url,
|
||||||
remoteUrl: seg.data.url,
|
remoteUrl: seg.data.url,
|
||||||
uploading: false,
|
|
||||||
width: seg.data.width,
|
width: seg.data.width,
|
||||||
height: seg.data.height,
|
height: seg.data.height,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|||||||
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
|
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
|
||||||
const inputRefs = useRef<Map<string, TextInput>>(new Map());
|
const inputRefs = useRef<Map<string, TextInput>>(new Map());
|
||||||
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
|
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
|
||||||
|
const blocksRef = useRef<EditorBlock[]>(blocks);
|
||||||
|
blocksRef.current = blocks;
|
||||||
|
|
||||||
const updateTextBlock = useCallback(
|
const updateTextBlock = useCallback(
|
||||||
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
|
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
|
||||||
@@ -87,51 +89,47 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|||||||
});
|
});
|
||||||
}, [focusBlock]);
|
}, [focusBlock]);
|
||||||
|
|
||||||
const uploadImageByLocalUri = useCallback(
|
const uploadPendingImages = useCallback(async (): Promise<boolean> => {
|
||||||
async (asset: ImagePicker.ImagePickerAsset) => {
|
let allSuccess = true;
|
||||||
try {
|
|
||||||
const uploadResult = await uploadService.uploadImage({
|
|
||||||
uri: asset.uri,
|
|
||||||
type: asset.mimeType || 'image/jpeg',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadResult) {
|
const uploadBlock = async () => {
|
||||||
setBlocks(prev =>
|
const currentBlocks = blocksRef.current;
|
||||||
prev.map(b =>
|
const pendingBlocks = currentBlocks.filter(
|
||||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
(b): b is ImageBlock => b.type === 'image' && !b.remoteUrl,
|
||||||
? {
|
);
|
||||||
...b,
|
|
||||||
remoteUrl: uploadResult.url,
|
for (const block of pendingBlocks) {
|
||||||
uploading: false,
|
try {
|
||||||
width: uploadResult.width ?? b.width,
|
const uploadResult = await uploadService.uploadImage({
|
||||||
height: uploadResult.height ?? b.height,
|
uri: block.localUri,
|
||||||
}
|
type: block.mimeType || 'image/jpeg',
|
||||||
: b
|
});
|
||||||
)
|
|
||||||
);
|
if (uploadResult) {
|
||||||
} else {
|
setBlocks(prev =>
|
||||||
// Upload failed
|
prev.map(b =>
|
||||||
setBlocks(prev =>
|
b.id === block.id && b.type === 'image'
|
||||||
prev.map(b =>
|
? {
|
||||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
...b,
|
||||||
? { ...b, uploading: false }
|
remoteUrl: uploadResult.url,
|
||||||
: b
|
width: uploadResult.width ?? b.width,
|
||||||
)
|
height: uploadResult.height ?? b.height,
|
||||||
);
|
}
|
||||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
: 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(
|
const insertImagesFromAssets = useCallback(
|
||||||
(assets: ImagePicker.ImagePickerAsset[]) => {
|
(assets: ImagePicker.ImagePickerAsset[]) => {
|
||||||
@@ -152,13 +150,12 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|||||||
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
|
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Create image block
|
|
||||||
const imageBlockId = generateBlockId();
|
const imageBlockId = generateBlockId();
|
||||||
const imageBlock: ImageBlock = {
|
const imageBlock: ImageBlock = {
|
||||||
id: imageBlockId,
|
id: imageBlockId,
|
||||||
type: 'image',
|
type: 'image',
|
||||||
localUri: asset.uri,
|
localUri: asset.uri,
|
||||||
uploading: true,
|
mimeType: asset.mimeType,
|
||||||
width: asset.width,
|
width: asset.width,
|
||||||
height: asset.height,
|
height: asset.height,
|
||||||
};
|
};
|
||||||
@@ -169,7 +166,6 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|||||||
const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos);
|
const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos);
|
||||||
const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length);
|
const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length);
|
||||||
|
|
||||||
// Create new text block for text after cursor
|
|
||||||
const newTextBlockId = generateBlockId();
|
const newTextBlockId = generateBlockId();
|
||||||
const newTextBlock: TextBlock = {
|
const newTextBlock: TextBlock = {
|
||||||
id: newTextBlockId,
|
id: newTextBlockId,
|
||||||
@@ -178,24 +174,19 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|||||||
activeMentions: mentionsAfter,
|
activeMentions: mentionsAfter,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update current block to text before cursor
|
|
||||||
updated[insertAfterIndex] = {
|
updated[insertAfterIndex] = {
|
||||||
...activeBlock,
|
...activeBlock,
|
||||||
text: textBefore,
|
text: textBefore,
|
||||||
activeMentions: mentionsBefore,
|
activeMentions: mentionsBefore,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Insert image block and new text block
|
|
||||||
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
|
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
|
||||||
|
|
||||||
// Update index for next image insertion
|
|
||||||
insertAfterIndex = insertAfterIndex + 2;
|
insertAfterIndex = insertAfterIndex + 2;
|
||||||
|
|
||||||
// Focus the new text block after the image
|
|
||||||
setActiveBlockId(newTextBlockId);
|
setActiveBlockId(newTextBlockId);
|
||||||
requestAnimationFrame(() => focusBlock(newTextBlockId));
|
requestAnimationFrame(() => focusBlock(newTextBlockId));
|
||||||
} else {
|
} else {
|
||||||
// Insert after the current block (which is an image)
|
|
||||||
updated.splice(insertAfterIndex + 1, 0, imageBlock);
|
updated.splice(insertAfterIndex + 1, 0, imageBlock);
|
||||||
insertAfterIndex = insertAfterIndex + 1;
|
insertAfterIndex = insertAfterIndex + 1;
|
||||||
}
|
}
|
||||||
@@ -203,13 +194,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start async uploads
|
|
||||||
for (const asset of assets) {
|
|
||||||
uploadImageByLocalUri(asset);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[activeBlockId, focusBlock, uploadImageByLocalUri],
|
[activeBlockId, focusBlock],
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertImage = useCallback(async () => {
|
const insertImage = useCallback(async () => {
|
||||||
@@ -282,7 +268,7 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|||||||
|
|
||||||
const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]);
|
const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]);
|
||||||
const getContent = useCallback(() => blocksToContent(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) => {
|
const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => {
|
||||||
if (ref) {
|
if (ref) {
|
||||||
@@ -308,7 +294,8 @@ export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|||||||
focusBlock,
|
focusBlock,
|
||||||
getSegments,
|
getSegments,
|
||||||
getContent,
|
getContent,
|
||||||
hasUploadingImages,
|
getBlocks,
|
||||||
|
uploadPendingImages,
|
||||||
registerInputRef,
|
registerInputRef,
|
||||||
updateCursor,
|
updateCursor,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.gridNoImagePreview}>
|
<View style={styles.gridNoImagePreview}>
|
||||||
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -676,7 +676,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
|
|
||||||
{!!post.title && (
|
{!!post.title && (
|
||||||
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -748,7 +748,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
|
|
||||||
{!!post.title && (
|
{!!post.title && (
|
||||||
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -758,7 +758,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
style={styles.content}
|
style={styles.content}
|
||||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
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>
|
</Text>
|
||||||
{shouldTruncate && (
|
{shouldTruncate && (
|
||||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
{highlightKeyword && content ? (
|
{highlightKeyword && content ? (
|
||||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
<HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
|
||||||
) : (
|
) : (
|
||||||
content || ''
|
content || ''
|
||||||
)}
|
)}
|
||||||
@@ -85,7 +85,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
elements.push(
|
elements.push(
|
||||||
<Text key={key} selectable style={textStyle}>
|
<Text key={key} selectable style={textStyle}>
|
||||||
{highlightKeyword ? (
|
{highlightKeyword ? (
|
||||||
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
|
<HighlightText text={text} keyword={highlightKeyword} highlightStyle={highlightStyle} />
|
||||||
) : (
|
) : (
|
||||||
text
|
text
|
||||||
)}
|
)}
|
||||||
@@ -125,7 +125,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
{highlightKeyword && content ? (
|
{highlightKeyword && content ? (
|
||||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
<HighlightText text={content} keyword={highlightKeyword} highlightStyle={highlightStyle} />
|
||||||
) : (
|
) : (
|
||||||
content || ''
|
content || ''
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -551,7 +551,6 @@ function createUserProfileHeaderStyles(colors: AppColors) {
|
|||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
...shadows.lg,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: colors.divider,
|
borderColor: colors.divider,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
|||||||
@@ -38,15 +38,26 @@ import {
|
|||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text, ResponsiveContainer } from '../../components/common';
|
||||||
import { channelService, postService, showPrompt, voteService } from '../../services';
|
import { channelService, postService, showPrompt, voteService } from '../../services';
|
||||||
import { ApiError } from '@/services/core';
|
import { ApiError } from '@/services/core';
|
||||||
import { uploadService } from '@/services/upload';
|
|
||||||
import VoteEditor from '../../components/business/VoteEditor';
|
import VoteEditor from '../../components/business/VoteEditor';
|
||||||
import PostMentionInput from '../../components/business/PostMentionInput';
|
import PostMentionInput from '../../components/business/PostMentionInput';
|
||||||
import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor';
|
import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor';
|
||||||
import { segmentsToBlocks } from '../../components/business/BlockEditor/blocksToSegments';
|
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 { useResponsive, useResponsiveValue } from '../../hooks';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { MessageSegment } from '../../types';
|
import { MessageSegment } from '../../types';
|
||||||
|
import {
|
||||||
|
type PendingOrRemoteImage,
|
||||||
|
type RemoteImage,
|
||||||
|
generatePendingImageId,
|
||||||
|
makePendingImageFromAsset,
|
||||||
|
getImageDisplayUri,
|
||||||
|
uploadAllPendingImages,
|
||||||
|
} from '../../utils/pendingImages';
|
||||||
|
|
||||||
// Props 接口
|
// Props 接口
|
||||||
interface CreatePostScreenProps {
|
interface CreatePostScreenProps {
|
||||||
@@ -130,7 +141,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [segments, setSegments] = useState<MessageSegment[]>([]);
|
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 [channelOptions, setChannelOptions] = useState<ChannelOption[]>([]);
|
||||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
|
||||||
const [showChannelPicker, setShowChannelPicker] = useState(false);
|
const [showChannelPicker, setShowChannelPicker] = useState(false);
|
||||||
@@ -213,7 +225,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
}
|
}
|
||||||
setTitle(existingPost.title || '');
|
setTitle(existingPost.title || '');
|
||||||
setContent(existingPost.content || '');
|
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 中包含图片块即为长文帖子
|
// 自动判断长文模式:segments 中包含图片块即为长文帖子
|
||||||
const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image');
|
const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image');
|
||||||
@@ -233,7 +245,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
loadPostForEdit();
|
loadPostForEdit();
|
||||||
}, [isEditMode, editPostID, router]);
|
}, [isEditMode, editPostID, router]);
|
||||||
|
|
||||||
// 选择图片(普通模式)
|
// 选择图片(普通模式)—— 仅选择,不立即上传
|
||||||
const handlePickImage = async () => {
|
const handlePickImage = async () => {
|
||||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
|
||||||
@@ -250,35 +262,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.canceled && result.assets) {
|
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]);
|
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 handleTakePhoto = async () => {
|
||||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
|
||||||
@@ -294,33 +283,13 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
|
|
||||||
if (!result.canceled && result.assets[0]) {
|
if (!result.canceled && result.assets[0]) {
|
||||||
const asset = result.assets[0];
|
const asset = result.assets[0];
|
||||||
setImages(prev => [...prev, { uri: asset.uri, uploading: true }]);
|
setImages(prev => [...prev, makePendingImageFromAsset(asset)]);
|
||||||
|
|
||||||
// 上传图片
|
|
||||||
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 handleRemoveImage = (index: number) => {
|
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(() => {
|
const handleToggleLongPostMode = useCallback(() => {
|
||||||
if (!isLongPostMode) {
|
if (!isLongPostMode) {
|
||||||
// 非长文 -> 长文:将当前文本和图片转为 blocks
|
// 非长文 -> 长文:将当前文本和图片(包括 pending)转为 blocks
|
||||||
const segs: MessageSegment[] = [];
|
const blocks: EditorBlock[] = [];
|
||||||
if (content.trim()) {
|
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) {
|
for (const img of images) {
|
||||||
if (img.uri && !img.uploading) {
|
const imageBlock: ImageBlock = img.kind === 'remote'
|
||||||
segs.push({ type: 'image', data: { url: img.uri } });
|
? { 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);
|
setImages(editorImages);
|
||||||
} 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);
|
|
||||||
}
|
}
|
||||||
setIsLongPostMode(prev => !prev);
|
setIsLongPostMode(prev => !prev);
|
||||||
}, [isLongPostMode, content, images]);
|
}, [isLongPostMode, content, images]);
|
||||||
@@ -436,25 +423,37 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有图片正在上传
|
// 检查 BlockEditor 中是否有正在上传的图片
|
||||||
if (isLongPostMode) {
|
// (已无此状态,发布时会统一上传,无需检查)
|
||||||
if (blockEditorRef.current?.hasUploadingImages()) {
|
|
||||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const uploadingImages = images.filter(img => img.uploading);
|
|
||||||
if (uploadingImages.length > 0) {
|
|
||||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPosting(true);
|
setPosting(true);
|
||||||
|
|
||||||
try {
|
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) {
|
if (isVotePost) {
|
||||||
const imageUrls = images.map(img => img.uri);
|
|
||||||
// 验证投票选项
|
// 验证投票选项
|
||||||
const validOptions = voteOptions.filter(opt => opt.trim() !== '');
|
const validOptions = voteOptions.filter(opt => opt.trim() !== '');
|
||||||
if (validOptions.length < 2) {
|
if (validOptions.length < 2) {
|
||||||
@@ -467,7 +466,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
await voteService.createVotePost({
|
await voteService.createVotePost({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||||
channel_id: selectedChannelId || undefined,
|
channel_id: selectedChannelId || undefined,
|
||||||
vote_options: validOptions.map(opt => ({ content: opt })),
|
vote_options: validOptions.map(opt => ({ content: opt })),
|
||||||
});
|
});
|
||||||
@@ -506,13 +505,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 普通模式
|
// 普通模式
|
||||||
const imageUrls = images.map(img => img.uri);
|
|
||||||
if (isEditMode && editPostID) {
|
if (isEditMode && editPostID) {
|
||||||
const updated = await postService.updatePost(editPostID, {
|
const updated = await postService.updatePost(editPostID, {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
||||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined,
|
||||||
});
|
});
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new Error('更新帖子失败');
|
throw new Error('更新帖子失败');
|
||||||
@@ -524,7 +522,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
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,
|
channel_id: selectedChannelId || undefined,
|
||||||
});
|
});
|
||||||
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||||
@@ -547,27 +545,25 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
<View style={styles.imageGrid}>
|
<View style={styles.imageGrid}>
|
||||||
{images.map((img, index) => (
|
{images.map((img, index) => (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key={`${img.uri}-${index}`}
|
key={img.id}
|
||||||
style={[
|
style={[
|
||||||
styles.imageGridItem,
|
styles.imageGridItem,
|
||||||
{
|
{
|
||||||
width: imageSize,
|
width: imageSize,
|
||||||
height: imageSize,
|
height: imageSize,
|
||||||
opacity: fadeAnim,
|
opacity: fadeAnim,
|
||||||
transform: [{ scale: fadeAnim }]
|
transform: [{ scale: fadeAnim }]
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Image source={{ uri: img.uri }} style={styles.gridImage} resizeMode="cover" />
|
<Image
|
||||||
{img.uploading && (
|
source={{ uri: getImageDisplayUri(img) }}
|
||||||
<View style={styles.uploadingOverlay}>
|
style={styles.gridImage}
|
||||||
<MaterialCommunityIcons name="loading" size={20} color={colors.text.inverse} />
|
resizeMode="cover"
|
||||||
</View>
|
/>
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.removeImageButton}
|
style={styles.removeImageButton}
|
||||||
onPress={() => handleRemoveImage(index)}
|
onPress={() => handleRemoveImage(index)}
|
||||||
disabled={img.uploading}
|
|
||||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||||
>
|
>
|
||||||
<View style={styles.removeImageButtonInner}>
|
<View style={styles.removeImageButtonInner}>
|
||||||
@@ -1044,12 +1040,6 @@ function createCreatePostStyles(colors: AppColors) {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
uploadingOverlay: {
|
|
||||||
...StyleSheet.absoluteFill,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
removeImageButton: {
|
removeImageButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 4,
|
top: 4,
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (homeTabPressCount > 0) {
|
if (homeTabPressCount > 0) {
|
||||||
// 滚动 FlashList 到顶部
|
// 滚动 FlashList 到顶部
|
||||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||||
// 滚动 ScrollView 到顶部(网格模式)
|
// 滚动 ScrollView 到顶部(网格模式)
|
||||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||||
// 重置底部 Tab 栏状态
|
// 重置底部 Tab 栏状态
|
||||||
|
|||||||
@@ -38,12 +38,18 @@ import {
|
|||||||
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
|
import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types';
|
||||||
import { useUserStore } from '../../stores';
|
import { useUserStore } from '../../stores';
|
||||||
import { useCurrentUser } from '../../stores/auth';
|
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 { postSyncService } from '@/services/post';
|
||||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
|
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 { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
|
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
|
||||||
|
import {
|
||||||
|
type PendingOrRemoteImage,
|
||||||
|
makePendingImageFromAsset,
|
||||||
|
getImageDisplayUri,
|
||||||
|
uploadAllPendingImages,
|
||||||
|
} from '../../utils/pendingImages';
|
||||||
import { handleError } from '@/services/core';
|
import { handleError } from '@/services/core';
|
||||||
import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime';
|
import { formatDateTime as formatDateTimeUtil, formatRelativeTime as formatRelativeTimeUtil } from '@/utils/formatTime';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
@@ -251,7 +257,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
// 评论图片相关状态
|
// 评论图片相关状态
|
||||||
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
const [commentImages, setCommentImages] = useState<PendingOrRemoteImage[]>([]);
|
||||||
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||||
const [isFollowing, setIsFollowing] = useState(false);
|
const [isFollowing, setIsFollowing] = useState(false);
|
||||||
const [isFollowingMe, setIsFollowingMe] = useState(false);
|
const [isFollowingMe, setIsFollowingMe] = useState(false);
|
||||||
@@ -688,31 +694,8 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.canceled && result.assets) {
|
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]);
|
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;
|
const hasContent = commentText.trim() || commentImages.length > 0;
|
||||||
if (!hasContent || !post) return;
|
if (!hasContent || !post) return;
|
||||||
|
|
||||||
// 检查是否有图片正在上传
|
// 上传所有 pending 图片
|
||||||
const uploadingImages = commentImages.filter(img => img.uploading);
|
let uploadedImageUrls: string[] = [];
|
||||||
if (uploadingImages.length > 0) {
|
if (commentImages.length > 0) {
|
||||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
const uploadResult = await uploadAllPendingImages(commentImages, (id, url) => {
|
||||||
return;
|
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()}`;
|
const tempId = `new-${Date.now()}`;
|
||||||
@@ -741,9 +730,6 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
// 获取根评论 ID:如果被回复评论有 root_id,则使用 root_id;否则使用 parent_id(即被回复的是顶级评论)
|
// 获取根评论 ID:如果被回复评论有 root_id,则使用 root_id;否则使用 parent_id(即被回复的是顶级评论)
|
||||||
const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null;
|
const rootId = isReply ? (replyingTo.root_id || replyingTo.id) : null;
|
||||||
|
|
||||||
// 获取已上传图片的URL列表
|
|
||||||
const uploadedImageUrls = commentImages.filter(img => !img.uploading).map(img => img.uri);
|
|
||||||
|
|
||||||
// 先创建临时评论显示在列表中
|
// 先创建临时评论显示在列表中
|
||||||
const tempComment: Comment = {
|
const tempComment: Comment = {
|
||||||
id: tempId,
|
id: tempId,
|
||||||
@@ -1501,13 +1487,12 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
{commentImages.length > 0 && (
|
{commentImages.length > 0 && (
|
||||||
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
|
<ScrollView horizontal style={styles.commentImagesPreview} showsHorizontalScrollIndicator={false}>
|
||||||
{commentImages.map((image, index) => (
|
{commentImages.map((image, index) => (
|
||||||
<View key={index} style={styles.commentImageWrapper}>
|
<View key={image.id} style={styles.commentImageWrapper}>
|
||||||
<Image source={{ uri: image.uri }} style={styles.commentImageThumbnail} resizeMode="cover" />
|
<Image
|
||||||
{image.uploading && (
|
source={{ uri: getImageDisplayUri(image) }}
|
||||||
<View style={styles.uploadingOverlay}>
|
style={styles.commentImageThumbnail}
|
||||||
<Loading size="sm" />
|
resizeMode="cover"
|
||||||
</View>
|
/>
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.removeImageButton}
|
style={styles.removeImageButton}
|
||||||
onPress={() => handleRemoveCommentImage(index)}
|
onPress={() => handleRemoveCommentImage(index)}
|
||||||
@@ -2185,17 +2170,6 @@ function createPostDetailStyles(colors: AppColors) {
|
|||||||
height: 60,
|
height: 60,
|
||||||
borderRadius: borderRadius.sm,
|
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: {
|
removeImageButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -6,
|
top: -6,
|
||||||
|
|||||||
@@ -208,11 +208,6 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
|||||||
backgroundColor: 'rgba(255, 255, 255, 0.96)',
|
backgroundColor: 'rgba(255, 255, 255, 0.96)',
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: 'rgba(0, 0, 0, 0.08)',
|
borderColor: 'rgba(0, 0, 0, 0.08)',
|
||||||
shadowColor: colors.chat.shadow,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.12,
|
|
||||||
shadowRadius: 6,
|
|
||||||
elevation: 4,
|
|
||||||
},
|
},
|
||||||
jumpToLatestFabText: {
|
jumpToLatestFabText: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|||||||
@@ -1104,7 +1104,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const segments: MessageSegment[] = [...buildTextSegments(trimmedText, replyingTo)];
|
// 有文本或回复时才构建文本段,纯图片时不塞空 text
|
||||||
|
const segments: MessageSegment[] = (trimmedText || replyingTo)
|
||||||
|
? [...buildTextSegments(trimmedText, replyingTo)]
|
||||||
|
: [];
|
||||||
for (const url of uploadedUrls) {
|
for (const url of uploadedUrls) {
|
||||||
segments.push({
|
segments.push({
|
||||||
type: 'image',
|
type: 'image',
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const UserScreen: React.FC = () => {
|
|||||||
<UserProfileScreen
|
<UserProfileScreen
|
||||||
mode={isSelfProfile ? 'self' : 'other'}
|
mode={isSelfProfile ? 'self' : 'other'}
|
||||||
userId={isSelfProfile ? undefined : userId}
|
userId={isSelfProfile ? undefined : userId}
|
||||||
hasHeader={false}
|
hasHeader
|
||||||
/>
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,15 +24,21 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
import { Loading } from '../../components/common';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
import { useAppColors, type AppColors } from '../../theme';
|
||||||
import { Text } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import { tradeService } from '../../services/trade/tradeService';
|
import { tradeService } from '../../services/trade/tradeService';
|
||||||
import { uploadService } from '../../services';
|
|
||||||
import type { TradeType, TradeCategory, TradeCondition, TradeItemDetailDTO } from '../../types/trade';
|
import type { TradeType, TradeCategory, TradeCondition, TradeItemDetailDTO } from '../../types/trade';
|
||||||
import { ALL_TRADE_CATEGORIES, ALL_TRADE_CONDITIONS } 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 {
|
interface CreateTradeScreenProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -258,13 +264,6 @@ function createStyles(colors: AppColors) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
uploadingOverlay: {
|
|
||||||
...StyleSheet.absoluteFill,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 14,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 提交按钮
|
// 提交按钮
|
||||||
submitBtn: {
|
submitBtn: {
|
||||||
@@ -323,8 +322,8 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS
|
|||||||
const [condition, setCondition] = useState<TradeCondition | undefined>(
|
const [condition, setCondition] = useState<TradeCondition | undefined>(
|
||||||
editItem?.condition as TradeCondition | undefined
|
editItem?.condition as TradeCondition | undefined
|
||||||
);
|
);
|
||||||
const [images, setImages] = useState<{ uri: string; uploading: boolean }[]>(
|
const [images, setImages] = useState<PendingOrRemoteImage[]>(
|
||||||
editItem?.images?.map(img => ({ uri: img.url, uploading: false })) || []
|
editItem?.images?.map((img): RemoteImage => ({ id: generatePendingImageId('ti'), kind: 'remote', url: img.url })) || []
|
||||||
);
|
);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -352,19 +351,29 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS
|
|||||||
Alert.alert('提示', '请输入标题');
|
Alert.alert('提示', '请输入标题');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (images.some(img => img.uploading)) {
|
|
||||||
Alert.alert('提示', '请等待图片上传完成');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
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 = {
|
const payload = {
|
||||||
trade_type: tradeType,
|
trade_type: tradeType,
|
||||||
category,
|
category,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim() || title.trim(),
|
content: content.trim() || title.trim(),
|
||||||
images: images.map(img => img.uri),
|
images: uploadedImageUrls,
|
||||||
price: price ? parseFloat(price) : null,
|
price: price ? parseFloat(price) : null,
|
||||||
condition,
|
condition,
|
||||||
};
|
};
|
||||||
@@ -403,37 +412,16 @@ export function CreateTradeScreen({ onClose, onSuccess, editItem }: CreateTradeS
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result.canceled && result.assets) {
|
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]);
|
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]);
|
}, [images.length]);
|
||||||
|
|
||||||
const removeImage = useCallback((uri: string) => {
|
const removeImage = useCallback((id: string) => {
|
||||||
setImages(prev => prev.filter(img => img.uri !== uri));
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<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>
|
<Text style={styles.inputLabel}>图片 ({images.length}/9)</Text>
|
||||||
<View style={styles.imageGrid}>
|
<View style={styles.imageGrid}>
|
||||||
{images.map((img) => (
|
{images.map((img) => (
|
||||||
<View key={img.uri} style={styles.imageItem}>
|
<View key={img.id} style={styles.imageItem}>
|
||||||
<Image source={{ uri: img.uri.startsWith('http') ? img.uri : img.uri }} style={styles.imagePreview} />
|
<Image source={{ uri: getImageDisplayUri(img) }} style={styles.imagePreview} />
|
||||||
{img.uploading && (
|
<Pressable style={styles.removeImageBtn} onPress={() => removeImage(img.id)}>
|
||||||
<View style={styles.uploadingOverlay}>
|
|
||||||
<Loading size="sm" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<Pressable style={styles.removeImageBtn} onPress={() => removeImage(img.uri)}>
|
|
||||||
<MaterialCommunityIcons name="close" size={16} color="#fff" />
|
<MaterialCommunityIcons name="close" size={16} color="#fff" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
|
import { File as FSFile, UploadType } from 'expo-file-system';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
import { eventBus } from '@/core/events/EventBus';
|
import { eventBus } from '@/core/events/EventBus';
|
||||||
@@ -437,35 +438,10 @@ class ApiClient {
|
|||||||
file: { uri: string; name: string; type: string },
|
file: { uri: string; name: string; type: string },
|
||||||
additionalData?: Record<string, string>
|
additionalData?: Record<string, string>
|
||||||
): Promise<ApiResponse<T>> {
|
): 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 并检查是否需要刷新
|
// 获取 token 并检查是否需要刷新
|
||||||
const token = await this.getToken();
|
const token = await this.getToken();
|
||||||
const headers: HeadersInit = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) {
|
if (token) {
|
||||||
// 检查 token 是否快过期
|
|
||||||
if (this.isTokenExpiringSoon(token)) {
|
if (this.isTokenExpiringSoon(token)) {
|
||||||
const refreshed = await this.refreshToken();
|
const refreshed = await this.refreshToken();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
@@ -479,14 +455,45 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
let data: ApiResponse<T>;
|
||||||
method: 'POST',
|
|
||||||
headers,
|
if (Platform.OS === 'web') {
|
||||||
body: formData,
|
// 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) {
|
if (data.code !== 0) {
|
||||||
throw new ApiError(data.code, data.message);
|
throw new ApiError(data.code, data.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ class WebSocketService {
|
|||||||
this.markActivity();
|
this.markActivity();
|
||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
this.connectionHandlers.forEach(h => h());
|
this.connectionHandlers.forEach(h => h());
|
||||||
|
|
||||||
// 发送队列中的消息
|
// 发送队列中的消息
|
||||||
this.flushPendingMessages();
|
this.flushPendingMessages();
|
||||||
};
|
};
|
||||||
@@ -776,10 +776,19 @@ class WebSocketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMessageSent(payload: any): void {
|
private handleMessageSent(payload: any): void {
|
||||||
// 找到对应的发送请求并resolve
|
// 使用后端回带的 client_msg_id 精确匹配单条消息
|
||||||
const pendingMsg = this.pendingMessages.find(p =>
|
// 避免同会话连续发送时,第一条 ACK 误清掉第二条的 pending 记录
|
||||||
p.type === 'chat' && p.payload.conversation_id === payload.conversation_id
|
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) {
|
if (pendingMsg) {
|
||||||
pendingMsg.resolve(payload);
|
pendingMsg.resolve(payload);
|
||||||
this.removePendingMessage(pendingMsg.id);
|
this.removePendingMessage(pendingMsg.id);
|
||||||
@@ -787,9 +796,16 @@ class WebSocketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMessageRecalled(payload: any): void {
|
private handleMessageRecalled(payload: any): void {
|
||||||
const pendingMsg = this.pendingMessages.find(p =>
|
const clientId = payload?.client_msg_id;
|
||||||
p.type === 'recall' && p.payload.message_id === payload.message_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) {
|
if (pendingMsg) {
|
||||||
pendingMsg.resolve(payload);
|
pendingMsg.resolve(payload);
|
||||||
this.removePendingMessage(pendingMsg.id);
|
this.removePendingMessage(pendingMsg.id);
|
||||||
@@ -880,16 +896,18 @@ class WebSocketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messageId = `msg_${++this.messageIdCounter}_${Date.now()}`;
|
const messageId = `msg_${++this.messageIdCounter}_${Date.now()}`;
|
||||||
|
// 携带 client_msg_id:用于服务器回 ACK / message_sent 时精确匹配单条消息
|
||||||
|
const payloadWithId = { ...payload, client_msg_id: messageId };
|
||||||
const message = {
|
const message = {
|
||||||
type,
|
type,
|
||||||
payload,
|
payload: payloadWithId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加到待处理队列
|
// 添加到待处理队列
|
||||||
const pendingMsg: PendingMessage = {
|
const pendingMsg: PendingMessage = {
|
||||||
id: messageId,
|
id: messageId,
|
||||||
type,
|
type,
|
||||||
payload,
|
payload: payloadWithId,
|
||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
timestamp: Date.now(),
|
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 {
|
private flushPendingMessages(): void {
|
||||||
if (this.pendingMessages.length === 0) return;
|
if (this.pendingMessages.length === 0) return;
|
||||||
|
|
||||||
@@ -938,7 +961,9 @@ class WebSocketService {
|
|||||||
|
|
||||||
// 分离过期和有效的消息
|
// 分离过期和有效的消息
|
||||||
for (const msg of this.pendingMessages) {
|
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);
|
expiredMessages.push(msg);
|
||||||
} else {
|
} else {
|
||||||
validMessages.push(msg);
|
validMessages.push(msg);
|
||||||
@@ -950,13 +975,32 @@ class WebSocketService {
|
|||||||
msg.reject(new Error('Message expired'));
|
msg.reject(new Error('Message expired'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// 重新发送有效消息
|
// 重新发送有效消息:直接复用原 client_msg_id,否则重连后服务端 ACK 无法回带到原 pending 记录
|
||||||
this.pendingMessages = [];
|
// 注意:不能调 sendViaWebSocket(会重新生成 id 并入队),必须直接 ws.send
|
||||||
validMessages.forEach(msg => {
|
this.pendingMessages = validMessages;
|
||||||
this.sendViaWebSocket(msg.type, msg.payload)
|
for (let i = 0; i < validMessages.length; i++) {
|
||||||
.then(msg.resolve)
|
const msg = validMessages[i];
|
||||||
.catch(msg.reject);
|
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 {
|
private removePendingMessage(id: string): void {
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ class MessageManager {
|
|||||||
|
|
||||||
useMessageStore.getState().setInitialized(true);
|
useMessageStore.getState().setInitialized(true);
|
||||||
|
|
||||||
// 处理缓冲的 SSE 事件
|
// 先关闭 bootstrap(事件流转入正常处理路径),再做尾部 flush
|
||||||
await this.wsHandler.flushBufferedSSEEvents();
|
// 避免 flush 期间新到的事件被压入 buffer 后无人消费
|
||||||
|
|
||||||
this.wsHandler.setBootstrapping(false);
|
this.wsHandler.setBootstrapping(false);
|
||||||
|
await this.wsHandler.flushBufferedSSEEvents();
|
||||||
|
|
||||||
if (useMessageStore.getState().currentConversationId) {
|
if (useMessageStore.getState().currentConversationId) {
|
||||||
await this.fetchMessages(useMessageStore.getState().currentConversationId!);
|
await this.fetchMessages(useMessageStore.getState().currentConversationId!);
|
||||||
|
|||||||
@@ -100,9 +100,14 @@ interface UseMessagesReturn {
|
|||||||
*
|
*
|
||||||
* 核心修复:
|
* 核心修复:
|
||||||
* 1. useFocusEffect:屏幕重新获得焦点时强制同步消息,解决"需要退出重进才能看到新消息"的问题
|
* 1. useFocusEffect:屏幕重新获得焦点时强制同步消息,解决"需要退出重进才能看到新消息"的问题
|
||||||
* 2. 清理 isLoadingMessages:组件卸载时清除残留的 loading 锁,防止下次进入被阻断
|
* 2. 引用计数清理 isLoadingMessages:仅在最后一个订阅者卸载时清除 loading 锁,
|
||||||
|
* 避免 StrictMode 双调用或快速切换会话时,第一次 fetch 还没完成就被清锁
|
||||||
* 3. 重入时 forceSync:相同 conversationId 重复进入时强制拉取最新消息
|
* 3. 重入时 forceSync:相同 conversationId 重复进入时强制拉取最新消息
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 记录每个 conversationId 上的活跃订阅数,仅在归零时清锁
|
||||||
|
const loadingLockRefCount: Map<string, number> = new Map();
|
||||||
|
|
||||||
export function useMessages(conversationId: string | null): UseMessagesReturn {
|
export function useMessages(conversationId: string | null): UseMessagesReturn {
|
||||||
const normalizedConversationId = conversationId ? String(conversationId) : null;
|
const normalizedConversationId = conversationId ? String(conversationId) : null;
|
||||||
|
|
||||||
@@ -128,6 +133,10 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 引用计数 +1
|
||||||
|
const prevCount = loadingLockRefCount.get(normalizedConversationId) ?? 0;
|
||||||
|
loadingLockRefCount.set(normalizedConversationId, prevCount + 1);
|
||||||
|
|
||||||
lastActivatedAtRef.current = Date.now();
|
lastActivatedAtRef.current = Date.now();
|
||||||
|
|
||||||
const isReentry = lastActivatedIdRef.current === normalizedConversationId;
|
const isReentry = lastActivatedIdRef.current === normalizedConversationId;
|
||||||
@@ -141,11 +150,17 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
|
|||||||
lastActivatedIdRef.current = normalizedConversationId;
|
lastActivatedIdRef.current = normalizedConversationId;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// 清理活动会话 + 清除残留的 loading 锁
|
// 引用计数 -1,仅在最后一个订阅者卸载时清活动会话 + 清 loading 锁
|
||||||
if (messageManager.getActiveConversation() === normalizedConversationId) {
|
const nextCount = (loadingLockRefCount.get(normalizedConversationId) ?? 1) - 1;
|
||||||
messageManager.setActiveConversation(null);
|
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]);
|
}, [normalizedConversationId]);
|
||||||
|
|
||||||
|
|||||||
@@ -197,9 +197,10 @@ export class MessageSyncService implements IMessageSyncService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取会话消息(增量同步)— 去重入口
|
* 获取会话消息(增量同步)— 去重入口
|
||||||
|
* key 包含 force 维度,强制刷新会跳过非强制的 in-flight 合并
|
||||||
*/
|
*/
|
||||||
async fetchMessages(conversationId: string, afterSeq?: number, force?: boolean): Promise<void> {
|
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);
|
const existing = this.inflightFetches.get(key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
const normalizedId = normalizeConversationId(conversationId);
|
const normalizedId = normalizeConversationId(conversationId);
|
||||||
const store = useMessageStore.getState();
|
const store = useMessageStore.getState();
|
||||||
const conversation = store.getConversation(normalizedId);
|
const conversation = store.getConversation(normalizedId);
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
console.warn('[ReadReceiptManager] 会话不存在:', normalizedId);
|
console.warn('[ReadReceiptManager] 会话不存在:', normalizedId);
|
||||||
return;
|
return;
|
||||||
@@ -53,6 +53,9 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
this.readStateVersion++;
|
this.readStateVersion++;
|
||||||
const currentVersion = this.readStateVersion;
|
const currentVersion = this.readStateVersion;
|
||||||
|
|
||||||
|
// 记录本次写入的 my_last_read_seq,用于失败时条件回滚
|
||||||
|
const writtenReadSeq = Math.max(Number(conversation.my_last_read_seq || 0), seq);
|
||||||
|
|
||||||
// 1. 标记此会话有进行中的已读请求
|
// 1. 标记此会话有进行中的已读请求
|
||||||
this.pendingReadMap.set(normalizedId, {
|
this.pendingReadMap.set(normalizedId, {
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -64,7 +67,7 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
const updatedConv: ConversationResponse = {
|
const updatedConv: ConversationResponse = {
|
||||||
...conversation,
|
...conversation,
|
||||||
unread_count: 0,
|
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();
|
const currentUnread = store.getUnreadCount();
|
||||||
@@ -79,11 +82,11 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
await messageService.markAsRead(normalizedId, seq);
|
await messageService.markAsRead(normalizedId, seq);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ReadReceiptManager] 标记已读API失败:', error);
|
console.error('[ReadReceiptManager] 标记已读API失败:', error);
|
||||||
|
|
||||||
// 失败时回滚状态
|
// 条件回滚(含 totalUnreadCount):仅在 my_last_read_seq 仍是本次写入值时回滚,
|
||||||
store.updateConversation(conversation);
|
// 避免覆盖并发 markAsRead 已写入的更大读游标或更小未读
|
||||||
store.setUnreadCount(currentUnread.total, currentUnread.system);
|
store.rollbackReadIfUnchanged(normalizedId, writtenReadSeq, prevUnreadCount);
|
||||||
|
|
||||||
// API 失败时立即清除保护
|
// API 失败时立即清除保护
|
||||||
this.pendingReadMap.delete(normalizedId);
|
this.pendingReadMap.delete(normalizedId);
|
||||||
return;
|
return;
|
||||||
@@ -137,6 +140,9 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
});
|
});
|
||||||
store.setConversationsWithUnread(newConversations, 0, currentSystem);
|
store.setConversationsWithUnread(newConversations, 0, currentSystem);
|
||||||
|
|
||||||
|
// 记录本次乐观写入的"目标快照",用于失败时条件回滚
|
||||||
|
const optimisticSnapshot = new Map(newConversations);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 标记系统消息已读
|
// 标记系统消息已读
|
||||||
await messageService.markAllSystemMessagesRead();
|
await messageService.markAllSystemMessagesRead();
|
||||||
@@ -155,8 +161,22 @@ export class ReadReceiptManager implements IReadReceiptManager {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ReadReceiptManager] 标记所有已读失败:', error);
|
console.error('[ReadReceiptManager] 标记所有已读失败:', error);
|
||||||
// 回滚:恢复原始会话和未读数
|
// 条件回滚:仅在 conversations 的关键字段未被其他操作修改时还原,
|
||||||
store.setConversationsWithUnread(new Map(conversationsMap), prevTotalUnread, currentSystem);
|
// 避免覆盖期间到达的 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 type { MessageResponse, UserDTO } from '../../../types/dto';
|
||||||
import { api } from '@/services/core';
|
import { api } from '@/services/core';
|
||||||
import { userCacheRepository } from '@/database';
|
import { userCacheRepository } from '@/database';
|
||||||
|
import { useMessageStore } from '../store';
|
||||||
import type { IUserCacheService } from '../types';
|
import type { IUserCacheService } from '../types';
|
||||||
|
|
||||||
export class UserCacheService implements IUserCacheService {
|
export class UserCacheService implements IUserCacheService {
|
||||||
@@ -61,16 +62,20 @@ export class UserCacheService implements IUserCacheService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量异步填充消息的 sender 信息
|
* 批量异步填充消息的 sender 信息
|
||||||
* 填充完成后调用 notifyUpdate 更新内存并通知订阅者
|
* 注意:fetch 期间新消息可能已到达,回调内必须重读最新 messages 列表,
|
||||||
|
* 否则新消息的 sender 字段不会被填充。
|
||||||
*/
|
*/
|
||||||
enrichMessagesWithSenderInfo(
|
enrichMessagesWithSenderInfo(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messages: MessageResponse[],
|
_messages: MessageResponse[],
|
||||||
notifyUpdate: (conversationId: string, messages: MessageResponse[]) => void
|
notifyUpdate: (conversationId: string, messages: MessageResponse[]) => void
|
||||||
): void {
|
): void {
|
||||||
|
// 取一次最新列表作为本次填充目标
|
||||||
|
const latestMessages = useMessageStore.getState().getMessages(conversationId);
|
||||||
|
|
||||||
// 收集所有需要查询的唯一 sender_id(排除系统用户)
|
// 收集所有需要查询的唯一 sender_id(排除系统用户)
|
||||||
const senderIds = [...new Set(
|
const senderIds = [...new Set(
|
||||||
messages
|
latestMessages
|
||||||
.filter(m => m.sender_id && m.sender_id !== '10000' && !m.sender)
|
.filter(m => m.sender_id && m.sender_id !== '10000' && !m.sender)
|
||||||
.map(m => m.sender_id)
|
.map(m => m.sender_id)
|
||||||
)];
|
)];
|
||||||
@@ -87,8 +92,10 @@ export class UserCacheService implements IUserCacheService {
|
|||||||
|
|
||||||
if (senderMap.size === 0) return;
|
if (senderMap.size === 0) return;
|
||||||
|
|
||||||
|
// 重新读取最新消息列表(期间可能又有新消息到达)
|
||||||
|
const currentMessages = useMessageStore.getState().getMessages(conversationId);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const updated = messages.map(m => {
|
const updated = currentMessages.map(m => {
|
||||||
if (!m.sender && m.sender_id && senderMap.has(m.sender_id)) {
|
if (!m.sender && m.sender_id && senderMap.has(m.sender_id)) {
|
||||||
changed = true;
|
changed = true;
|
||||||
return { ...m, sender: senderMap.get(m.sender_id) };
|
return { ...m, sender: senderMap.get(m.sender_id) };
|
||||||
|
|||||||
@@ -212,10 +212,13 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
/**
|
/**
|
||||||
* 统一同步入口 — 节流 + 去重
|
* 统一同步入口 — 节流 + 去重
|
||||||
* onConnect 和 sync_required 共用此入口,防止并发触发重复同步
|
* onConnect 和 sync_required 共用此入口,防止并发触发重复同步
|
||||||
|
* 启动阶段(isBootstrapping=true)跳过,由 MessageManager.initialize 完成后统一触发
|
||||||
*/
|
*/
|
||||||
private async triggerSync(source: string): Promise<void> {
|
private async triggerSync(source: string): Promise<void> {
|
||||||
|
if (this.isBootstrapping) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// 最小间隔节流
|
// 最小间隔节流:避免连续 sync_required 事件短时间内重复触发全量同步
|
||||||
if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return;
|
if (now - this.lastSyncTriggerAt < MIN_RECONNECT_SYNC_INTERVAL) return;
|
||||||
if (this.syncInProgress) return;
|
if (this.syncInProgress) return;
|
||||||
|
|
||||||
@@ -246,14 +249,22 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化完成后,处理缓冲的 SSE 事件
|
* 初始化完成后,处理缓冲的 SSE 事件
|
||||||
|
* 每次循环:取走当前 buffer 后立即清空(避免在 fetch 期间新事件再被覆盖)
|
||||||
|
* 退出条件:连续一轮没有新事件,确保 flush 期间到达的事件也被消费
|
||||||
*/
|
*/
|
||||||
async flushBufferedSSEEvents(): Promise<void> {
|
async flushBufferedSSEEvents(): Promise<void> {
|
||||||
|
let lastChatLen = -1;
|
||||||
|
let lastReadLen = -1;
|
||||||
for (let i = 0; i < MAX_FLUSH_ITERATIONS; i++) {
|
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 chatEvents = this.bufferedChatMessages;
|
||||||
const readEvents = this.bufferedReadReceipts;
|
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.bufferedChatMessages = [];
|
||||||
this.bufferedReadReceipts = [];
|
this.bufferedReadReceipts = [];
|
||||||
|
|
||||||
@@ -528,9 +539,8 @@ export class WSMessageHandler implements IWSMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private incrementSystemUnread(): void {
|
private incrementSystemUnread(): void {
|
||||||
const store = useMessageStore.getState();
|
// 原子递增:避免外部 read-modify-write 造成丢计数
|
||||||
const currentUnread = store.getUnreadCount();
|
useMessageStore.getState().incrementSystemUnreadAtomic();
|
||||||
store.setUnreadCount(currentUnread.total + 1, currentUnread.system + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private markMessageAsRecalled(conversationId: string, messageId: string): void {
|
private markMessageAsRecalled(conversationId: string, messageId: string): void {
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export interface MessageActions {
|
|||||||
removeMessage: (conversationId: string, messageId: string) => void;
|
removeMessage: (conversationId: string, messageId: string) => void;
|
||||||
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => void;
|
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => void;
|
||||||
setUnreadCount: (total: number, system: number) => void;
|
setUnreadCount: (total: number, system: number) => void;
|
||||||
|
/**
|
||||||
|
* 原子递增系统未读数:避免外部 RMW 造成丢失
|
||||||
|
*/
|
||||||
|
incrementSystemUnreadAtomic: () => void;
|
||||||
setLastSystemMessageAt: (time: string | null) => void;
|
setLastSystemMessageAt: (time: string | null) => void;
|
||||||
setSSEConnected: (connected: boolean) => void;
|
setSSEConnected: (connected: boolean) => void;
|
||||||
setCurrentConversation: (conversationId: string | null) => void;
|
setCurrentConversation: (conversationId: string | null) => void;
|
||||||
@@ -107,6 +111,16 @@ export interface MessageActions {
|
|||||||
) => void;
|
) => void;
|
||||||
incrementUnreadAtomic: (conversationId: string) => void;
|
incrementUnreadAtomic: (conversationId: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子回滚乐观已读:仅在当前会话的 my_last_read_seq 仍等于 expectedReadSeq 时才回滚
|
||||||
|
* 避免覆盖后续并发 markAsRead 已更新的更大值
|
||||||
|
*/
|
||||||
|
rollbackReadIfUnchanged: (
|
||||||
|
conversationId: string,
|
||||||
|
expectedReadSeq: number,
|
||||||
|
prevUnreadCount: number,
|
||||||
|
) => void;
|
||||||
|
|
||||||
// 重置
|
// 重置
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
@@ -393,6 +407,13 @@ export const useMessageStore = create<MessageStore>((set, get) => ({
|
|||||||
set({ totalUnreadCount: total, systemUnreadCount: system });
|
set({ totalUnreadCount: total, systemUnreadCount: system });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
incrementSystemUnreadAtomic: () => {
|
||||||
|
set(state => ({
|
||||||
|
totalUnreadCount: state.totalUnreadCount + 1,
|
||||||
|
systemUnreadCount: state.systemUnreadCount + 1,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
setLastSystemMessageAt: (time: string | null) => {
|
setLastSystemMessageAt: (time: string | null) => {
|
||||||
set({ lastSystemMessageAt: time });
|
set({ lastSystemMessageAt: time });
|
||||||
if (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: () => {
|
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