feat(post): add post reference functionality with inline cards and suggestion search
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m42s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled

Add support for referencing/quoting other posts within post content. Includes:
- New `post_ref` segment type with `PostRefSegmentData` interface for post references
- New `PostRefCard` component to display referenced posts inline
- Post suggestion search triggered by `#` in `PostMentionInput`
- Updated `PostContentRenderer` to render post reference segments
- Add `suggestPosts` and `recordRefClick` API methods in postService

Additional improvements:
- Add image gallery preview for user profile covers and avatars
- Make text selectable across CommentItem, PostContentRenderer, and PostDetailScreen
- Migrate FlatList to FlashList in PostDetailScreen and UserProfileScreen
- Add nestedScrollEnabled to CreatePostScreen
- Add clickable member avatars in GroupMembersScreen
This commit is contained in:
lafay
2026-04-26 01:11:30 +08:00
parent 5fa5403d6a
commit 2dc6936aac
11 changed files with 554 additions and 193 deletions

View File

@@ -493,7 +493,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
</View>
{/* 显示回复内容(如果有文字) */}
{reply.content ? (
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
<Text variant="body" color={colors.text.primary} selectable style={styles.subReplyBody}>
{reply.content}
</Text>
) : null}
@@ -625,7 +625,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
textStyle={[styles.text, { color: colors.text.primary }]}
/>
) : (
<Text variant="body" color={colors.text.primary} style={styles.text}>
<Text variant="body" color={colors.text.primary} selectable style={styles.text}>
{comment.content}
</Text>
)}

View File

