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

@@ -7,7 +7,6 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
View,
FlatList,
StyleSheet,
RefreshControl,
TouchableOpacity,
@@ -23,6 +22,7 @@ import {
Clipboard,
Dimensions,
} from 'react-native';
import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -253,7 +253,7 @@ export const PostDetailScreen: React.FC = () => {
const [isFollowing, setIsFollowing] = useState(false);
const [isFollowingMe, setIsFollowingMe] = useState(false);
const [isFollowLoading, setIsFollowLoading] = useState(false);
const flatListRef = useRef<FlatList>(null);
const flatListRef = useRef<FlashListRef<Comment> | null>(null);
const hasRecordedView = useRef(false); // 是否已记录浏览量
// 举报相关状态
@@ -1136,6 +1136,7 @@ export const PostDetailScreen: React.FC = () => {
<View style={styles.titleContainer}>
<Text
variant="h2"
selectable
style={[
styles.title,
{
@@ -1361,7 +1362,7 @@ export const PostDetailScreen: React.FC = () => {
// 渲染评论 - 带楼层号
const commentKeyExtractor = useCallback((item: Comment) => item.id, []);
const renderComment = useCallback(({ item, index }: { item: Comment; index: number }) => {
const renderComment = useCallback<ListRenderItem<Comment>>(({ item, index }) => {
const authorId = item.author?.id || '';
// 收集当前评论的所有回复,用于根据 target_id 查找被回复用户
const allReplies = item.replies || [];
@@ -1597,10 +1598,10 @@ export const PostDetailScreen: React.FC = () => {
</TouchableOpacity>
</View>
{/* Emoji Panel —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿 */}
{/* Emoji Panel —— 使用 FlashList 虚拟化渲染,解决显示不全和卡顿 */}
{showEmojiPanel && (
<View style={styles.emojiPanel}>
<FlatList
<FlashList
data={EMOJI_ROWS}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
@@ -1619,14 +1620,7 @@ export const PostDetailScreen: React.FC = () => {
)}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
initialNumToRender={8}
maxToRenderPerBatch={8}
windowSize={5}
getItemLayout={(_data, index) => ({
length: EMOJI_ROW_HEIGHT,
offset: EMOJI_ROW_HEIGHT * index,
index,
})}
drawDistance={100}
/>
</View>
)}
@@ -1637,7 +1631,7 @@ export const PostDetailScreen: React.FC = () => {
// 渲染评论列表
const renderCommentsList = () => (
<FlatList
<FlashList
ref={flatListRef}
data={comments}
renderItem={renderComment}
@@ -1645,11 +1639,6 @@ export const PostDetailScreen: React.FC = () => {
ListEmptyComponent={renderEmptyComments}
contentContainerStyle={{ paddingBottom: responsiveGap }}
showsVerticalScrollIndicator={false}
onScrollToIndexFailed={() => {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
}}
refreshControl={
<RefreshControl
refreshing={isPostRefreshing || isCommentsRefreshing}
@@ -1660,11 +1649,7 @@ export const PostDetailScreen: React.FC = () => {
}
onEndReached={handleLoadMoreComments}
onEndReachedThreshold={0.3}
initialNumToRender={10}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={60}
windowSize={7}
removeClippedSubviews
drawDistance={250}
ListFooterComponent={
isCommentsInitialLoading ? (
<View style={styles.commentsLoadingFooter}>
@@ -1735,8 +1720,13 @@ export const PostDetailScreen: React.FC = () => {
sidebarPosition="right"
>
<View style={styles.flex}>
<ScrollView
style={styles.flex}
<FlashList
ref={flatListRef}
data={comments}
renderItem={renderComment}
keyExtractor={commentKeyExtractor}
ListHeaderComponent={renderPostHeader}
ListEmptyComponent={renderEmptyComments}
contentContainerStyle={{ paddingBottom: responsivePadding }}
showsVerticalScrollIndicator={false}
refreshControl={
@@ -1747,10 +1737,34 @@ export const PostDetailScreen: React.FC = () => {
tintColor={colors.primary.main}
/>
}
>
{renderPostHeader()}
{renderCommentsList()}
</ScrollView>
onEndReached={handleLoadMoreComments}
onEndReachedThreshold={0.3}
drawDistance={250}
ListFooterComponent={
isCommentsInitialLoading ? (
<View style={styles.commentsLoadingFooter}>
<Loading size="sm" />
</View>
) : isCommentsLoadingMore ? (
<View style={styles.commentsLoadingFooter}>
<Loading size="sm" />
</View>
) : hasMoreComments ? (
<TouchableOpacity
style={styles.loadMoreButton}
onPress={handleLoadMoreComments}
>
<Text variant="caption" color={colors.primary.main}>
</Text>
</TouchableOpacity>
) : (comments?.length ?? 0) > 0 ? (
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
</Text>
) : null
}
/>
{renderCommentInput()}
</View>
</AdaptiveLayout>
@@ -1778,7 +1792,7 @@ export const PostDetailScreen: React.FC = () => {
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={88}
>
<FlatList
<FlashList
ref={flatListRef}
data={comments}
renderItem={renderComment}
@@ -1787,11 +1801,6 @@ export const PostDetailScreen: React.FC = () => {
ListEmptyComponent={renderEmptyComments}
contentContainerStyle={{ paddingBottom: responsiveGap }}
showsVerticalScrollIndicator={false}
onScrollToIndexFailed={() => {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
}}
refreshControl={
<RefreshControl
refreshing={isPostRefreshing || isCommentsRefreshing}
@@ -1802,11 +1811,7 @@ export const PostDetailScreen: React.FC = () => {
}
onEndReached={handleLoadMoreComments}
onEndReachedThreshold={0.3}
initialNumToRender={10}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={60}
windowSize={7}
removeClippedSubviews
drawDistance={250}
ListFooterComponent={
isCommentsInitialLoading ? (
<View style={styles.commentsLoadingFooter}>