feat(business): implement block-based content editing and rich text rendering
All checks were successful
Frontend CI / ota-android (push) Successful in 1m18s
Frontend CI / ota-ios (push) Successful in 1m31s
Frontend CI / build-and-push-web (push) Successful in 2m49s
Frontend CI / build-android-apk (push) Successful in 1h18m20s

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:
2026-05-08 01:57:05 +08:00
parent ea9e51b0b0
commit d4c3e1f268
18 changed files with 1416 additions and 234 deletions

View 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;

View 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;

View 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;

View 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;
}

View 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();
}

View File

@@ -0,0 +1,2 @@
export { default } from './BlockEditor';
export type { BlockEditorHandle } from './BlockEditor';

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

View File

@@ -16,13 +16,12 @@ import React, { useMemo, useState, memo, useEffect } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { PostCardProps, PostCardAction } from './types';
import { ImageGridItem, SmartImage } from '../../common';
import { ImageGridItem, SmartImage, ImageGrid } from '../../common';
import Text from '../../common/Text';
import HighlightText from '../../common/HighlightText';
import Avatar from '../../common/Avatar';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
import { getPreviewImageUrl } from '../../../utils/imageHelper';
import PostImages from './components/PostImages';
import PostContentRenderer from '../PostContentRenderer';
import { useResponsive } from '../../../hooks';
import { formatPostCreatedAtString } from '../../../core/entities/Post';
@@ -55,6 +54,13 @@ function imagesSignature(
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 {
if (!author) return '';
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 (authorSignature(a.author) !== authorSignature(b.author)) 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 (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
@@ -426,6 +433,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
const rawContent = post.content ?? '';
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 handleUserPress = () => emit({ type: 'userPress' });
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)
? 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,
}))
: [];
const segmentImages: ImageGridItem[] = (post.segments || [])
.filter((s: any) => s.type === 'image' && s.data?.url)
.map((s: any, i: number) => ({
id: `seg-${i}`,
url: s.data.url,
width: s.data.width,
height: s.data.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 => {
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
@@ -538,10 +573,10 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
return `${value.substring(0, maxLength)}...`;
};
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
const shouldTruncate = !showGrid && !isCompact && displayContent.length > getResponsiveMaxLength();
const content = useMemo(
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
() => (showGrid || isCompact ? displayContent : getTruncatedContent(displayContent)),
[displayContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
);
const renderImages = () => {
@@ -563,10 +598,24 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
}
return (
<PostImages
images={post.images || []}
displayMode="list"
<ImageGrid
images={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,
}))}
maxDisplayCount={isWideScreen ? 12 : 9}
mode="auto"
gap={isDesktop ? 4 : 2}
borderRadius={isDesktop ? borderRadius.xl : borderRadius.md}
showMoreOverlay={true}
onImagePress={handleImagePress}
displayMode="list"
usePreview={true}
/>
);
};
@@ -704,24 +753,12 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
{!isCompact && !!content && (
<>
{post.segments && post.segments.length > 0 ? (
<PostContentRenderer
content={content}
segments={post.segments}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
style={styles.contentRenderer}
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>
)}
<Text
style={styles.content}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
>
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
</Text>
{shouldTruncate && (
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
@@ -730,6 +767,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
</>
)}
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
{renderImages()}
{renderTopComment()}

View File

@@ -167,7 +167,7 @@ export interface PostContentProps {
* PostImages 子组件 Props
*/
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';
onImagePress: (images: ImageGridItem[], index: number) => void;
}

View File

@@ -1,16 +1,24 @@
/**
* PostContentRenderer - 帖子内容渲染器
* 根据 segments 渲染富文本内容(@提及、投票等)
* 根据 segments 渲染富文本内容(@提及、投票、内嵌图片等)
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
*/
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 HighlightText from '../common/HighlightText';
import { useAppColors } from '../../theme';
import { MessageSegment, AtSegmentData, VoteSegmentData, PostRefSegmentData } from '../../types';
import {
MessageSegment,
AtSegmentData,
VoteSegmentData,
PostRefSegmentData,
ImageSegmentData,
partitionSegments,
} from '../../types';
import PostRefCard from './PostRefCard';
interface PostContentRendererProps {
@@ -19,24 +27,35 @@ interface PostContentRendererProps {
numberOfLines?: number;
onMentionPress?: (userId: string) => void;
onPostRefPress?: (postId: string) => void;
onImagePress?: (url: string) => void;
style?: any;
textStyle?: any;
highlightKeyword?: string;
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> = ({
content,
segments,
numberOfLines,
onMentionPress,
onPostRefPress,
onImagePress,
style,
textStyle,
highlightKeyword,
highlightStyle,
}) => {
const colors = useAppColors();
const hasImages = hasInlineImages(segments);
if (!segments || segments.length === 0) {
return (
@@ -50,115 +69,198 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
);
}
const elements: React.ReactNode[] = [];
let keyIndex = 0;
const hasComplexContent = hasImages ||
segments.some(s => s.type === 'vote' || s.type === 'post_ref');
for (const segment of segments) {
const key = `seg_${keyIndex++}`;
if (!hasComplexContent || (numberOfLines && !hasImages)) {
const elements: React.ReactNode[] = [];
let keyIndex = 0;
switch (segment.type) {
case 'text': {
const text = segment.data?.text || segment.data?.content || '';
if (text) {
for (const segment of segments) {
const key = `seg_${keyIndex++}`;
switch (segment.type) {
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(
<Text key={key} selectable style={textStyle}>
{highlightKeyword ? (
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
) : (
text
)}
<Text
key={key}
selectable
style={[
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>
);
}
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(
<Text
key={key}
selectable
style={[
textStyle,
{
color: colors.primary.main,
fontWeight: '500',
},
]}
>
@{displayName}{' '}
</Text>
);
break;
}
if (chunk.kind === 'images') {
return (
<View key={`imgs-${i}`} style={styles.inlineImagesContainer}>
{chunk.parts.map((segment, j) => {
const imgData = segment.data as ImageSegmentData;
const url = imgData?.url || '';
if (!url) return null;
case 'vote': {
const voteData = segment.data as VoteSegmentData;
if (voteData?.options?.length) {
elements.push(
<View key={key} style={[styles.voteBlock, { borderColor: colors.divider }]}>
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
📊 {voteData.options.length}
</Text>
{voteData.options.map((opt, i) => (
<Text key={`vote_opt_${i}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
{i + 1}. {opt.content}
</Text>
))}
const aspectRatio = imgData.width && imgData.height
? imgData.width / imgData.height
: 4 / 3;
const displayWidth = INLINE_IMAGE_MAX_WIDTH;
const displayHeight = displayWidth / aspectRatio;
return (
<TouchableOpacity
key={`img-${i}-${j}`}
activeOpacity={0.9}
onPress={() => onImagePress?.(url)}
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>
);
}
break;
}
case 'post_ref': {
const refData = segment.data as PostRefSegmentData;
elements.push(
<PostRefCard
key={key}
data={refData}
compact={!!numberOfLines}
onPress={onPostRefPress}
/>
);
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 { segment } = chunk;
switch (segment.type) {
case 'vote': {
const voteData = segment.data as VoteSegmentData;
if (!voteData?.options?.length) return null;
return (
<View key={`blk-${i}`} style={[styles.voteBlock, { borderColor: colors.divider }]}>
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
{voteData.options.length}
</Text>
{voteData.options.map((opt, k) => (
<Text key={`vote_opt_${k}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
{k + 1}. {opt.content}
</Text>
))}
</View>
);
}
case 'post_ref': {
const refData = segment.data as PostRefSegmentData;
return (
<PostRefCard
key={`blk-${i}`}
data={refData}
compact={false}
onPress={onPostRefPress}
/>
);
}
default:
return null;
}
})}
</View>
);
};
const styles = StyleSheet.create({
@@ -167,6 +269,15 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
alignItems: 'flex-start',
},
chunkContainer: {
flexDirection: 'column',
},
inlineImagesContainer: {
marginVertical: 8,
},
imageSpacing: {
marginBottom: 6,
},
voteBlock: {
width: '100%',
borderWidth: 1,

View File

@@ -24,6 +24,8 @@ export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview';
export { default as PostContentRenderer } from './PostContentRenderer';
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 ShareSheet } from './ShareSheet';
export { TradeCard } from './TradeCard/TradeCard';

View File

@@ -5,6 +5,8 @@
// ==================== 帖子状态枚举 ====================
import type { MessageSegment } from '../../types/dto/message';
export type PostStatus = 'published' | 'draft' | 'deleted';
// ==================== 值对象 ====================
@@ -73,6 +75,8 @@ export interface Post {
isFavorited: boolean;
/** 是否置顶 (camelCase) */
isPinned: boolean;
/** 帖子内容段落(图文混排) */
segments?: MessageSegment[];
/** 帖子状态 */
status: PostStatus;
/** 所属频道ID */

View File

@@ -27,6 +27,7 @@ interface PostApiResponse {
user_id: string;
title: string;
content: string;
segments?: any[];
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
likes_count: number;
comments_count: number;
@@ -110,6 +111,7 @@ export class PostRepository implements IPostRepository {
} : undefined,
title: model.title,
content: model.content,
segments: response.segments,
images: (model.images || []).map(url => ({
url,
width: undefined,

View File

@@ -41,6 +41,7 @@ import { ApiError } from '@/services/core';
import { uploadService } from '@/services/upload';
import VoteEditor from '../../components/business/VoteEditor';
import PostMentionInput from '../../components/business/PostMentionInput';
import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor';
import { useResponsive, useResponsiveValue } from '../../hooks';
import * as hrefs from '../../navigation/hrefs';
import { MessageSegment } from '../../types';
@@ -135,12 +136,16 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
const [posting, setPosting] = useState(false);
const [loadingPost, setLoadingPost] = useState(false);
// 长文模式:图片作为 image segment 嵌入文本
const [isLongPostMode, setIsLongPostMode] = useState(false);
// 投票相关状态
const [isVotePost, setIsVotePost] = useState(false);
const [voteOptions, setVoteOptions] = useState<string[]>(['', '']); // 默认2个选项
// 内容输入框引用
const contentInputRef = React.useRef<TextInput>(null);
const blockEditorRef = React.useRef<BlockEditorHandle>(null);
const [selection, setSelection] = useState({ start: 0, end: 0 });
// 动画值
@@ -218,7 +223,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
loadPostForEdit();
}, [isEditMode, editPostID, router]);
// 选择图片
// 选择图片(普通模式)
const handlePickImage = async () => {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
@@ -308,7 +313,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
setImages(images.filter((_, i) => i !== index));
};
// 选择频道(单选,可再次点击取消)
// 选择频道(单选,可再次点击取消)
const handleSelectChannel = useCallback((channelId: string) => {
setSelectedChannelId(prev => (prev === channelId ? null : channelId));
setShowChannelPicker(false);
@@ -316,6 +321,10 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 插入表情
const handleInsertEmoji = (emoji: string) => {
if (isLongPostMode) {
blockEditorRef.current?.insertTextAtCursor(emoji);
return;
}
const newContent = content.slice(0, selection.start) + emoji + content.slice(selection.end);
setContent(newContent);
const newPosition = selection.start + emoji.length;
@@ -381,24 +390,34 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
return;
}
if (!content.trim()) {
const contentToCheck = isLongPostMode
? (blockEditorRef.current?.getContent() || content)
: content;
if (!contentToCheck.trim()) {
Alert.alert('错误', '请输入帖子内容');
return;
}
// 检查是否有图片正在上传
const uploadingImages = images.filter(img => img.uploading);
if (uploadingImages.length > 0) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
return;
if (isLongPostMode) {
if (blockEditorRef.current?.hasUploadingImages()) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
return;
}
} else {
const uploadingImages = images.filter(img => img.uploading);
if (uploadingImages.length > 0) {
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
return;
}
}
setPosting(true);
try {
const imageUrls = images.map(img => img.uri);
if (isVotePost) {
const imageUrls = images.map(img => img.uri);
// 验证投票选项
const validOptions = voteOptions.filter(opt => opt.trim() !== '');
if (validOptions.length < 2) {
@@ -406,7 +425,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
setPosting(false);
return;
}
// 创建投票帖子
await voteService.createVotePost({
title: title.trim(),
@@ -422,7 +441,35 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
duration: 2600,
});
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 {
// 普通模式
const imageUrls = images.map(img => img.uri);
if (isEditMode && editPostID) {
const updated = await postService.updatePost(editPostID, {
title: title.trim(),
@@ -433,12 +480,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
if (!updated) {
throw new Error('更新帖子失败');
}
showPrompt({
type: 'success',
title: '修改成功',
message: '帖子内容已更新',
duration: 2200,
});
showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 });
router.replace(hrefs.hrefHome());
} else {
await postService.createPost({
@@ -448,12 +490,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
images: imageUrls.length > 0 ? imageUrls : undefined,
channel_id: selectedChannelId || undefined,
});
showPrompt({
type: 'info',
title: '审核中',
message: '帖子已提交,内容审核中,稍后展示',
duration: 2600,
});
showPrompt({ type: 'info', title: '审核中', message: '帖子已提交,内容审核中,稍后展示', duration: 2600 });
router.replace(hrefs.hrefHome());
}
}
@@ -619,23 +656,37 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
maxLength={MAX_TITLE_LENGTH}
/>
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
<PostMentionInput
value={content}
onChangeText={setContent}
onSegmentsChange={setSegments}
placeholder="添加正文"
maxLength={MAX_CONTENT_LENGTH}
minHeight={contentInputMinHeight}
autoExpand
style={[
styles.contentInput,
isWideScreen && styles.contentInputWide,
]}
/>
{isLongPostMode ? (
<BlockEditor
ref={blockEditorRef}
placeholder="添加正文"
maxLength={MAX_CONTENT_LENGTH}
textStyle={[
styles.contentInput,
isWideScreen && styles.contentInputWide,
]}
onContentChange={setContent}
/>
) : (
<>
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
<PostMentionInput
value={content}
onChangeText={setContent}
onSegmentsChange={setSegments}
placeholder="添加正文"
maxLength={MAX_CONTENT_LENGTH}
minHeight={contentInputMinHeight}
autoExpand
style={[
styles.contentInput,
isWideScreen && styles.contentInputWide,
]}
/>
{renderImageGrid()}
</>
)}
{/* 作为正文容器的子模块,追加在文本后面 */}
{renderImageGrid()}
{renderVoteEditor()}
</View>
);
@@ -688,7 +739,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
<View style={styles.toolbarLeft}>
<TouchableOpacity
style={styles.toolbarButton}
onPress={handlePickImage}
onPress={isLongPostMode ? () => blockEditorRef.current?.insertImage() : handlePickImage}
disabled={posting}
>
<View style={styles.toolbarButtonInner}>
@@ -700,19 +751,21 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
</View>
</TouchableOpacity>
<TouchableOpacity
style={styles.toolbarButton}
onPress={handleTakePhoto}
disabled={posting}
>
<View style={styles.toolbarButtonInner}>
<MaterialCommunityIcons
name="camera-outline"
size={24}
color={colors.text.secondary}
/>
</View>
</TouchableOpacity>
{!isLongPostMode && (
<TouchableOpacity
style={styles.toolbarButton}
onPress={handleTakePhoto}
disabled={posting}
>
<View style={styles.toolbarButtonInner}>
<MaterialCommunityIcons
name="camera-outline"
size={24}
color={colors.text.secondary}
/>
</View>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.toolbarButton}
@@ -742,6 +795,21 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
/>
</View>
</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 File

@@ -1125,11 +1125,12 @@ export const PostDetailScreen: React.FC = () => {
lineHeight: isDesktop ? 32 : isTablet ? 30 : 28
}
]}
onImagePress={(url) => handleImagePress([{ uri: url }], 0)}
/>
</View>
{/* 图片 - 详情页显示所有图片,不限制数量 */}
{postImages.length > 0 && (
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
{!post.segments?.some(s => s.type === 'image') && postImages.length > 0 && (
<ImageGrid
images={postImages}
mode="auto"

View File

@@ -39,43 +39,7 @@ const IMAGE_MAX_WIDTH = 200;
const IMAGE_MAX_HEIGHT = 260;
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
/** 将非 reply 的 segments 分成:行内文案/@、连续图片组、其它块级(音视频文件等) */
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;
}
import { partitionSegments, type ContentChunk } from '../../../../types/dto';
// Segment 渲染器 Props
export interface SegmentRendererProps {
@@ -741,7 +705,7 @@ const MessageSegmentsRendererInner: React.FC<{
const replySegment = useMemo(() => segments.find(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(() => ({
isMe,

View File

@@ -296,11 +296,11 @@ export function createChatScreenStyles(colors: AppColors, dynamicStyles?: {
alignItems: 'flex-start',
},
senderName: {
fontSize: 14,
color: textPrimary,
fontSize: 12,
color: textSecondary,
marginBottom: 4,
marginLeft: 4,
fontWeight: '600',
fontWeight: '500',
},
mySenderName: {
marginLeft: 0,

View File

@@ -1,10 +1,65 @@
/**
* Segment text extraction utilities
* Segment utilities
*/
import type { MessageSegment, TextSegmentData, AtSegmentData, FileSegmentData, FaceSegmentData, LinkSegmentData, PostRefSegmentData } from './message';
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
*/