@@ -9,13 +9,15 @@ import { View, TouchableOpacity, StyleSheet } from 'react-native';
import Text from '../common/Text';
import { useAppColors } from '../../theme';
import { MessageSegment, AtSegmentData, VoteSegmentData } from '../../types';
import { MessageSegment, AtSegmentData, VoteSegmentData, PostRefSegmentData } from '../../types';
import PostRefCard from './PostRefCard';
interface PostContentRendererProps {
content?: string;
segments?: MessageSegment[];
numberOfLines?: number;
onMentionPress?: (userId: string) => void;
onPostRefPress?: (postId: string) => void;
style?: any;
textStyle?: any;
}
@@ -25,6 +27,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
segments,
numberOfLines,
onMentionPress,
onPostRefPress,
style,
textStyle,
}) => {
@@ -32,7 +35,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
if (!segments || segments.length === 0) {
return (
<Text numberOfLines={numberOfLines} style={textStyle}>
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{content || ''}
</Text>
);
@@ -49,7 +52,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
const text = segment.data?.text || segment.data?.content || '';
if (text) {
elements.push(
<Text key={key} style={textStyle}>
<Text key={key} selectable style={textStyle}>
{text}
</Text>
);
@@ -65,6 +68,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
elements.push(
<Text
key={key}
selectable
style={[
textStyle,
{
@@ -88,7 +92,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
📊 {voteData.options.length}
</Text>
{voteData.options.map((opt, i) => (
<Text key={`vote_opt_${i}`} style={[styles.voteOption, { color: colors.text.primary }]}>
<Text key={`vote_opt_${i}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
{i + 1}. {opt.content}
</Text>
))}
@@ -98,6 +102,19 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
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':
@@ -108,7 +125,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
if (elements.length === 0) {
return (
<Text numberOfLines={numberOfLines} style={textStyle}>
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{content || ''}
</Text>
);
@@ -117,7 +134,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
if (numberOfLines) {
return (
<View style={style}>
<Text numberOfLines={numberOfLines} style={textStyle}>
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
{elements}
</Text>
</View>

View File

@@ -8,7 +8,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
TextInput,
FlatList,
ScrollView,
TouchableOpacity,
StyleSheet,
Platform,
@@ -20,6 +20,7 @@ import Avatar from '../common/Avatar';
import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme';
import { useAuthStore } from '../../stores';
import { authService } from '../../services/auth';
import { postService } from '../../services/post/postService';
import { MessageSegment, AtSegmentData } from '../../types';
interface MentionUser {
@@ -28,6 +29,14 @@ interface MentionUser {
avatar: string | null;
}
interface SuggestPost {
id: string;
title: string;
channel?: string;
}
type SuggestionMode = 'none' | 'mention' | 'postref';
interface PostMentionInputProps {
value: string;
onChangeText: (text: string) => void;
@@ -37,6 +46,7 @@ interface PostMentionInputProps {
style?: any;
minHeight?: number;
autoExpand?: boolean;
onPostRefPasted?: (postId: string, title: string) => void;
}
const PostMentionInput: React.FC<PostMentionInputProps> = ({
@@ -48,14 +58,17 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
style,
minHeight = 100,
autoExpand = false,
onPostRefPasted,
}) => {
const colors = useAppColors();
const currentUser = useAuthStore(s => s.currentUser);
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
const [showMentions, setShowMentions] = useState(false);
const [suggestPosts, setSuggestPosts] = useState<SuggestPost[]>([]);
const [suggestionMode, setSuggestionMode] = useState<SuggestionMode>('none');
const [mentionQuery, setMentionQuery] = useState('');
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
const [loaded, setLoaded] = useState(false);
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<TextInput>(null);
useEffect(() => {
@@ -125,8 +138,42 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
onChangeText(text);
const cursorPos = text.length;
let atStart = -1;
// Check for # trigger (post ref search)
let hashStart = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === '#') {
hashStart = i;
break;
}
if (text[i] === ' ' || text[i] === '\n') {
break;
}
}
if (hashStart >= 0) {
const query = text.slice(hashStart + 1, cursorPos);
if (query.length >= 1 && !query.includes(' ') && !query.includes('\n')) {
if (postSearchTimer) {
clearTimeout(postSearchTimer);
}
const timer = setTimeout(async () => {
try {
const results = await postService.suggestPosts(query, 8);
setSuggestPosts(results);
setSuggestionMode('postref');
setMentionStartIndex(hashStart);
} catch {
// ignore
}
}, 300);
setPostSearchTimer(timer);
return;
}
}
// Check for @ trigger (mention)
let atStart = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (text[i] === '@') {
atStart = i;
@@ -142,15 +189,15 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
if (!query.includes(' ') && !query.includes('\n')) {
setMentionQuery(query);
setMentionStartIndex(atStart);
setShowMentions(true);
setSuggestionMode('mention');
return;
}
}
setShowMentions(false);
setSuggestionMode('none');
rebuildSegments(text);
},
[onChangeText, rebuildSegments]
[onChangeText, rebuildSegments, postSearchTimer],
);
const handleSelectMention = useCallback(
@@ -168,7 +215,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
});
onChangeText(newText);
setShowMentions(false);
setSuggestionMode('none');
setMentionQuery('');
setMentionStartIndex(-1);
@@ -176,7 +223,31 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
inputRef.current?.focus();
},
[value, mentionStartIndex, onChangeText, rebuildSegments]
[value, mentionStartIndex, onChangeText, rebuildSegments],
);
const handleSelectPost = useCallback(
(post: SuggestPost) => {
if (mentionStartIndex < 0) return;
const before = value.slice(0, mentionStartIndex);
const refText = `#${post.title} `;
const newText = before + refText;
onChangeText(newText);
setSuggestionMode('none');
setSuggestPosts([]);
setMentionStartIndex(-1);
if (onPostRefPasted) {
onPostRefPasted(post.id, post.title);
}
rebuildSegments(newText);
inputRef.current?.focus();
},
[value, mentionStartIndex, onChangeText, rebuildSegments, onPostRefPasted],
);
const renderMentionItem = ({ item }: { item: MentionUser }) => (
@@ -221,26 +292,66 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
]}
/>
{showMentions && filteredUsers.length > 0 && (
{suggestionMode === 'mention' && filteredUsers.length > 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<FlatList
data={filteredUsers}
renderItem={renderMentionItem}
keyExtractor={item => item.id}
<ScrollView
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
style={styles.mentionList}
/>
>
{filteredUsers.map(item => (
<React.Fragment key={item.id}>
{renderMentionItem({ item })}
</React.Fragment>
))}
</ScrollView>
</View>
)}
{showMentions && filteredUsers.length === 0 && (
{suggestionMode === 'mention' && filteredUsers.length === 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
</Text>
</View>
)}
{suggestionMode === 'postref' && suggestPosts.length > 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<ScrollView
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
style={styles.mentionList}
>
{suggestPosts.map(item => (
<TouchableOpacity
key={item.id}
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
onPress={() => handleSelectPost(item)}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="file-document-outline" size={18} color={colors.primary.main} />
<View style={styles.mentionInfo}>
<Text style={[styles.mentionName, { color: colors.text.primary }]} numberOfLines={1}>
{item.title}
</Text>
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
</Text>
</View>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
{suggestionMode === 'postref' && suggestPosts.length === 0 && (
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
</Text>
</View>
)}
</View>
);
};

View File

@@ -0,0 +1,124 @@
import React, { useCallback } from 'react';
import { View, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import Text from '../common/Text';
import { useAppColors, spacing, borderRadius } from '../../theme';
import type { PostRefSegmentData } from '../../types';
interface PostRefCardProps {
data: PostRefSegmentData;
compact?: boolean;
onPress?: (postId: string) => void;
}
const PostRefCard: React.FC<PostRefCardProps> = ({ data, compact = false, onPress }) => {
const colors = useAppColors();
const router = useRouter();
const handlePress = useCallback(() => {
if (onPress) {
onPress(data.post_id);
} else {
router.push(`/post/${data.post_id}` as any);
}
}, [data.post_id, onPress, router]);
const isDeleted = data.status === 'deleted';
const isHidden = data.status === 'hidden';
const isUnavailable = isDeleted || isHidden || !data.accessible;
const authorName = data.author?.nickname || '';
if (isUnavailable) {
return (
<View
style={[
styles.container,
{ backgroundColor: colors.background.default, borderColor: colors.divider },
compact && styles.containerCompact,
]}
>
<MaterialCommunityIcons name="link-off" size={16} color={colors.text.hint} />
<Text style={[styles.unavailableText, { color: colors.text.hint }]} numberOfLines={1}>
{isDeleted ? '该帖子已删除' : isHidden ? '该帖子不可见' : '请登录后查看'}
</Text>
</View>
);
}
return (
<TouchableOpacity
style={[
styles.container,
{ backgroundColor: colors.background.default, borderColor: colors.primary.light },
compact && styles.containerCompact,
]}
onPress={handlePress}
activeOpacity={0.7}
>
<View style={[styles.iconWrap, { backgroundColor: colors.primary.light }]}>
<MaterialCommunityIcons name="file-document-outline" size={14} color={colors.primary.main} />
</View>
<View style={styles.contentWrap}>
<Text
style={[styles.title, { color: colors.text.primary }]}
numberOfLines={compact ? 1 : 2}
>
{data.title || '帖子'}
</Text>
{authorName ? (
<Text style={[styles.author, { color: colors.text.secondary }]} numberOfLines={1}>
@{authorName}
</Text>
) : null}
</View>
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.text.hint} />
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: borderRadius.md,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginTop: spacing.xs,
marginBottom: spacing.xs,
},
containerCompact: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
},
iconWrap: {
width: 24,
height: 24,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm,
},
contentWrap: {
flex: 1,
justifyContent: 'center',
},
title: {
fontSize: 13,
fontWeight: '500',
lineHeight: 18,
},
author: {
fontSize: 11,
lineHeight: 14,
marginTop: 1,
},
unavailableText: {
fontSize: 13,
marginLeft: spacing.sm,
},
});
export default PostRefCard;

View File

@@ -20,6 +20,8 @@ import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '
import { User } from '../../types';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
import { ImageGallery } from '../common';
import type { GalleryImageItem } from '../common';
import { useResponsive } from '../../hooks';
interface UserProfileHeaderProps {
@@ -55,6 +57,9 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
const [menuVisible, setMenuVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const moreButtonRef = useRef<View>(null);
const [galleryVisible, setGalleryVisible] = useState(false);
const [galleryImages, setGalleryImages] = useState<GalleryImageItem[]>([]);
const [galleryIndex, setGalleryIndex] = useState(0);
const formatCount = (count: number | undefined): string => {
if (count === undefined || count === null) return '0';
@@ -106,6 +111,24 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
onBlock?.();
}, [onBlock]);
const openImageGallery = useCallback((images: GalleryImageItem[], index: number = 0) => {
setGalleryImages(images);
setGalleryIndex(index);
setGalleryVisible(true);
}, []);
const handleCoverPress = useCallback(() => {
if (user.cover_url) {
openImageGallery([{ id: 'cover', url: user.cover_url }], 0);
}
}, [user.cover_url, openImageGallery]);
const handleAvatarPressPreview = useCallback(() => {
if (user.avatar) {
openImageGallery([{ id: 'avatar', url: user.avatar }], 0);
}
}, [user.avatar, openImageGallery]);
const renderStatLink = (count: number, label: string, onPress?: () => void) => {
if (!onPress) {
return (
@@ -165,7 +188,12 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
<View style={styles.container}>
{/* 全宽封面背景 */}
<View style={[styles.coverContainer, { height: coverHeight }]}>
<View style={styles.coverTouchable}>
<TouchableOpacity
style={styles.coverTouchable}
onPress={handleCoverPress}
activeOpacity={0.9}
disabled={!user.cover_url}
>
{user.cover_url ? (
<Image
source={{ uri: user.cover_url }}
@@ -177,7 +205,7 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
<MaterialCommunityIcons name="image-outline" size={48} color="rgba(255,255,255,0.4)" />
</View>
)}
</View>
</TouchableOpacity>
{/* 顶部导航栏按钮 */}
<View style={styles.topBar}>
@@ -194,18 +222,23 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
{/* 头像与操作按钮行 */}
<View style={styles.avatarActionRow}>
<View style={[styles.avatarWrapper, { marginTop: -avatarSize / 2 }]}>
<View style={[styles.avatarContainer, { width: avatarSize, height: avatarSize }]}>
<TouchableOpacity
style={[styles.avatarContainer, { width: avatarSize, height: avatarSize }]}
onPress={isCurrentUser && onAvatarPress ? onAvatarPress : handleAvatarPressPreview}
activeOpacity={0.9}
disabled={isCurrentUser ? !onAvatarPress : !user.avatar}
>
<Avatar
source={user.avatar}
size={avatarSize - 6}
name={user.nickname}
/>
{isCurrentUser && onAvatarPress && (
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
<View style={styles.editAvatarButton} pointerEvents="none">
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
</TouchableOpacity>
</View>
)}
</View>
</TouchableOpacity>
</View>
<View style={styles.actionButtons}>
@@ -291,6 +324,15 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
</View>
{renderDropdownMenu()}
{/* 图片预览 */}
<ImageGallery
visible={galleryVisible}
images={galleryImages}
initialIndex={galleryIndex}
onClose={() => setGalleryVisible(false)}
enableSave
/>
</View>
);
};