From d4c3e1f2689f6653c09fb32d59682289665a6802 Mon Sep 17 00:00:00 2001 From: lan Date: Fri, 8 May 2026 01:57:05 +0800 Subject: [PATCH] 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. --- .../business/BlockEditor/BlockEditor.tsx | 118 +++++++ .../business/BlockEditor/ImageBlockView.tsx | 92 ++++++ .../business/BlockEditor/TextBlockInput.tsx | 276 ++++++++++++++++ .../business/BlockEditor/blockEditorTypes.ts | 103 ++++++ .../business/BlockEditor/blocksToSegments.ts | 57 ++++ src/components/business/BlockEditor/index.ts | 2 + .../business/BlockEditor/useBlockEditor.ts | 289 ++++++++++++++++ src/components/business/PostCard/PostCard.tsx | 112 ++++--- src/components/business/PostCard/types.ts | 2 +- .../business/PostContentRenderer.tsx | 311 ++++++++++++------ src/components/business/index.ts | 2 + src/core/entities/Post.ts | 4 + src/data/repositories/PostRepository.ts | 2 + src/screens/create/CreatePostScreen.tsx | 172 +++++++--- src/screens/home/PostDetailScreen.tsx | 5 +- .../components/ChatScreen/SegmentRenderer.tsx | 40 +-- .../message/components/ChatScreen/styles.ts | 6 +- src/types/dto/segmentUtils.ts | 57 +++- 18 files changed, 1416 insertions(+), 234 deletions(-) create mode 100644 src/components/business/BlockEditor/BlockEditor.tsx create mode 100644 src/components/business/BlockEditor/ImageBlockView.tsx create mode 100644 src/components/business/BlockEditor/TextBlockInput.tsx create mode 100644 src/components/business/BlockEditor/blockEditorTypes.ts create mode 100644 src/components/business/BlockEditor/blocksToSegments.ts create mode 100644 src/components/business/BlockEditor/index.ts create mode 100644 src/components/business/BlockEditor/useBlockEditor.ts diff --git a/src/components/business/BlockEditor/BlockEditor.tsx b/src/components/business/BlockEditor/BlockEditor.tsx new file mode 100644 index 0000000..e02f8c6 --- /dev/null +++ b/src/components/business/BlockEditor/BlockEditor.tsx @@ -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; + 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( + ({ 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 ( + + + {editor.blocks.map(block => { + if (block.type === 'text') { + return ( + {}} + inputRef={(ref) => editor.registerInputRef(block.id, ref)} + style={textStyle} + /> + ); + } + + return ( + + ); + })} + + + ); + }, +); + +BlockEditor.displayName = 'BlockEditor'; + +const styles = StyleSheet.create({ + container: { + flexGrow: 1, + }, + scroll: { + flexGrow: 1, + }, + scrollContent: { + flexGrow: 1, + paddingBottom: spacing.xs, + }, +}); + +export default BlockEditor; \ No newline at end of file diff --git a/src/components/business/BlockEditor/ImageBlockView.tsx b/src/components/business/BlockEditor/ImageBlockView.tsx new file mode 100644 index 0000000..b7f1994 --- /dev/null +++ b/src/components/business/BlockEditor/ImageBlockView.tsx @@ -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 = ({ 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 ( + + + {block.uploading && ( + + + + )} + onRemove(block.id)} + hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} + > + + + + + + ); +}; + +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; \ No newline at end of file diff --git a/src/components/business/BlockEditor/TextBlockInput.tsx b/src/components/business/BlockEditor/TextBlockInput.tsx new file mode 100644 index 0000000..289055b --- /dev/null +++ b/src/components/business/BlockEditor/TextBlockInput.tsx @@ -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) => 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 = ({ + block, + isActive, + placeholder, + maxLength, + onTextChange, + onFocusBlock, + onSelectMention, + inputRef, + style, + minHeight, +}) => { + const colors = useAppColors(); + const currentUser = useAuthStore(s => s.currentUser); + const [followingUsers, setFollowingUsers] = useState([]); + 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(); + // 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 ( + + setSelection(e.nativeEvent.selection)} + onFocus={() => onFocusBlock(block.id)} + style={[ + styles.input, + { + color: colors.text.primary, + minHeight: minHeight, + }, + style, + ]} + /> + + {suggestionMode === 'mention' && isActive && filteredUsers.length > 0 && ( + + + {filteredUsers.map(item => ( + handleSelectMention(item)} + activeOpacity={0.7} + > + + + + {item.nickname} + + + 点击提及 + + + + + ))} + + + )} + + {suggestionMode === 'mention' && isActive && filteredUsers.length === 0 && ( + + + 没有找到匹配的关注用户 + + + )} + + ); +}; + +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; \ No newline at end of file diff --git a/src/components/business/BlockEditor/blockEditorTypes.ts b/src/components/business/BlockEditor/blockEditorTypes.ts new file mode 100644 index 0000000..240071e --- /dev/null +++ b/src/components/business/BlockEditor/blockEditorTypes.ts @@ -0,0 +1,103 @@ +import { MessageSegment, AtSegmentData } from '../../../types'; + +export interface TextBlock { + id: string; + type: 'text'; + text: string; + activeMentions: Map; +} + +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, + cursorPos: number, +): Map { + const result = new Map(); + for (const [start, mention] of mentions) { + if (mention.endIndex <= cursorPos) { + result.set(start, { ...mention }); + } + } + return result; +} + +export function remapMentionsAfter( + mentions: Map, + cursorPos: number, + textLength: number, +): Map { + const result = new Map(); + 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, + m1TextLength: number, + m2: Map, +): Map { + 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, +): 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; +} diff --git a/src/components/business/BlockEditor/blocksToSegments.ts b/src/components/business/BlockEditor/blocksToSegments.ts new file mode 100644 index 0000000..6e047e4 --- /dev/null +++ b/src/components/business/BlockEditor/blocksToSegments.ts @@ -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(); +} \ No newline at end of file diff --git a/src/components/business/BlockEditor/index.ts b/src/components/business/BlockEditor/index.ts new file mode 100644 index 0000000..33741bd --- /dev/null +++ b/src/components/business/BlockEditor/index.ts @@ -0,0 +1,2 @@ +export { default } from './BlockEditor'; +export type { BlockEditorHandle } from './BlockEditor'; \ No newline at end of file diff --git a/src/components/business/BlockEditor/useBlockEditor.ts b/src/components/business/BlockEditor/useBlockEditor.ts new file mode 100644 index 0000000..ae5e31c --- /dev/null +++ b/src/components/business/BlockEditor/useBlockEditor.ts @@ -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([createInitialTextBlock()]); + const [activeBlockId, setActiveBlockId] = useState(null); + const inputRefs = useRef>(new Map()); + const blockCursors = useRef>(new Map()); + + const updateTextBlock = useCallback( + (blockId: string, text: string, mentions: Map) => { + 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(); + 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, + }; +} \ No newline at end of file diff --git a/src/components/business/PostCard/PostCard.tsx b/src/components/business/PostCard/PostCard.tsx index 498f4be..cdd76f9 100644 --- a/src/components/business/PostCard/PostCard.tsx +++ b/src/components/business/PostCard/PostCard.tsx @@ -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 = (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 = (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 = (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 = (normalizedProps) => { } return ( - ({ + 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 = (normalizedProps) => { {!isCompact && !!content && ( <> - {post.segments && post.segments.length > 0 ? ( - - ) : ( - - {highlightKeyword ? : content} - - )} + + {highlightKeyword ? : content} + {shouldTruncate && ( setIsExpanded((prev) => !prev)} style={styles.expandBtn}> {isExpanded ? '收起' : '展开全文'} @@ -730,6 +767,7 @@ const PostCardInner: React.FC = (normalizedProps) => { )} + {/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */} {renderImages()} {renderTopComment()} diff --git a/src/components/business/PostCard/types.ts b/src/components/business/PostCard/types.ts index 003f3a3..4d65c9c 100644 --- a/src/components/business/PostCard/types.ts +++ b/src/components/business/PostCard/types.ts @@ -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; } diff --git a/src/components/business/PostContentRenderer.tsx b/src/components/business/PostContentRenderer.tsx index 513fdcf..e3379db 100644 --- a/src/components/business/PostContentRenderer.tsx +++ b/src/components/business/PostContentRenderer.tsx @@ -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; } +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 = ({ 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 = ({ ); } - 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( + + {highlightKeyword ? ( + + ) : ( + 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( - - {highlightKeyword ? ( - - ) : ( - text - )} + + @{displayName}{' '} + + ); + break; + } + case 'image': + case 'link': + case 'face': + default: + break; + } + } + + if (elements.length === 0) { + return ( + + {highlightKeyword && content ? ( + + ) : ( + content || '' + )} + + ); + } + + if (numberOfLines) { + return ( + + + {elements} + + + ); + } + + return {elements}; + } + + const chunks = partitionSegments(segments); + + return ( + + {chunks.map((chunk, i) => { + if (chunk.kind === 'inline') { + return ( + + {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 ? ( + + ) : ( + {text} + ); + } + case 'at': { + const atData = segment.data as AtSegmentData; + const userId = atData?.user_id; + const displayName = atData?.nickname || (userId === 'all' ? '所有人' : '某人'); + return ( + onMentionPress?.(userId)} + > + @{displayName}{' '} + + ); + } + default: + return null; + } + })} ); } - 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( - - @{displayName}{' '} - - ); - break; - } + if (chunk.kind === 'images') { + return ( + + {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( - - - 📊 投票({voteData.options.length} 个选项) - - {voteData.options.map((opt, i) => ( - - {i + 1}. {opt.content} - - ))} + const aspectRatio = imgData.width && imgData.height + ? imgData.width / imgData.height + : 4 / 3; + const displayWidth = INLINE_IMAGE_MAX_WIDTH; + const displayHeight = displayWidth / aspectRatio; + + return ( + onImagePress?.(url)} + style={j < chunk.parts.length - 1 ? styles.imageSpacing : undefined} + > + + + ); + })} ); } - break; - } - case 'post_ref': { - const refData = segment.data as PostRefSegmentData; - elements.push( - - ); - break; - } - - case 'image': - case 'link': - case 'face': - default: - break; - } - } - - if (elements.length === 0) { - return ( - - {highlightKeyword && content ? ( - - ) : ( - content || '' - )} - - ); - } - - if (numberOfLines) { - return ( - - - {elements} - - - ); - } - - return {elements}; + const { segment } = chunk; + switch (segment.type) { + case 'vote': { + const voteData = segment.data as VoteSegmentData; + if (!voteData?.options?.length) return null; + return ( + + + 投票({voteData.options.length} 个选项) + + {voteData.options.map((opt, k) => ( + + {k + 1}. {opt.content} + + ))} + + ); + } + case 'post_ref': { + const refData = segment.data as PostRefSegmentData; + return ( + + ); + } + default: + return null; + } + })} + + ); }; 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, diff --git a/src/components/business/index.ts b/src/components/business/index.ts index 749640e..d44eeeb 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -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'; diff --git a/src/core/entities/Post.ts b/src/core/entities/Post.ts index 684d3b3..3589fcd 100644 --- a/src/core/entities/Post.ts +++ b/src/core/entities/Post.ts @@ -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 */ diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index 28f7923..7dc1cbd 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -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, diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 9a1d389..832ad16 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -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 = (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(['', '']); // 默认2个选项 // 内容输入框引用 const contentInputRef = React.useRef(null); + const blockEditorRef = React.useRef(null); const [selection, setSelection] = useState({ start: 0, end: 0 }); // 动画值 @@ -218,7 +223,7 @@ export const CreatePostScreen: React.FC = (props) => { loadPostForEdit(); }, [isEditMode, editPostID, router]); - // 选择图片 + // 选择图片(普通模式) const handlePickImage = async () => { const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); @@ -308,7 +313,7 @@ export const CreatePostScreen: React.FC = (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 = (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 = (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 = (props) => { setPosting(false); return; } - + // 创建投票帖子 await voteService.createVotePost({ title: title.trim(), @@ -422,7 +441,35 @@ export const CreatePostScreen: React.FC = (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 = (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 = (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 = (props) => { maxLength={MAX_TITLE_LENGTH} /> - {/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */} - + {isLongPostMode ? ( + + ) : ( + <> + {/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */} + + {renderImageGrid()} + + )} - {/* 作为正文容器的子模块,追加在文本后面 */} - {renderImageGrid()} {renderVoteEditor()} ); @@ -688,7 +739,7 @@ export const CreatePostScreen: React.FC = (props) => { blockEditorRef.current?.insertImage() : handlePickImage} disabled={posting} > @@ -700,19 +751,21 @@ export const CreatePostScreen: React.FC = (props) => { - - - - - + {!isLongPostMode && ( + + + + + + )} = (props) => { /> + + {/* 长文模式开关 */} + setIsLongPostMode(prev => !prev)} + disabled={posting} + > + + + + ); diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 5d16319..e13b980 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -1125,11 +1125,12 @@ export const PostDetailScreen: React.FC = () => { lineHeight: isDesktop ? 32 : isTablet ? 30 : 28 } ]} + onImagePress={(url) => handleImagePress([{ uri: url }], 0)} /> - {/* 图片 - 详情页显示所有图片,不限制数量 */} - {postImages.length > 0 && ( + {/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */} + {!post.segments?.some(s => s.type === 'image') && postImages.length > 0 && ( { - 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, diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index cc1ad71..3dd2295 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -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, diff --git a/src/types/dto/segmentUtils.ts b/src/types/dto/segmentUtils.ts index 937bae0..ebb12dc 100644 --- a/src/types/dto/segmentUtils.ts +++ b/src/types/dto/segmentUtils.ts @@ -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 */