feat(content): add rich content rendering with @mentions and vote segments
- Add PostContentRenderer component for rendering posts/comments with segments - Add PostMentionInput component for @mention support in post/comment creation - Add VoteSegmentData and vote segment type for unified post creation API - Update post and comment services to support segments in API requests - Fix auth service to clear tokens before login/register - Improve database initialization with proper close/retry logic - Add verification check before allowing post creation - Add metro web shims for react-native-webrtc full package and requireNativeComponent - Update build config with git hash based version numbers
This commit is contained in:
@@ -13,6 +13,7 @@ import { Comment, CommentImage } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||
import PostContentRenderer from './PostContentRenderer';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
@@ -614,9 +615,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
|
||||
{/* 评论文本 - 非气泡样式 */}
|
||||
<View style={styles.commentContent}>
|
||||
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
{comment.segments && comment.segments.length > 0 ? (
|
||||
<PostContentRenderer
|
||||
content={comment.content}
|
||||
segments={comment.segments}
|
||||
textStyle={[styles.text, { color: colors.text.primary }]}
|
||||
/>
|
||||
) : (
|
||||
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 评论图片 */}
|
||||
|
||||
@@ -23,6 +23,7 @@ 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/useResponsive';
|
||||
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
||||
|
||||
@@ -208,6 +209,9 @@ function createPostCardStyles(colors: AppColors) {
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: fontSizes.md * 1.45,
|
||||
},
|
||||
contentRenderer: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
expandBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
@@ -649,12 +653,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!isCompact && !!content && (
|
||||
<>
|
||||
<Text
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||
</Text>
|
||||
{post.segments && post.segments.length > 0 ? (
|
||||
<PostContentRenderer
|
||||
content={content}
|
||||
segments={post.segments}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
style={styles.contentRenderer}
|
||||
textStyle={styles.content}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
153
src/components/business/PostContentRenderer.tsx
Normal file
153
src/components/business/PostContentRenderer.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* PostContentRenderer - 帖子内容渲染器
|
||||
* 根据 segments 渲染富文本内容(@提及、投票等)
|
||||
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
|
||||
import Text from '../common/Text';
|
||||
import { useAppColors } from '../../theme';
|
||||
import { MessageSegment, AtSegmentData, VoteSegmentData } from '../../types';
|
||||
|
||||
interface PostContentRendererProps {
|
||||
content?: string;
|
||||
segments?: MessageSegment[];
|
||||
numberOfLines?: number;
|
||||
onMentionPress?: (userId: string) => void;
|
||||
style?: any;
|
||||
textStyle?: any;
|
||||
}
|
||||
|
||||
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
||||
content,
|
||||
segments,
|
||||
numberOfLines,
|
||||
onMentionPress,
|
||||
style,
|
||||
textStyle,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
|
||||
if (!segments || segments.length === 0) {
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
||||
{content || ''}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
let keyIndex = 0;
|
||||
|
||||
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} style={textStyle}>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'at': {
|
||||
const atData = segment.data as AtSegmentData;
|
||||
const userId = atData?.user_id;
|
||||
const isAtAll = userId === 'all';
|
||||
elements.push(
|
||||
<Text
|
||||
key={key}
|
||||
style={[
|
||||
textStyle,
|
||||
{
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{isAtAll ? '@所有人 ' : '@某人 '}
|
||||
</Text>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
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}`} style={[styles.voteOption, { color: colors.text.primary }]}>
|
||||
{i + 1}. {opt.content}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'image':
|
||||
case 'link':
|
||||
case 'face':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (elements.length === 0) {
|
||||
return (
|
||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
||||
{content || ''}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (numberOfLines) {
|
||||
return (
|
||||
<View style={style}>
|
||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
||||
{elements}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={[styles.container, style]}>{elements}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
voteBlock: {
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginTop: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
voteTitle: {
|
||||
fontSize: 13,
|
||||
marginBottom: 6,
|
||||
},
|
||||
voteOption: {
|
||||
fontSize: 14,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export default PostContentRenderer;
|
||||
269
src/components/business/PostMentionInput.tsx
Normal file
269
src/components/business/PostMentionInput.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* PostMentionInput - 帖子内容输入框(支持 @提及)
|
||||
* 复用关注列表加载逻辑
|
||||
* 检测输入中的 @ 字符,弹出关注者列表供选择
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
|
||||
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 { MessageSegment, AtSegmentData } from '../../types';
|
||||
|
||||
interface MentionUser {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
interface PostMentionInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSegmentsChange: (segments: MessageSegment[]) => void;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
style?: any;
|
||||
minHeight?: number;
|
||||
}
|
||||
|
||||
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
onSegmentsChange,
|
||||
placeholder = '说点什么...',
|
||||
maxLength = 2000,
|
||||
style,
|
||||
minHeight = 100,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const currentUser = useAuthStore(s => s.currentUser);
|
||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
||||
const [showMentions, setShowMentions] = useState(false);
|
||||
const [mentionQuery, setMentionQuery] = useState('');
|
||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
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 activeMentions = useRef(new Map<number, { endIndex: number; userId: string; nickname: string }>());
|
||||
const segmentsRef = useRef<MessageSegment[]>([]);
|
||||
|
||||
const rebuildSegments = useCallback(
|
||||
(text: string) => {
|
||||
const mentions = activeMentions.current;
|
||||
if (mentions.size === 0) {
|
||||
onSegmentsChange(text.trim() ? [{ type: 'text', data: { text } }] : []);
|
||||
return;
|
||||
}
|
||||
|
||||
const segments: MessageSegment[] = [];
|
||||
const sorted = Array.from(mentions.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) } });
|
||||
}
|
||||
|
||||
onSegmentsChange(segments);
|
||||
},
|
||||
[onSegmentsChange]
|
||||
);
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
(text: string) => {
|
||||
onChangeText(text);
|
||||
|
||||
const cursorPos = text.length;
|
||||
let atStart = -1;
|
||||
|
||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||
if (text[i] === '@') {
|
||||
atStart = i;
|
||||
break;
|
||||
}
|
||||
if (text[i] === ' ' || text[i] === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atStart >= 0) {
|
||||
const query = text.slice(atStart + 1, cursorPos);
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
setMentionQuery(query);
|
||||
setMentionStartIndex(atStart);
|
||||
setShowMentions(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowMentions(false);
|
||||
rebuildSegments(text);
|
||||
},
|
||||
[onChangeText, rebuildSegments]
|
||||
);
|
||||
|
||||
const handleSelectMention = useCallback(
|
||||
(mentionUser: MentionUser) => {
|
||||
if (mentionStartIndex < 0) return;
|
||||
|
||||
const before = value.slice(0, mentionStartIndex);
|
||||
const mentionText = `@${mentionUser.nickname} `;
|
||||
const newText = before + mentionText;
|
||||
|
||||
activeMentions.current.set(mentionStartIndex, {
|
||||
endIndex: before.length + mentionText.length,
|
||||
userId: mentionUser.id,
|
||||
nickname: mentionUser.nickname,
|
||||
});
|
||||
|
||||
onChangeText(newText);
|
||||
setShowMentions(false);
|
||||
setMentionQuery('');
|
||||
setMentionStartIndex(-1);
|
||||
|
||||
rebuildSegments(newText);
|
||||
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[value, mentionStartIndex, onChangeText, rebuildSegments]
|
||||
);
|
||||
|
||||
const renderMentionItem = ({ item }: { item: MentionUser }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
||||
onPress={() => handleSelectMention(item)}
|
||||
>
|
||||
<Avatar source={item.avatar} size={32} name={item.nickname} />
|
||||
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChangeText={handleChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={maxLength}
|
||||
multiline
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colors.text.primary,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{showMentions && filteredUsers.length > 0 && (
|
||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||
<FlatList
|
||||
data={filteredUsers}
|
||||
renderItem={renderMentionItem}
|
||||
keyExtractor={item => item.id}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
nestedScrollEnabled
|
||||
style={styles.mentionList}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showMentions && 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,
|
||||
padding: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
minHeight: 100,
|
||||
textAlignVertical: 'top' as const,
|
||||
},
|
||||
mentionPanel: {
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
maxHeight: 200,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
mentionList: {
|
||||
maxHeight: 200,
|
||||
},
|
||||
mentionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
mentionName: {
|
||||
fontSize: fontSizes.md,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: fontSizes.sm,
|
||||
padding: spacing.md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default PostMentionInput;
|
||||
@@ -22,5 +22,7 @@ export { default as TabBar } from './TabBar';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
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 ReportDialog } from './ReportDialog';
|
||||
export { default as ShareSheet } from './ShareSheet';
|
||||
|
||||
Reference in New Issue
Block a user