feat(business): implement block-based content editing and rich text rendering
Introduce a new `BlockEditor` component and upgrade the post/message rendering system to support rich text segments (images, @mentions, votes, and post references). - Implement `BlockEditor` for long-form post creation with image embedding support. - Upgrade `PostContentRenderer` and `SegmentRenderer` to handle complex segment types including inline images and block elements. - Refactor `PostCard` to use segment-based content and image signatures for optimized memoization. - Centralize segment partitioning logic in `segmentUtils.ts` to ensure consistent rendering across chat and post modules. - Update `PostRepository` and `Post` entity to support the new `segments` data structure.
This commit is contained in:
118
src/components/business/BlockEditor/BlockEditor.tsx
Normal file
118
src/components/business/BlockEditor/BlockEditor.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useImperativeHandle, useCallback } from 'react';
|
||||||
|
import { View, ScrollView, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { useAppColors, spacing } from '../../../theme';
|
||||||
|
import { MessageSegment } from '../../../types';
|
||||||
|
import TextBlockInput from './TextBlockInput';
|
||||||
|
import ImageBlockView from './ImageBlockView';
|
||||||
|
import { useBlockEditor } from './useBlockEditor';
|
||||||
|
|
||||||
|
export interface BlockEditorHandle {
|
||||||
|
insertImage: () => Promise<void>;
|
||||||
|
insertTextAtCursor: (text: string) => void;
|
||||||
|
getSegments: () => MessageSegment[];
|
||||||
|
getContent: () => string;
|
||||||
|
hasUploadingImages: () => boolean;
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockEditorProps {
|
||||||
|
placeholder?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
style?: any;
|
||||||
|
textStyle?: any;
|
||||||
|
onContentChange?: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
|
||||||
|
({ placeholder = '添加正文', maxLength = 2000, style, textStyle, onContentChange }, ref) => {
|
||||||
|
const colors = useAppColors();
|
||||||
|
const editor = useBlockEditor();
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
insertImage: editor.insertImage,
|
||||||
|
insertTextAtCursor: editor.insertTextAtCursor,
|
||||||
|
getSegments: editor.getSegments,
|
||||||
|
getContent: editor.getContent,
|
||||||
|
hasUploadingImages: editor.hasUploadingImages,
|
||||||
|
focus: () => {
|
||||||
|
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
|
||||||
|
if (firstTextBlock) {
|
||||||
|
editor.focusBlock(firstTextBlock.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleTextChange = useCallback(
|
||||||
|
(blockId: string, text: string, mentions: any) => {
|
||||||
|
editor.updateTextBlock(blockId, text, mentions);
|
||||||
|
onContentChange?.(editor.getContent());
|
||||||
|
},
|
||||||
|
[editor.updateTextBlock, editor.getContent, onContentChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemoveImage = useCallback(
|
||||||
|
(blockId: string) => {
|
||||||
|
editor.removeImageBlock(blockId);
|
||||||
|
onContentChange?.(editor.getContent());
|
||||||
|
},
|
||||||
|
[editor.removeImageBlock, editor.getContent, onContentChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style]}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
nestedScrollEnabled
|
||||||
|
>
|
||||||
|
{editor.blocks.map(block => {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
return (
|
||||||
|
<TextBlockInput
|
||||||
|
key={block.id}
|
||||||
|
block={block}
|
||||||
|
isActive={editor.activeBlockId === block.id}
|
||||||
|
placeholder={placeholder}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onTextChange={handleTextChange}
|
||||||
|
onFocusBlock={editor.setActiveBlockId}
|
||||||
|
onSelectMention={() => {}}
|
||||||
|
inputRef={(ref) => editor.registerInputRef(block.id, ref)}
|
||||||
|
style={textStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ImageBlockView
|
||||||
|
key={block.id}
|
||||||
|
block={block}
|
||||||
|
onRemove={handleRemoveImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
BlockEditor.displayName = 'BlockEditor';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default BlockEditor;
|
||||||
92
src/components/business/BlockEditor/ImageBlockView.tsx
Normal file
92
src/components/business/BlockEditor/ImageBlockView.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
|
||||||
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import { useAppColors, spacing, borderRadius } from '../../../theme';
|
||||||
|
import type { ImageBlock } from './blockEditorTypes';
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
|
const IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
|
||||||
|
|
||||||
|
interface ImageBlockViewProps {
|
||||||
|
block: ImageBlock;
|
||||||
|
onRemove: (blockId: string) => void;
|
||||||
|
style?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageBlockView: React.FC<ImageBlockViewProps> = ({ block, onRemove, style }) => {
|
||||||
|
const colors = useAppColors();
|
||||||
|
|
||||||
|
const aspectRatio = block.width && block.height
|
||||||
|
? block.width / block.height
|
||||||
|
: 4 / 3;
|
||||||
|
const displayWidth = IMAGE_MAX_WIDTH;
|
||||||
|
const displayHeight = displayWidth / aspectRatio;
|
||||||
|
|
||||||
|
const imageSource = block.remoteUrl
|
||||||
|
? { uri: block.remoteUrl }
|
||||||
|
: { uri: block.localUri };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style]}>
|
||||||
|
<ExpoImage
|
||||||
|
source={imageSource}
|
||||||
|
style={{
|
||||||
|
width: displayWidth,
|
||||||
|
height: displayHeight,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
}}
|
||||||
|
contentFit="cover"
|
||||||
|
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)}
|
||||||
|
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||||
|
>
|
||||||
|
<View style={styles.removeButtonInner}>
|
||||||
|
<MaterialCommunityIcons name="close" size={14} color={colors.text.inverse} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 0,
|
||||||
|
position: 'relative',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
uploadingOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8 + (SCREEN_WIDTH - IMAGE_MAX_WIDTH) / 2,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
removeButtonInner: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ImageBlockView;
|
||||||
276
src/components/business/BlockEditor/TextBlockInput.tsx
Normal file
276
src/components/business/BlockEditor/TextBlockInput.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
TextInput,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import Text from '../../common/Text';
|
||||||
|
import Avatar from '../../common/Avatar';
|
||||||
|
import { useAppColors, spacing, fontSizes, borderRadius } from '../../../theme';
|
||||||
|
import { useAuthStore } from '../../../stores';
|
||||||
|
import { authService } from '../../../services/auth';
|
||||||
|
import type { TextBlock } from './blockEditorTypes';
|
||||||
|
|
||||||
|
interface MentionUser {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextBlockInputProps {
|
||||||
|
block: TextBlock;
|
||||||
|
isActive: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
maxLength: number;
|
||||||
|
onTextChange: (blockId: string, text: string, mentions: Map<number, { endIndex: number; userId: string; nickname: string }>) => void;
|
||||||
|
onFocusBlock: (blockId: string) => void;
|
||||||
|
onSelectMention: (blockId: string, userId: string, nickname: string, mentionStartIndex: number) => void;
|
||||||
|
inputRef: (ref: TextInput | null) => void;
|
||||||
|
style?: any;
|
||||||
|
minHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextBlockInput: React.FC<TextBlockInputProps> = ({
|
||||||
|
block,
|
||||||
|
isActive,
|
||||||
|
placeholder,
|
||||||
|
maxLength,
|
||||||
|
onTextChange,
|
||||||
|
onFocusBlock,
|
||||||
|
onSelectMention,
|
||||||
|
inputRef,
|
||||||
|
style,
|
||||||
|
minHeight,
|
||||||
|
}) => {
|
||||||
|
const colors = useAppColors();
|
||||||
|
const currentUser = useAuthStore(s => s.currentUser);
|
||||||
|
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [suggestionMode, setSuggestionMode] = useState<'none' | 'mention'>('none');
|
||||||
|
const [mentionQuery, setMentionQuery] = useState('');
|
||||||
|
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
||||||
|
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded && currentUser?.id) {
|
||||||
|
loadFollowing();
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}, [currentUser?.id, loaded]);
|
||||||
|
|
||||||
|
const loadFollowing = async () => {
|
||||||
|
if (!currentUser?.id) return;
|
||||||
|
try {
|
||||||
|
const list = await authService.getFollowingList(currentUser.id, 1, 200);
|
||||||
|
setFollowingUsers(
|
||||||
|
list.map(u => ({
|
||||||
|
id: u.id,
|
||||||
|
nickname: u.nickname || u.username || '',
|
||||||
|
avatar: u.avatar || null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = followingUsers.filter(u =>
|
||||||
|
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChangeText = useCallback(
|
||||||
|
(newText: string) => {
|
||||||
|
const cursorPos = newText.length;
|
||||||
|
|
||||||
|
// Check for @ trigger
|
||||||
|
let atStart = -1;
|
||||||
|
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||||
|
if (newText[i] === '@') {
|
||||||
|
atStart = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (newText[i] === ' ' || newText[i] === '\n') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atStart >= 0) {
|
||||||
|
const query = newText.slice(atStart + 1, cursorPos);
|
||||||
|
if (!query.includes(' ') && !query.includes('\n')) {
|
||||||
|
setMentionQuery(query);
|
||||||
|
setMentionStartIndex(atStart);
|
||||||
|
setSuggestionMode('mention');
|
||||||
|
} else {
|
||||||
|
setSuggestionMode('none');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSuggestionMode('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mentions when text changes: remap existing mentions
|
||||||
|
const newMentions = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
||||||
|
// Simple approach: only keep mentions tracked by this block's own activeMentions
|
||||||
|
// The parent handles the canonical mentions state
|
||||||
|
block.activeMentions.forEach((mention, start) => {
|
||||||
|
if (start < newText.length) {
|
||||||
|
const mentionText = `@${mention.nickname} `;
|
||||||
|
const expectedEnd = start + mentionText.length;
|
||||||
|
// Check if the mention text is still intact
|
||||||
|
if (newText.slice(start, expectedEnd) === mentionText) {
|
||||||
|
newMentions.set(start, { ...mention, endIndex: expectedEnd });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onTextChange(block.id, newText, newMentions);
|
||||||
|
},
|
||||||
|
[block.id, block.activeMentions, onTextChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectMention = useCallback(
|
||||||
|
(mentionUser: MentionUser) => {
|
||||||
|
if (mentionStartIndex < 0) return;
|
||||||
|
|
||||||
|
const before = block.text.slice(0, mentionStartIndex);
|
||||||
|
const mentionText = `@${mentionUser.nickname} `;
|
||||||
|
const newText = before + mentionText;
|
||||||
|
|
||||||
|
const newMentions = new Map(block.activeMentions);
|
||||||
|
newMentions.set(mentionStartIndex, {
|
||||||
|
endIndex: before.length + mentionText.length,
|
||||||
|
userId: mentionUser.id,
|
||||||
|
nickname: mentionUser.nickname,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also remove any mention that was partially covered
|
||||||
|
const toRemove: number[] = [];
|
||||||
|
newMentions.forEach((m, start) => {
|
||||||
|
if (start !== mentionStartIndex && start >= mentionStartIndex && start < before.length + mentionText.length) {
|
||||||
|
toRemove.push(start);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toRemove.forEach(k => newMentions.delete(k));
|
||||||
|
|
||||||
|
onTextChange(block.id, newText, newMentions);
|
||||||
|
setSuggestionMode('none');
|
||||||
|
setMentionQuery('');
|
||||||
|
setMentionStartIndex(-1);
|
||||||
|
|
||||||
|
onSelectMention(block.id, mentionUser.id, mentionUser.nickname, mentionStartIndex);
|
||||||
|
},
|
||||||
|
[block.id, block.text, mentionStartIndex, onTextChange, onSelectMention],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={block.text}
|
||||||
|
onChangeText={handleChangeText}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={colors.text.hint}
|
||||||
|
maxLength={maxLength}
|
||||||
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
scrollEnabled={false}
|
||||||
|
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||||
|
onFocus={() => onFocusBlock(block.id)}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{
|
||||||
|
color: colors.text.primary,
|
||||||
|
minHeight: minHeight,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{suggestionMode === 'mention' && isActive && filteredUsers.length > 0 && (
|
||||||
|
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||||
|
<ScrollView
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
nestedScrollEnabled
|
||||||
|
style={styles.mentionList}
|
||||||
|
>
|
||||||
|
{filteredUsers.map(item => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.id}
|
||||||
|
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
||||||
|
onPress={() => handleSelectMention(item)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Avatar source={item.avatar} size={36} name={item.nickname} />
|
||||||
|
<View style={styles.mentionInfo}>
|
||||||
|
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
||||||
|
{item.nickname}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
|
||||||
|
点击提及
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suggestionMode === 'mention' && isActive && filteredUsers.length === 0 && (
|
||||||
|
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||||
|
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
||||||
|
没有找到匹配的关注用户
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
input: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
paddingVertical: 0,
|
||||||
|
textAlignVertical: 'top' as const,
|
||||||
|
},
|
||||||
|
mentionPanel: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
maxHeight: 200,
|
||||||
|
marginHorizontal: spacing.sm,
|
||||||
|
},
|
||||||
|
mentionList: {
|
||||||
|
maxHeight: 200,
|
||||||
|
},
|
||||||
|
mentionItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
|
mentionInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
mentionName: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
mentionHint: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
padding: spacing.md,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TextBlockInput;
|
||||||
103
src/components/business/BlockEditor/blockEditorTypes.ts
Normal file
103
src/components/business/BlockEditor/blockEditorTypes.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { MessageSegment, AtSegmentData } from '../../../types';
|
||||||
|
|
||||||
|
export interface TextBlock {
|
||||||
|
id: string;
|
||||||
|
type: 'text';
|
||||||
|
text: string;
|
||||||
|
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageBlock {
|
||||||
|
id: string;
|
||||||
|
type: 'image';
|
||||||
|
localUri: string;
|
||||||
|
remoteUrl?: string;
|
||||||
|
uploading: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorBlock = TextBlock | ImageBlock;
|
||||||
|
|
||||||
|
export function generateBlockId(): string {
|
||||||
|
return `blk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialTextBlock(): TextBlock {
|
||||||
|
return { id: generateBlockId(), type: 'text', text: '', activeMentions: new Map() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remapMentionsBefore(
|
||||||
|
mentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||||
|
cursorPos: number,
|
||||||
|
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
||||||
|
const result = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
||||||
|
for (const [start, mention] of mentions) {
|
||||||
|
if (mention.endIndex <= cursorPos) {
|
||||||
|
result.set(start, { ...mention });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remapMentionsAfter(
|
||||||
|
mentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||||
|
cursorPos: number,
|
||||||
|
textLength: number,
|
||||||
|
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
||||||
|
const result = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
||||||
|
for (const [start, mention] of mentions) {
|
||||||
|
if (start >= cursorPos) {
|
||||||
|
result.set(start - cursorPos, {
|
||||||
|
...mention,
|
||||||
|
endIndex: mention.endIndex - cursorPos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeMentions(
|
||||||
|
m1: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||||
|
m1TextLength: number,
|
||||||
|
m2: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||||
|
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
||||||
|
const result = new Map(m1);
|
||||||
|
for (const [start, mention] of m2) {
|
||||||
|
result.set(start + m1TextLength, {
|
||||||
|
...mention,
|
||||||
|
endIndex: mention.endIndex + m1TextLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockTextToSegments(
|
||||||
|
text: string,
|
||||||
|
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
||||||
|
): MessageSegment[] {
|
||||||
|
if (activeMentions.size === 0) {
|
||||||
|
return text.trim() ? [{ type: 'text', data: { text } }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: MessageSegment[] = [];
|
||||||
|
const sorted = Array.from(activeMentions.entries()).sort((a, b) => a[0] - b[0]);
|
||||||
|
let lastEnd = 0;
|
||||||
|
|
||||||
|
for (const [startIdx, mention] of sorted) {
|
||||||
|
if (startIdx > lastEnd) {
|
||||||
|
segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } });
|
||||||
|
}
|
||||||
|
segments.push({
|
||||||
|
type: 'at',
|
||||||
|
data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData,
|
||||||
|
});
|
||||||
|
lastEnd = mention.endIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastEnd < text.length) {
|
||||||
|
segments.push({ type: 'text', data: { text: text.slice(lastEnd) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
57
src/components/business/BlockEditor/blocksToSegments.ts
Normal file
57
src/components/business/BlockEditor/blocksToSegments.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { MessageSegment } from '../../../types';
|
||||||
|
import { EditorBlock, blockTextToSegments } from './blockEditorTypes';
|
||||||
|
|
||||||
|
export function blocksToSegments(blocks: EditorBlock[]): MessageSegment[] {
|
||||||
|
const segments: MessageSegment[] = [];
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
const textSegments = blockTextToSegments(block.text, block.activeMentions);
|
||||||
|
segments.push(...textSegments);
|
||||||
|
} else if (block.type === 'image') {
|
||||||
|
if (block.remoteUrl) {
|
||||||
|
segments.push({
|
||||||
|
type: 'image',
|
||||||
|
data: {
|
||||||
|
url: block.remoteUrl,
|
||||||
|
width: block.width,
|
||||||
|
height: block.height,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeAdjacentTextSegments(segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAdjacentTextSegments(segments: MessageSegment[]): MessageSegment[] {
|
||||||
|
if (segments.length === 0) return segments;
|
||||||
|
|
||||||
|
const merged: MessageSegment[] = [];
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (
|
||||||
|
last &&
|
||||||
|
last.type === 'text' &&
|
||||||
|
seg.type === 'text'
|
||||||
|
) {
|
||||||
|
const lastText = last.data?.text || last.data?.content || '';
|
||||||
|
const curText = seg.data?.text || seg.data?.content || '';
|
||||||
|
last.data = { text: lastText + curText };
|
||||||
|
} else {
|
||||||
|
merged.push({ ...seg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blocksToContent(blocks: EditorBlock[]): string {
|
||||||
|
return blocks
|
||||||
|
.filter((b): b is typeof b & { type: 'text' } => b.type === 'text')
|
||||||
|
.map(b => b.text)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
2
src/components/business/BlockEditor/index.ts
Normal file
2
src/components/business/BlockEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from './BlockEditor';
|
||||||
|
export type { BlockEditorHandle } from './BlockEditor';
|
||||||
289
src/components/business/BlockEditor/useBlockEditor.ts
Normal file
289
src/components/business/BlockEditor/useBlockEditor.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { TextInput, Alert } from 'react-native';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
|
||||||
|
import { uploadService } from '@/services/upload';
|
||||||
|
import type { EditorBlock, TextBlock, ImageBlock } from './blockEditorTypes';
|
||||||
|
import {
|
||||||
|
generateBlockId,
|
||||||
|
createInitialTextBlock,
|
||||||
|
remapMentionsBefore,
|
||||||
|
remapMentionsAfter,
|
||||||
|
mergeMentions,
|
||||||
|
} from './blockEditorTypes';
|
||||||
|
import { blocksToSegments, blocksToContent } from './blocksToSegments';
|
||||||
|
|
||||||
|
interface MentionData {
|
||||||
|
endIndex: number;
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBlockEditor() {
|
||||||
|
const [blocks, setBlocks] = useState<EditorBlock[]>([createInitialTextBlock()]);
|
||||||
|
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 updateTextBlock = useCallback(
|
||||||
|
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
|
||||||
|
setBlocks(prev =>
|
||||||
|
prev.map(b =>
|
||||||
|
b.id === blockId && b.type === 'text'
|
||||||
|
? { ...b, text, activeMentions: mentions }
|
||||||
|
: b
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusBlock = useCallback((blockId: string) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ref = inputRefs.current.get(blockId);
|
||||||
|
ref?.focus();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeImageBlock = useCallback((blockId: string) => {
|
||||||
|
setBlocks(prev => {
|
||||||
|
const index = prev.findIndex(b => b.id === blockId);
|
||||||
|
if (index < 0) return prev;
|
||||||
|
|
||||||
|
const updated = prev.filter(b => b.id !== blockId);
|
||||||
|
|
||||||
|
// Merge adjacent text blocks if both neighbors are text
|
||||||
|
const before = updated[index - 1];
|
||||||
|
const after = updated[index]; // was index + 1 before removal
|
||||||
|
|
||||||
|
if (
|
||||||
|
before && after &&
|
||||||
|
before.type === 'text' && after.type === 'text'
|
||||||
|
) {
|
||||||
|
const mergedMentions = mergeMentions(
|
||||||
|
before.activeMentions,
|
||||||
|
before.text.length,
|
||||||
|
after.activeMentions,
|
||||||
|
);
|
||||||
|
const mergedBlock: TextBlock = {
|
||||||
|
...before,
|
||||||
|
text: before.text + after.text,
|
||||||
|
activeMentions: mergedMentions,
|
||||||
|
};
|
||||||
|
updated[index - 1] = mergedBlock;
|
||||||
|
updated.splice(index, 1);
|
||||||
|
setActiveBlockId(mergedBlock.id);
|
||||||
|
requestAnimationFrame(() => focusBlock(mergedBlock.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure at least one text block exists
|
||||||
|
if (updated.length === 0) {
|
||||||
|
const newBlock = createInitialTextBlock();
|
||||||
|
updated.push(newBlock);
|
||||||
|
setActiveBlockId(newBlock.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, [focusBlock]);
|
||||||
|
|
||||||
|
const insertImage = useCallback(async () => {
|
||||||
|
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!permissionResult.granted) {
|
||||||
|
Alert.alert('权限不足', '需要访问相册权限来选择图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: 'images',
|
||||||
|
allowsMultipleSelection: true,
|
||||||
|
selectionLimit: 0,
|
||||||
|
quality: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets) {
|
||||||
|
setBlocks(prev => {
|
||||||
|
let updated = [...prev];
|
||||||
|
let currentActiveId = activeBlockId;
|
||||||
|
let insertAfterIndex = updated.findIndex(b => b.id === currentActiveId);
|
||||||
|
|
||||||
|
if (insertAfterIndex < 0) {
|
||||||
|
insertAfterIndex = updated.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const asset of result.assets) {
|
||||||
|
const activeBlock = updated[insertAfterIndex];
|
||||||
|
const cursorPos = activeBlock && activeBlock.type === 'text'
|
||||||
|
? (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,
|
||||||
|
width: asset.width,
|
||||||
|
height: asset.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeBlock && activeBlock.type === 'text') {
|
||||||
|
const textBefore = activeBlock.text.slice(0, cursorPos);
|
||||||
|
const textAfter = activeBlock.text.slice(cursorPos);
|
||||||
|
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,
|
||||||
|
type: 'text',
|
||||||
|
text: textAfter,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start async uploads
|
||||||
|
for (const asset of result.assets) {
|
||||||
|
// Find the newly created image block by localUri
|
||||||
|
uploadImageByLocalUri(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeBlockId, focusBlock]);
|
||||||
|
|
||||||
|
const uploadImageByLocalUri = useCallback(
|
||||||
|
async (asset: ImagePicker.ImagePickerAsset) => {
|
||||||
|
try {
|
||||||
|
const uploadResult = await uploadService.uploadImage({
|
||||||
|
uri: asset.uri,
|
||||||
|
type: asset.mimeType || 'image/jpeg',
|
||||||
|
});
|
||||||
|
|
||||||
|
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('上传失败', '图片上传失败,请重试');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setBlocks(prev =>
|
||||||
|
prev.map(b =>
|
||||||
|
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
||||||
|
? { ...b, uploading: false }
|
||||||
|
: b
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertTextAtCursor = useCallback((text: string) => {
|
||||||
|
const activeId = activeBlockId;
|
||||||
|
if (!activeId) return;
|
||||||
|
|
||||||
|
setBlocks(prev =>
|
||||||
|
prev.map(b => {
|
||||||
|
if (b.id !== activeId || b.type !== 'text') return b;
|
||||||
|
|
||||||
|
const cursorPos = blockCursors.current.get(b.id)?.start ?? b.text.length;
|
||||||
|
const newText = b.text.slice(0, cursorPos) + text + b.text.slice(cursorPos);
|
||||||
|
|
||||||
|
// Shift mentions after cursor
|
||||||
|
const newMentions = new Map<number, MentionData>();
|
||||||
|
b.activeMentions.forEach((mention, start) => {
|
||||||
|
if (start >= cursorPos) {
|
||||||
|
newMentions.set(start + text.length, {
|
||||||
|
...mention,
|
||||||
|
endIndex: mention.endIndex + text.length,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
newMentions.set(start, mention);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPosition = cursorPos + text.length;
|
||||||
|
blockCursors.current.set(b.id, { start: newPosition, end: newPosition });
|
||||||
|
|
||||||
|
return { ...b, text: newText, activeMentions: newMentions };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [activeBlockId]);
|
||||||
|
|
||||||
|
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 registerInputRef = useCallback((blockId: string, ref: TextInput | null) => {
|
||||||
|
if (ref) {
|
||||||
|
inputRefs.current.set(blockId, ref);
|
||||||
|
} else {
|
||||||
|
inputRefs.current.delete(blockId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateCursor = useCallback((blockId: string, selection: { start: number; end: number }) => {
|
||||||
|
blockCursors.current.set(blockId, selection);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocks,
|
||||||
|
activeBlockId,
|
||||||
|
setActiveBlockId,
|
||||||
|
updateTextBlock,
|
||||||
|
removeImageBlock,
|
||||||
|
insertImage,
|
||||||
|
insertTextAtCursor,
|
||||||
|
focusBlock,
|
||||||
|
getSegments,
|
||||||
|
getContent,
|
||||||
|
hasUploadingImages,
|
||||||
|
registerInputRef,
|
||||||
|
updateCursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,13 +16,12 @@ import React, { useMemo, useState, memo, useEffect } from 'react';
|
|||||||
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { PostCardProps, PostCardAction } from './types';
|
import { PostCardProps, PostCardAction } from './types';
|
||||||
import { ImageGridItem, SmartImage } from '../../common';
|
import { ImageGridItem, SmartImage, ImageGrid } from '../../common';
|
||||||
import Text from '../../common/Text';
|
import Text from '../../common/Text';
|
||||||
import HighlightText from '../../common/HighlightText';
|
import HighlightText from '../../common/HighlightText';
|
||||||
import Avatar from '../../common/Avatar';
|
import Avatar from '../../common/Avatar';
|
||||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||||
import PostImages from './components/PostImages';
|
|
||||||
import PostContentRenderer from '../PostContentRenderer';
|
import PostContentRenderer from '../PostContentRenderer';
|
||||||
import { useResponsive } from '../../../hooks';
|
import { useResponsive } from '../../../hooks';
|
||||||
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
||||||
@@ -55,6 +54,13 @@ function imagesSignature(
|
|||||||
return images.map((i) => i.id).join('\u001f');
|
return images.map((i) => i.id).join('\u001f');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function segmentSignature(
|
||||||
|
segments: PostCardProps['post']['segments'] | undefined
|
||||||
|
): string {
|
||||||
|
if (!segments?.length) return '';
|
||||||
|
return segments.map(s => `${s.type}:${s.type === 'image' ? (s.data as any)?.url || '' : ''}`).join('\u001f');
|
||||||
|
}
|
||||||
|
|
||||||
function authorSignature(author: PostCardProps['post']['author']): string {
|
function authorSignature(author: PostCardProps['post']['author']): string {
|
||||||
if (!author) return '';
|
if (!author) return '';
|
||||||
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
||||||
@@ -110,6 +116,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
|||||||
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
||||||
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
||||||
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
||||||
|
if (segmentSignature(a.segments) !== segmentSignature(b.segments)) return false;
|
||||||
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
||||||
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
||||||
|
|
||||||
@@ -426,6 +433,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
const rawContent = post.content ?? '';
|
const rawContent = post.content ?? '';
|
||||||
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
|
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
|
||||||
|
|
||||||
|
const displayContent = useMemo(() => {
|
||||||
|
if (!post.segments?.length) return rawContent;
|
||||||
|
const segs = post.segments;
|
||||||
|
let result = '';
|
||||||
|
for (const s of segs) {
|
||||||
|
if (s.type === 'image') {
|
||||||
|
result += '[图片]';
|
||||||
|
} else if (s.type === 'text') {
|
||||||
|
result += (s.data as any)?.text || (s.data as any)?.content || '';
|
||||||
|
} else if (s.type === 'at') {
|
||||||
|
result += `@${(s.data as any)?.nickname || '某人'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result || rawContent;
|
||||||
|
}, [post.segments, rawContent]);
|
||||||
|
|
||||||
const handleCardPress = () => emit({ type: 'press' });
|
const handleCardPress = () => emit({ type: 'press' });
|
||||||
const handleUserPress = () => emit({ type: 'userPress' });
|
const handleUserPress = () => emit({ type: 'userPress' });
|
||||||
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
||||||
@@ -455,17 +478,29 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const images: ImageGridItem[] = Array.isArray(post.images)
|
const segmentImages: ImageGridItem[] = (post.segments || [])
|
||||||
? post.images.map((img) => ({
|
.filter((s: any) => s.type === 'image' && s.data?.url)
|
||||||
id: img.id,
|
.map((s: any, i: number) => ({
|
||||||
url: img.url,
|
id: `seg-${i}`,
|
||||||
thumbnail_url: img.thumbnail_url,
|
url: s.data.url,
|
||||||
preview_url: img.preview_url,
|
width: s.data.width,
|
||||||
preview_url_large: img.preview_url_large,
|
height: s.data.height,
|
||||||
width: img.width,
|
}));
|
||||||
height: img.height,
|
|
||||||
}))
|
const images: ImageGridItem[] = [
|
||||||
: [];
|
...Array.isArray(post.images)
|
||||||
|
? post.images.map((img) => ({
|
||||||
|
id: img.id,
|
||||||
|
url: img.url,
|
||||||
|
thumbnail_url: img.thumbnail_url,
|
||||||
|
preview_url: img.preview_url,
|
||||||
|
preview_url_large: img.preview_url_large,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
...segmentImages,
|
||||||
|
];
|
||||||
|
|
||||||
const formatNumber = (num: number): string => {
|
const formatNumber = (num: number): string => {
|
||||||
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
||||||
@@ -538,10 +573,10 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
return `${value.substring(0, maxLength)}...`;
|
return `${value.substring(0, maxLength)}...`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
|
const shouldTruncate = !showGrid && !isCompact && displayContent.length > getResponsiveMaxLength();
|
||||||
const content = useMemo(
|
const content = useMemo(
|
||||||
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
|
() => (showGrid || isCompact ? displayContent : getTruncatedContent(displayContent)),
|
||||||
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
[displayContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderImages = () => {
|
const renderImages = () => {
|
||||||
@@ -563,10 +598,24 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PostImages
|
<ImageGrid
|
||||||
images={post.images || []}
|
images={images.map(img => ({
|
||||||
displayMode="list"
|
id: img.id,
|
||||||
|
url: img.url || '',
|
||||||
|
thumbnail_url: img.thumbnail_url,
|
||||||
|
preview_url: img.preview_url,
|
||||||
|
preview_url_large: img.preview_url_large,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
}))}
|
||||||
|
maxDisplayCount={isWideScreen ? 12 : 9}
|
||||||
|
mode="auto"
|
||||||
|
gap={isDesktop ? 4 : 2}
|
||||||
|
borderRadius={isDesktop ? borderRadius.xl : borderRadius.md}
|
||||||
|
showMoreOverlay={true}
|
||||||
onImagePress={handleImagePress}
|
onImagePress={handleImagePress}
|
||||||
|
displayMode="list"
|
||||||
|
usePreview={true}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -704,24 +753,12 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
|
|
||||||
{!isCompact && !!content && (
|
{!isCompact && !!content && (
|
||||||
<>
|
<>
|
||||||
{post.segments && post.segments.length > 0 ? (
|
<Text
|
||||||
<PostContentRenderer
|
style={styles.content}
|
||||||
content={content}
|
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||||
segments={post.segments}
|
>
|
||||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||||
style={styles.contentRenderer}
|
</Text>
|
||||||
textStyle={styles.content}
|
|
||||||
highlightKeyword={highlightKeyword}
|
|
||||||
highlightStyle={styles.highlight}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
style={styles.content}
|
|
||||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
|
||||||
>
|
|
||||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{shouldTruncate && (
|
{shouldTruncate && (
|
||||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||||
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
|
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
|
||||||
@@ -730,6 +767,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
|
||||||
{renderImages()}
|
{renderImages()}
|
||||||
{renderTopComment()}
|
{renderTopComment()}
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export interface PostContentProps {
|
|||||||
* PostImages 子组件 Props
|
* PostImages 子组件 Props
|
||||||
*/
|
*/
|
||||||
export interface PostImagesProps {
|
export interface PostImagesProps {
|
||||||
images: PostImageDTO[];
|
images: (PostImageDTO | { id?: string; url: string; thumbnail_url?: string; preview_url?: string; preview_url_large?: string; width?: number; height?: number })[];
|
||||||
displayMode: 'list' | 'grid';
|
displayMode: 'list' | 'grid';
|
||||||
onImagePress: (images: ImageGridItem[], index: number) => void;
|
onImagePress: (images: ImageGridItem[], index: number) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* PostContentRenderer - 帖子内容渲染器
|
* PostContentRenderer - 帖子内容渲染器
|
||||||
* 根据 segments 渲染富文本内容(@提及、投票等)
|
* 根据 segments 渲染富文本内容(@提及、投票、内嵌图片等)
|
||||||
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp, Dimensions } from 'react-native';
|
||||||
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
|
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import HighlightText from '../common/HighlightText';
|
import HighlightText from '../common/HighlightText';
|
||||||
import { useAppColors } from '../../theme';
|
import { useAppColors } from '../../theme';
|
||||||
import { MessageSegment, AtSegmentData, VoteSegmentData, PostRefSegmentData } from '../../types';
|
import {
|
||||||
|
MessageSegment,
|
||||||
|
AtSegmentData,
|
||||||
|
VoteSegmentData,
|
||||||
|
PostRefSegmentData,
|
||||||
|
ImageSegmentData,
|
||||||
|
partitionSegments,
|
||||||
|
} from '../../types';
|
||||||
import PostRefCard from './PostRefCard';
|
import PostRefCard from './PostRefCard';
|
||||||
|
|
||||||
interface PostContentRendererProps {
|
interface PostContentRendererProps {
|
||||||
@@ -19,24 +27,35 @@ interface PostContentRendererProps {
|
|||||||
numberOfLines?: number;
|
numberOfLines?: number;
|
||||||
onMentionPress?: (userId: string) => void;
|
onMentionPress?: (userId: string) => void;
|
||||||
onPostRefPress?: (postId: string) => void;
|
onPostRefPress?: (postId: string) => void;
|
||||||
|
onImagePress?: (url: string) => void;
|
||||||
style?: any;
|
style?: any;
|
||||||
textStyle?: any;
|
textStyle?: any;
|
||||||
highlightKeyword?: string;
|
highlightKeyword?: string;
|
||||||
highlightStyle?: StyleProp<TextStyle>;
|
highlightStyle?: StyleProp<TextStyle>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCREEN_WIDTH = Dimensions.get('window').width;
|
||||||
|
const INLINE_IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
|
||||||
|
|
||||||
|
function hasInlineImages(segments?: MessageSegment[]): boolean {
|
||||||
|
if (!segments) return false;
|
||||||
|
return segments.some(s => s.type === 'image');
|
||||||
|
}
|
||||||
|
|
||||||
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||||
content,
|
content,
|
||||||
segments,
|
segments,
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
onMentionPress,
|
onMentionPress,
|
||||||
onPostRefPress,
|
onPostRefPress,
|
||||||
|
onImagePress,
|
||||||
style,
|
style,
|
||||||
textStyle,
|
textStyle,
|
||||||
highlightKeyword,
|
highlightKeyword,
|
||||||
highlightStyle,
|
highlightStyle,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
|
const hasImages = hasInlineImages(segments);
|
||||||
|
|
||||||
if (!segments || segments.length === 0) {
|
if (!segments || segments.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -50,115 +69,198 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements: React.ReactNode[] = [];
|
const hasComplexContent = hasImages ||
|
||||||
let keyIndex = 0;
|
segments.some(s => s.type === 'vote' || s.type === 'post_ref');
|
||||||
|
|
||||||
for (const segment of segments) {
|
if (!hasComplexContent || (numberOfLines && !hasImages)) {
|
||||||
const key = `seg_${keyIndex++}`;
|
const elements: React.ReactNode[] = [];
|
||||||
|
let keyIndex = 0;
|
||||||
|
|
||||||
switch (segment.type) {
|
for (const segment of segments) {
|
||||||
case 'text': {
|
const key = `seg_${keyIndex++}`;
|
||||||
const text = segment.data?.text || segment.data?.content || '';
|
switch (segment.type) {
|
||||||
if (text) {
|
case 'text': {
|
||||||
|
const text = segment.data?.text || segment.data?.content || '';
|
||||||
|
if (text) {
|
||||||
|
elements.push(
|
||||||
|
<Text key={key} selectable style={textStyle}>
|
||||||
|
{highlightKeyword ? (
|
||||||
|
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
|
||||||
|
) : (
|
||||||
|
text
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'at': {
|
||||||
|
const atData = segment.data as AtSegmentData;
|
||||||
|
const userId = atData?.user_id;
|
||||||
|
const isAtAll = userId === 'all';
|
||||||
|
const displayName = atData?.nickname || (isAtAll ? '所有人' : '某人');
|
||||||
elements.push(
|
elements.push(
|
||||||
<Text key={key} selectable style={textStyle}>
|
<Text
|
||||||
{highlightKeyword ? (
|
key={key}
|
||||||
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
|
selectable
|
||||||
) : (
|
style={[
|
||||||
text
|
textStyle,
|
||||||
)}
|
{ color: colors.primary.main, fontWeight: '500' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
@{displayName}{' '}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'image':
|
||||||
|
case 'link':
|
||||||
|
case 'face':
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
|
{highlightKeyword && content ? (
|
||||||
|
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
||||||
|
) : (
|
||||||
|
content || ''
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numberOfLines) {
|
||||||
|
return (
|
||||||
|
<View style={style}>
|
||||||
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
|
{elements}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View style={[styles.container, style]}>{elements}</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = partitionSegments(segments);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.chunkContainer, style]}>
|
||||||
|
{chunks.map((chunk, i) => {
|
||||||
|
if (chunk.kind === 'inline') {
|
||||||
|
return (
|
||||||
|
<Text key={`inl-${i}`} selectable style={textStyle}>
|
||||||
|
{chunk.parts.map((segment, j) => {
|
||||||
|
const segKey = `inl-${i}-${j}`;
|
||||||
|
switch (segment.type) {
|
||||||
|
case 'text': {
|
||||||
|
const text = segment.data?.text || segment.data?.content || '';
|
||||||
|
return highlightKeyword ? (
|
||||||
|
<HighlightText key={segKey} text={text} keyword={highlightKeyword} style={highlightStyle} />
|
||||||
|
) : (
|
||||||
|
<Text key={segKey}>{text}</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'at': {
|
||||||
|
const atData = segment.data as AtSegmentData;
|
||||||
|
const userId = atData?.user_id;
|
||||||
|
const displayName = atData?.nickname || (userId === 'all' ? '所有人' : '某人');
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={segKey}
|
||||||
|
style={[textStyle, { color: colors.primary.main, fontWeight: '500' }]}
|
||||||
|
onPress={() => onMentionPress?.(userId)}
|
||||||
|
>
|
||||||
|
@{displayName}{' '}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'at': {
|
if (chunk.kind === 'images') {
|
||||||
const atData = segment.data as AtSegmentData;
|
return (
|
||||||
const userId = atData?.user_id;
|
<View key={`imgs-${i}`} style={styles.inlineImagesContainer}>
|
||||||
const isAtAll = userId === 'all';
|
{chunk.parts.map((segment, j) => {
|
||||||
const displayName = atData?.nickname || (isAtAll ? '所有人' : '某人');
|
const imgData = segment.data as ImageSegmentData;
|
||||||
elements.push(
|
const url = imgData?.url || '';
|
||||||
<Text
|
if (!url) return null;
|
||||||
key={key}
|
|
||||||
selectable
|
|
||||||
style={[
|
|
||||||
textStyle,
|
|
||||||
{
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
@{displayName}{' '}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'vote': {
|
const aspectRatio = imgData.width && imgData.height
|
||||||
const voteData = segment.data as VoteSegmentData;
|
? imgData.width / imgData.height
|
||||||
if (voteData?.options?.length) {
|
: 4 / 3;
|
||||||
elements.push(
|
const displayWidth = INLINE_IMAGE_MAX_WIDTH;
|
||||||
<View key={key} style={[styles.voteBlock, { borderColor: colors.divider }]}>
|
const displayHeight = displayWidth / aspectRatio;
|
||||||
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
|
|
||||||
📊 投票({voteData.options.length} 个选项)
|
return (
|
||||||
</Text>
|
<TouchableOpacity
|
||||||
{voteData.options.map((opt, i) => (
|
key={`img-${i}-${j}`}
|
||||||
<Text key={`vote_opt_${i}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
|
activeOpacity={0.9}
|
||||||
{i + 1}. {opt.content}
|
onPress={() => onImagePress?.(url)}
|
||||||
</Text>
|
style={j < chunk.parts.length - 1 ? styles.imageSpacing : undefined}
|
||||||
))}
|
>
|
||||||
|
<ExpoImage
|
||||||
|
source={{ uri: url }}
|
||||||
|
style={{
|
||||||
|
width: displayWidth,
|
||||||
|
height: displayHeight,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
contentFit="cover"
|
||||||
|
cachePolicy="memory-disk"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'post_ref': {
|
const { segment } = chunk;
|
||||||
const refData = segment.data as PostRefSegmentData;
|
switch (segment.type) {
|
||||||
elements.push(
|
case 'vote': {
|
||||||
<PostRefCard
|
const voteData = segment.data as VoteSegmentData;
|
||||||
key={key}
|
if (!voteData?.options?.length) return null;
|
||||||
data={refData}
|
return (
|
||||||
compact={!!numberOfLines}
|
<View key={`blk-${i}`} style={[styles.voteBlock, { borderColor: colors.divider }]}>
|
||||||
onPress={onPostRefPress}
|
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
|
||||||
/>
|
投票({voteData.options.length} 个选项)
|
||||||
);
|
</Text>
|
||||||
break;
|
{voteData.options.map((opt, k) => (
|
||||||
}
|
<Text key={`vote_opt_${k}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
|
||||||
|
{k + 1}. {opt.content}
|
||||||
case 'image':
|
</Text>
|
||||||
case 'link':
|
))}
|
||||||
case 'face':
|
</View>
|
||||||
default:
|
);
|
||||||
break;
|
}
|
||||||
}
|
case 'post_ref': {
|
||||||
}
|
const refData = segment.data as PostRefSegmentData;
|
||||||
|
return (
|
||||||
if (elements.length === 0) {
|
<PostRefCard
|
||||||
return (
|
key={`blk-${i}`}
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
data={refData}
|
||||||
{highlightKeyword && content ? (
|
compact={false}
|
||||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
onPress={onPostRefPress}
|
||||||
) : (
|
/>
|
||||||
content || ''
|
);
|
||||||
)}
|
}
|
||||||
</Text>
|
default:
|
||||||
);
|
return null;
|
||||||
}
|
}
|
||||||
|
})}
|
||||||
if (numberOfLines) {
|
</View>
|
||||||
return (
|
);
|
||||||
<View style={style}>
|
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
|
||||||
{elements}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <View style={[styles.container, style]}>{elements}</View>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
@@ -167,6 +269,15 @@ const styles = StyleSheet.create({
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
|
chunkContainer: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
inlineImagesContainer: {
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
imageSpacing: {
|
||||||
|
marginBottom: 6,
|
||||||
|
},
|
||||||
voteBlock: {
|
voteBlock: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export { default as VoteEditor } from './VoteEditor';
|
|||||||
export { default as VotePreview } from './VotePreview';
|
export { default as VotePreview } from './VotePreview';
|
||||||
export { default as PostContentRenderer } from './PostContentRenderer';
|
export { default as PostContentRenderer } from './PostContentRenderer';
|
||||||
export { default as PostMentionInput } from './PostMentionInput';
|
export { default as PostMentionInput } from './PostMentionInput';
|
||||||
|
export { default as BlockEditor } from './BlockEditor';
|
||||||
|
export type { BlockEditorHandle } from './BlockEditor';
|
||||||
export { default as ReportDialog } from './ReportDialog';
|
export { default as ReportDialog } from './ReportDialog';
|
||||||
export { default as ShareSheet } from './ShareSheet';
|
export { default as ShareSheet } from './ShareSheet';
|
||||||
export { TradeCard } from './TradeCard/TradeCard';
|
export { TradeCard } from './TradeCard/TradeCard';
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
// ==================== 帖子状态枚举 ====================
|
// ==================== 帖子状态枚举 ====================
|
||||||
|
|
||||||
|
import type { MessageSegment } from '../../types/dto/message';
|
||||||
|
|
||||||
export type PostStatus = 'published' | 'draft' | 'deleted';
|
export type PostStatus = 'published' | 'draft' | 'deleted';
|
||||||
|
|
||||||
// ==================== 值对象 ====================
|
// ==================== 值对象 ====================
|
||||||
@@ -73,6 +75,8 @@ export interface Post {
|
|||||||
isFavorited: boolean;
|
isFavorited: boolean;
|
||||||
/** 是否置顶 (camelCase) */
|
/** 是否置顶 (camelCase) */
|
||||||
isPinned: boolean;
|
isPinned: boolean;
|
||||||
|
/** 帖子内容段落(图文混排) */
|
||||||
|
segments?: MessageSegment[];
|
||||||
/** 帖子状态 */
|
/** 帖子状态 */
|
||||||
status: PostStatus;
|
status: PostStatus;
|
||||||
/** 所属频道ID */
|
/** 所属频道ID */
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface PostApiResponse {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
segments?: any[];
|
||||||
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
|
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
|
||||||
likes_count: number;
|
likes_count: number;
|
||||||
comments_count: number;
|
comments_count: number;
|
||||||
@@ -110,6 +111,7 @@ export class PostRepository implements IPostRepository {
|
|||||||
} : undefined,
|
} : undefined,
|
||||||
title: model.title,
|
title: model.title,
|
||||||
content: model.content,
|
content: model.content,
|
||||||
|
segments: response.segments,
|
||||||
images: (model.images || []).map(url => ({
|
images: (model.images || []).map(url => ({
|
||||||
url,
|
url,
|
||||||
width: undefined,
|
width: undefined,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { ApiError } from '@/services/core';
|
|||||||
import { uploadService } from '@/services/upload';
|
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 { 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';
|
||||||
@@ -135,12 +136,16 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
const [posting, setPosting] = useState(false);
|
const [posting, setPosting] = useState(false);
|
||||||
const [loadingPost, setLoadingPost] = useState(false);
|
const [loadingPost, setLoadingPost] = useState(false);
|
||||||
|
|
||||||
|
// 长文模式:图片作为 image segment 嵌入文本
|
||||||
|
const [isLongPostMode, setIsLongPostMode] = useState(false);
|
||||||
|
|
||||||
// 投票相关状态
|
// 投票相关状态
|
||||||
const [isVotePost, setIsVotePost] = useState(false);
|
const [isVotePost, setIsVotePost] = useState(false);
|
||||||
const [voteOptions, setVoteOptions] = useState<string[]>(['', '']); // 默认2个选项
|
const [voteOptions, setVoteOptions] = useState<string[]>(['', '']); // 默认2个选项
|
||||||
|
|
||||||
// 内容输入框引用
|
// 内容输入框引用
|
||||||
const contentInputRef = React.useRef<TextInput>(null);
|
const contentInputRef = React.useRef<TextInput>(null);
|
||||||
|
const blockEditorRef = React.useRef<BlockEditorHandle>(null);
|
||||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||||
|
|
||||||
// 动画值
|
// 动画值
|
||||||
@@ -218,7 +223,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();
|
||||||
|
|
||||||
@@ -308,7 +313,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
setImages(images.filter((_, i) => i !== index));
|
setImages(images.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 选择频道(单选,可再次点击取消)
|
// 选择频道(单选,可再次点击取消)
|
||||||
const handleSelectChannel = useCallback((channelId: string) => {
|
const handleSelectChannel = useCallback((channelId: string) => {
|
||||||
setSelectedChannelId(prev => (prev === channelId ? null : channelId));
|
setSelectedChannelId(prev => (prev === channelId ? null : channelId));
|
||||||
setShowChannelPicker(false);
|
setShowChannelPicker(false);
|
||||||
@@ -316,6 +321,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
|
|
||||||
// 插入表情
|
// 插入表情
|
||||||
const handleInsertEmoji = (emoji: string) => {
|
const handleInsertEmoji = (emoji: string) => {
|
||||||
|
if (isLongPostMode) {
|
||||||
|
blockEditorRef.current?.insertTextAtCursor(emoji);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newContent = content.slice(0, selection.start) + emoji + content.slice(selection.end);
|
const newContent = content.slice(0, selection.start) + emoji + content.slice(selection.end);
|
||||||
setContent(newContent);
|
setContent(newContent);
|
||||||
const newPosition = selection.start + emoji.length;
|
const newPosition = selection.start + emoji.length;
|
||||||
@@ -381,24 +390,34 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!content.trim()) {
|
const contentToCheck = isLongPostMode
|
||||||
|
? (blockEditorRef.current?.getContent() || content)
|
||||||
|
: content;
|
||||||
|
|
||||||
|
if (!contentToCheck.trim()) {
|
||||||
Alert.alert('错误', '请输入帖子内容');
|
Alert.alert('错误', '请输入帖子内容');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有图片正在上传
|
// 检查是否有图片正在上传
|
||||||
const uploadingImages = images.filter(img => img.uploading);
|
if (isLongPostMode) {
|
||||||
if (uploadingImages.length > 0) {
|
if (blockEditorRef.current?.hasUploadingImages()) {
|
||||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const uploadingImages = images.filter(img => img.uploading);
|
||||||
|
if (uploadingImages.length > 0) {
|
||||||
|
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPosting(true);
|
setPosting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imageUrls = images.map(img => img.uri);
|
|
||||||
|
|
||||||
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) {
|
||||||
@@ -406,7 +425,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
setPosting(false);
|
setPosting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建投票帖子
|
// 创建投票帖子
|
||||||
await voteService.createVotePost({
|
await voteService.createVotePost({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
@@ -422,7 +441,35 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
duration: 2600,
|
duration: 2600,
|
||||||
});
|
});
|
||||||
router.replace(hrefs.hrefHome());
|
router.replace(hrefs.hrefHome());
|
||||||
|
} else if (isLongPostMode) {
|
||||||
|
// 长文模式:从 BlockEditor 获取 segments
|
||||||
|
const editorSegments = blockEditorRef.current?.getSegments() || [];
|
||||||
|
const editorContent = blockEditorRef.current?.getContent() || '';
|
||||||
|
const finalSegments = editorSegments.length > 0 ? editorSegments : undefined;
|
||||||
|
if (isEditMode && editPostID) {
|
||||||
|
const updated = await postService.updatePost(editPostID, {
|
||||||
|
title: title.trim(),
|
||||||
|
content: editorContent.trim(),
|
||||||
|
segments: finalSegments,
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('更新帖子失败');
|
||||||
|
}
|
||||||
|
showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 });
|
||||||
|
router.replace(hrefs.hrefHome());
|
||||||
|
} else {
|
||||||
|
await postService.createPost({
|
||||||
|
title: title.trim(),
|
||||||
|
content: editorContent.trim(),
|
||||||
|
segments: finalSegments,
|
||||||
|
channel_id: selectedChannelId || undefined,
|
||||||
|
});
|
||||||
|
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||||
|
router.replace(hrefs.hrefHome());
|
||||||
|
}
|
||||||
} 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(),
|
||||||
@@ -433,12 +480,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new Error('更新帖子失败');
|
throw new Error('更新帖子失败');
|
||||||
}
|
}
|
||||||
showPrompt({
|
showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 });
|
||||||
type: 'success',
|
|
||||||
title: '修改成功',
|
|
||||||
message: '帖子内容已更新',
|
|
||||||
duration: 2200,
|
|
||||||
});
|
|
||||||
router.replace(hrefs.hrefHome());
|
router.replace(hrefs.hrefHome());
|
||||||
} else {
|
} else {
|
||||||
await postService.createPost({
|
await postService.createPost({
|
||||||
@@ -448,12 +490,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
images: imageUrls.length > 0 ? imageUrls : undefined,
|
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||||
channel_id: selectedChannelId || undefined,
|
channel_id: selectedChannelId || undefined,
|
||||||
});
|
});
|
||||||
showPrompt({
|
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
|
||||||
type: 'info',
|
|
||||||
title: '审核中',
|
|
||||||
message: '帖子已提交,内容审核中,稍后展示',
|
|
||||||
duration: 2600,
|
|
||||||
});
|
|
||||||
router.replace(hrefs.hrefHome());
|
router.replace(hrefs.hrefHome());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -619,23 +656,37 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
maxLength={MAX_TITLE_LENGTH}
|
maxLength={MAX_TITLE_LENGTH}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
|
{isLongPostMode ? (
|
||||||
<PostMentionInput
|
<BlockEditor
|
||||||
value={content}
|
ref={blockEditorRef}
|
||||||
onChangeText={setContent}
|
placeholder="添加正文"
|
||||||
onSegmentsChange={setSegments}
|
maxLength={MAX_CONTENT_LENGTH}
|
||||||
placeholder="添加正文"
|
textStyle={[
|
||||||
maxLength={MAX_CONTENT_LENGTH}
|
styles.contentInput,
|
||||||
minHeight={contentInputMinHeight}
|
isWideScreen && styles.contentInputWide,
|
||||||
autoExpand
|
]}
|
||||||
style={[
|
onContentChange={setContent}
|
||||||
styles.contentInput,
|
/>
|
||||||
isWideScreen && styles.contentInputWide,
|
) : (
|
||||||
]}
|
<>
|
||||||
/>
|
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
|
||||||
|
<PostMentionInput
|
||||||
|
value={content}
|
||||||
|
onChangeText={setContent}
|
||||||
|
onSegmentsChange={setSegments}
|
||||||
|
placeholder="添加正文"
|
||||||
|
maxLength={MAX_CONTENT_LENGTH}
|
||||||
|
minHeight={contentInputMinHeight}
|
||||||
|
autoExpand
|
||||||
|
style={[
|
||||||
|
styles.contentInput,
|
||||||
|
isWideScreen && styles.contentInputWide,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{renderImageGrid()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 作为正文容器的子模块,追加在文本后面 */}
|
|
||||||
{renderImageGrid()}
|
|
||||||
{renderVoteEditor()}
|
{renderVoteEditor()}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -688,7 +739,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
<View style={styles.toolbarLeft}>
|
<View style={styles.toolbarLeft}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.toolbarButton}
|
style={styles.toolbarButton}
|
||||||
onPress={handlePickImage}
|
onPress={isLongPostMode ? () => blockEditorRef.current?.insertImage() : handlePickImage}
|
||||||
disabled={posting}
|
disabled={posting}
|
||||||
>
|
>
|
||||||
<View style={styles.toolbarButtonInner}>
|
<View style={styles.toolbarButtonInner}>
|
||||||
@@ -700,19 +751,21 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
{!isLongPostMode && (
|
||||||
style={styles.toolbarButton}
|
<TouchableOpacity
|
||||||
onPress={handleTakePhoto}
|
style={styles.toolbarButton}
|
||||||
disabled={posting}
|
onPress={handleTakePhoto}
|
||||||
>
|
disabled={posting}
|
||||||
<View style={styles.toolbarButtonInner}>
|
>
|
||||||
<MaterialCommunityIcons
|
<View style={styles.toolbarButtonInner}>
|
||||||
name="camera-outline"
|
<MaterialCommunityIcons
|
||||||
size={24}
|
name="camera-outline"
|
||||||
color={colors.text.secondary}
|
size={24}
|
||||||
/>
|
color={colors.text.secondary}
|
||||||
</View>
|
/>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.toolbarButton}
|
style={styles.toolbarButton}
|
||||||
@@ -742,6 +795,21 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 长文模式开关 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.toolbarButton}
|
||||||
|
onPress={() => setIsLongPostMode(prev => !prev)}
|
||||||
|
disabled={posting}
|
||||||
|
>
|
||||||
|
<View style={styles.toolbarButtonInner}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={isLongPostMode ? "text-box" : "text-box-outline"}
|
||||||
|
size={24}
|
||||||
|
color={isLongPostMode ? colors.primary.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1125,11 +1125,12 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
|
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
|
onImagePress={(url) => handleImagePress([{ uri: url }], 0)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 图片 - 详情页显示所有图片,不限制数量 */}
|
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
|
||||||
{postImages.length > 0 && (
|
{!post.segments?.some(s => s.type === 'image') && postImages.length > 0 && (
|
||||||
<ImageGrid
|
<ImageGrid
|
||||||
images={postImages}
|
images={postImages}
|
||||||
mode="auto"
|
mode="auto"
|
||||||
|
|||||||
@@ -39,43 +39,7 @@ const IMAGE_MAX_WIDTH = 200;
|
|||||||
const IMAGE_MAX_HEIGHT = 260;
|
const IMAGE_MAX_HEIGHT = 260;
|
||||||
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
|
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
|
||||||
|
|
||||||
/** 将非 reply 的 segments 分成:行内文案/@、连续图片组、其它块级(音视频文件等) */
|
import { partitionSegments, type ContentChunk } from '../../../../types/dto';
|
||||||
type ContentChunk =
|
|
||||||
| { kind: 'inline'; parts: MessageSegment[] }
|
|
||||||
| { kind: 'images'; parts: MessageSegment[] }
|
|
||||||
| { kind: 'block'; segment: MessageSegment };
|
|
||||||
|
|
||||||
function partitionMessageSegments(segments: MessageSegment[]): ContentChunk[] {
|
|
||||||
const out: ContentChunk[] = [];
|
|
||||||
let inlineBuf: MessageSegment[] = [];
|
|
||||||
const flushInline = () => {
|
|
||||||
if (inlineBuf.length) {
|
|
||||||
out.push({ kind: 'inline', parts: [...inlineBuf] });
|
|
||||||
inlineBuf = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const s of segments) {
|
|
||||||
if (s.type === 'image') {
|
|
||||||
flushInline();
|
|
||||||
const last = out[out.length - 1];
|
|
||||||
if (last?.kind === 'images') {
|
|
||||||
last.parts.push(s);
|
|
||||||
} else {
|
|
||||||
out.push({ kind: 'images', parts: [s] });
|
|
||||||
}
|
|
||||||
} else if (s.type === 'video' || s.type === 'file' || s.type === 'link' || s.type === 'voice') {
|
|
||||||
flushInline();
|
|
||||||
out.push({ kind: 'block', segment: s });
|
|
||||||
} else if (s.type === 'text' || s.type === 'at' || s.type === 'face') {
|
|
||||||
inlineBuf.push(s);
|
|
||||||
} else {
|
|
||||||
inlineBuf.push(s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushInline();
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Segment 渲染器 Props
|
// Segment 渲染器 Props
|
||||||
export interface SegmentRendererProps {
|
export interface SegmentRendererProps {
|
||||||
@@ -741,7 +705,7 @@ const MessageSegmentsRendererInner: React.FC<{
|
|||||||
|
|
||||||
const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
|
const replySegment = useMemo(() => segments.find(s => s.type === 'reply'), [segments]);
|
||||||
const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
|
const otherSegments = useMemo(() => segments.filter(s => s.type !== 'reply'), [segments]);
|
||||||
const chunks = useMemo(() => partitionMessageSegments(otherSegments), [otherSegments]);
|
const chunks = useMemo(() => partitionSegments(otherSegments), [otherSegments]);
|
||||||
|
|
||||||
const renderProps = useMemo(() => ({
|
const renderProps = useMemo(() => ({
|
||||||
isMe,
|
isMe,
|
||||||
|
|||||||
@@ -296,11 +296,11 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
senderName: {
|
senderName: {
|
||||||
fontSize: 14,
|
fontSize: 12,
|
||||||
color: textPrimary,
|
color: textSecondary,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
mySenderName: {
|
mySenderName: {
|
||||||
marginLeft: 0,
|
marginLeft: 0,
|
||||||
|
|||||||
@@ -1,10 +1,65 @@
|
|||||||
/**
|
/**
|
||||||
* Segment text extraction utilities
|
* Segment utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MessageSegment, TextSegmentData, AtSegmentData, FileSegmentData, FaceSegmentData, LinkSegmentData, PostRefSegmentData } from './message';
|
import type { MessageSegment, TextSegmentData, AtSegmentData, FileSegmentData, FaceSegmentData, LinkSegmentData, PostRefSegmentData } from './message';
|
||||||
import type { UserDTO } from './user';
|
import type { UserDTO } from './user';
|
||||||
|
|
||||||
|
// ==================== Chunk Types ====================
|
||||||
|
|
||||||
|
export type ContentChunk =
|
||||||
|
| { kind: 'inline'; parts: MessageSegment[] }
|
||||||
|
| { kind: 'images'; parts: MessageSegment[] }
|
||||||
|
| { kind: 'block'; segment: MessageSegment };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 segments 分成三种 chunk:
|
||||||
|
* - inline: text / at / face 等行内元素
|
||||||
|
* - images: 连续的图片 segment 组合
|
||||||
|
* - block: vote / post_ref / video / file / link / voice 等块级元素
|
||||||
|
*
|
||||||
|
* 与聊天和帖子详情共用同一套分组逻辑。
|
||||||
|
*/
|
||||||
|
export function partitionSegments(segments: MessageSegment[]): ContentChunk[] {
|
||||||
|
const out: ContentChunk[] = [];
|
||||||
|
let inlineBuf: MessageSegment[] = [];
|
||||||
|
const flushInline = () => {
|
||||||
|
if (inlineBuf.length) {
|
||||||
|
out.push({ kind: 'inline', parts: [...inlineBuf] });
|
||||||
|
inlineBuf = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const s of segments) {
|
||||||
|
if (s.type === 'image') {
|
||||||
|
flushInline();
|
||||||
|
const last = out[out.length - 1];
|
||||||
|
if (last?.kind === 'images') {
|
||||||
|
last.parts.push(s);
|
||||||
|
} else {
|
||||||
|
out.push({ kind: 'images', parts: [s] });
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
s.type === 'vote' ||
|
||||||
|
s.type === 'post_ref' ||
|
||||||
|
s.type === 'video' ||
|
||||||
|
s.type === 'file' ||
|
||||||
|
s.type === 'link' ||
|
||||||
|
s.type === 'voice'
|
||||||
|
) {
|
||||||
|
flushInline();
|
||||||
|
out.push({ kind: 'block', segment: s });
|
||||||
|
} else if (s.type === 'text' || s.type === 'at' || s.type === 'face') {
|
||||||
|
inlineBuf.push(s);
|
||||||
|
} else {
|
||||||
|
// 未知类型也当作 inline 处理,避免丢失内容
|
||||||
|
inlineBuf.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushInline();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract plain text from message segments for display
|
* Extract plain text from message segments for display
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user