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

@@ -795,6 +795,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
>
{/* 内容输入区 */}
{renderContentSection()}

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

View File

@@ -21,6 +21,7 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { hrefUserProfile } from '../../navigation/hrefs';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { blurActiveElement } from '../../infrastructure/platform';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
@@ -383,24 +384,36 @@ const GroupMembersScreen: React.FC = () => {
}
};
// 点击头像进入对方主页
const handleAvatarPress = (member: GroupMemberResponse) => {
if (member.user_id) {
router.push(hrefUserProfile(String(member.user_id)));
}
};
// 渲染成员项
const renderMember: ListRenderItem<GroupMemberResponse> = ({ item }) => {
const isSelf = item.user_id === currentUser?.id;
const canManage = isAdmin && item.role !== 'owner' && (isOwner || item.role === 'member');
return (
<TouchableOpacity
style={styles.memberItem}
onPress={() => canManage ? openActionModal(item) : null}
onLongPress={() => isSelf ? openNicknameModal(item) : null}
activeOpacity={canManage ? 0.7 : 1}
>
<Avatar
source={item.user?.avatar}
size={48}
name={item.user?.nickname || item.nickname}
/>
<View style={styles.memberInfo}>
<View style={styles.memberItem}>
<TouchableOpacity
onPress={() => handleAvatarPress(item)}
activeOpacity={0.7}
>
<Avatar
source={item.user?.avatar}
size={48}
name={item.user?.nickname || item.nickname}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.memberInfo}
onPress={() => isSelf ? openNicknameModal(item) : handleAvatarPress(item)}
onLongPress={() => isSelf ? openNicknameModal(item) : null}
activeOpacity={0.7}
>
<View style={styles.memberNameRow}>
<Text variant="body" style={styles.memberName}>
{item.nickname || item.user?.nickname || '未知用户'}
@@ -422,11 +435,16 @@ const GroupMembersScreen: React.FC = () => {
<Text variant="label" color={colors.error.main}> </Text>
</View>
)}
</View>
</TouchableOpacity>
{canManage && (
<MaterialCommunityIcons name="dots-vertical" size={20} color={colors.text.hint} />
<TouchableOpacity
onPress={() => openActionModal(item)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<MaterialCommunityIcons name="dots-vertical" size={20} color={colors.text.hint} />
</TouchableOpacity>
)}
</TouchableOpacity>
</View>
);
};

View File

@@ -5,12 +5,13 @@
* 支持桌面端双栏布局
*/
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import {
View,
RefreshControl,
ScrollView,
} from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppColors } from '../../theme';
import { Post } from '../../types';
@@ -54,113 +55,90 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
currentUser,
} = useUserProfile({ mode, userId, isDesktop, isTablet });
// 渲染帖子列表 - Twitter 风格无边框
const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string, blockedTitle?: string, blockedDesc?: string) => {
if (isBlockedProfile && blockedTitle) {
// 当前显示的帖子列表
const currentPosts = activeTab === 0 ? posts : favorites;
// 渲染帖子项
const renderPostItem = useCallback<ListRenderItem<Post>>(({ item, index }) => {
const isPostAuthor = currentUser?.id === item.author?.id;
const isLast = index === currentPosts.length - 1;
return (
<View style={[
sharedStyles.postWrapper,
isLast && sharedStyles.lastPost,
]}>
<PostCard
post={item}
onAction={(action) => handlePostAction(item, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [currentUser?.id, handlePostAction, currentPosts.length]);
const postKeyExtractor = useCallback((item: Post) => item.id, []);
// 渲染空状态
const renderEmptyPosts = useCallback(() => {
if (loading) return <Loading />;
if (isBlockedProfile && activeTab === 0 && mode === 'other') {
return (
<EmptyState
title={blockedTitle}
description={blockedDesc || ''}
title="已将该用户拉黑"
description="你已将此用户拉黑,不再显示其帖子"
icon="account-off-outline"
variant="modern"
/>
);
}
if (postList.length === 0) {
return (
<EmptyState
title={emptyTitle}
description={emptyDesc}
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-heart-outline'}
variant="modern"
/>
);
}
const emptyTitle = activeTab === 0
? (mode === 'self' ? '还没有帖子' : '这个用户还没有发布任何帖子')
: (mode === 'self' ? '还没有收藏' : '这个用户还没有收藏任何帖子');
const emptyDesc = activeTab === 0
? (mode === 'self' ? '分享你的想法,发布第一条帖子吧' : '')
: (mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : '');
return (
<View style={sharedStyles.postsContainer}>
{postList.map((post, index) => {
const isPostAuthor = currentUser?.id === post.author?.id;
return (
<View key={post.id} style={[
sharedStyles.postWrapper,
index === postList.length - 1 && sharedStyles.lastPost,
]}>
<PostCard
post={post}
onAction={(action) => handlePostAction(post, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
})}
</View>
);
}, [currentUser?.id, handlePostAction, activeTab, isBlockedProfile]);
// 渲染内容
const renderContent = useCallback(() => {
if (loading) return <Loading />;
if (activeTab === 0) {
return renderPostList(
posts,
mode === 'self' ? '还没有帖子' : '这个用户还没有发布任何帖子',
mode === 'self' ? '分享你的想法,发布第一条帖子吧' : '',
mode === 'other' ? '已将该用户拉黑' : undefined,
mode === 'other' ? '你已将此用户拉黑,不再显示其帖子' : undefined
);
}
if (activeTab === 1) {
return renderPostList(
favorites,
mode === 'self' ? '还没有收藏' : '这个用户还没有收藏任何帖子',
mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : ''
);
}
return null;
}, [loading, activeTab, posts, favorites, mode, renderPostList]);
// 渲染用户信息头部
const renderUserHeader = useMemo(() => {
if (!user) return null;
return (
<UserProfileHeader
user={user}
isCurrentUser={isCurrentUser}
isBlocked={isBlocked}
onFollow={handleFollow}
onSettings={handleSettings}
onEditProfile={handleEditProfile}
onMessage={handleMessage}
onBlock={handleBlock}
onFollowingPress={handleFollowingPress}
onFollowersPress={handleFollowersPress}
<EmptyState
title={emptyTitle}
description={emptyDesc}
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-heart-outline'}
variant="modern"
/>
);
}, [user, isCurrentUser, isBlocked, handleFollow, handleSettings, handleEditProfile, handleMessage, handleBlock, handleFollowingPress, handleFollowersPress]);
}, [loading, activeTab, mode, isBlockedProfile]);
// 渲染 TabBar 和内容
const renderTabBarAndContent = useMemo(() => (
<>
<View style={sharedStyles.tabBarContainer}>
<TabBar
tabs={TABS}
activeIndex={activeTab}
onTabChange={setActiveTab}
variant="modern"
icons={TAB_ICONS}
// 渲染 FlashList 头部(用户信息 + TabBar
const renderListHeader = useCallback(() => {
if (!user) return null;
return (
<>
<UserProfileHeader
user={user}
isCurrentUser={isCurrentUser}
isBlocked={isBlocked}
onFollow={handleFollow}
onSettings={handleSettings}
onEditProfile={handleEditProfile}
onMessage={handleMessage}
onBlock={handleBlock}
onFollowingPress={handleFollowingPress}
onFollowersPress={handleFollowersPress}
/>
</View>
<View style={sharedStyles.contentContainer}>
{renderContent()}
</View>
</>
), [activeTab, renderContent]);
<View style={sharedStyles.tabBarContainer}>
<TabBar
tabs={TABS}
activeIndex={activeTab}
onTabChange={setActiveTab}
variant="modern"
icons={TAB_ICONS}
/>
</View>
</>
);
}, [user, isCurrentUser, isBlocked, handleFollow, handleSettings, handleEditProfile, handleMessage, handleBlock, handleFollowingPress, handleFollowersPress, activeTab, setActiveTab]);
// 未登录/用户不存在状态
if (mode === 'self' && !currentUser) {
@@ -209,18 +187,40 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
/>
}
>
{renderUserHeader}
{renderListHeader()}
</ScrollView>
</View>
{/* 右侧:帖子列表 */}
<View style={sharedStyles.desktopContent}>
<ScrollView
<FlashList
data={currentPosts}
renderItem={renderPostItem}
keyExtractor={postKeyExtractor}
ListHeaderComponent={
<View style={sharedStyles.tabBarContainer}>
<TabBar
tabs={TABS}
activeIndex={activeTab}
onTabChange={setActiveTab}
variant="modern"
icons={TAB_ICONS}
/>
</View>
}
ListEmptyComponent={renderEmptyPosts}
contentContainerStyle={{ paddingBottom: scrollBottomInset }}
showsVerticalScrollIndicator={false}
contentContainerStyle={[sharedStyles.desktopScrollContent, { paddingBottom: scrollBottomInset }]}
>
{renderTabBarAndContent}
</ScrollView>
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
drawDistance={250}
/>
</View>
</View>
</ResponsiveContainer>
@@ -231,7 +231,13 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
// 移动端使用单栏布局
return (
<SafeAreaView style={sharedStyles.container} edges={safeAreaEdges as any}>
<ScrollView
<FlashList
data={currentPosts}
renderItem={renderPostItem}
keyExtractor={postKeyExtractor}
ListHeaderComponent={renderListHeader}
ListEmptyComponent={renderEmptyPosts}
contentContainerStyle={{ paddingBottom: scrollBottomInset }}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
@@ -241,11 +247,8 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
tintColor={colors.primary.main}
/>
}
contentContainerStyle={[sharedStyles.scrollContent, { paddingBottom: scrollBottomInset }]}
>
{renderUserHeader}
{renderTabBarAndContent}
</ScrollView>
drawDistance={250}
/>
</SafeAreaView>
);
};