From 2dc6936aac5448f45251bc9789faa5cbdd21b549 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Sun, 26 Apr 2026 01:11:30 +0800 Subject: [PATCH] feat(post): add post reference functionality with inline cards and suggestion search 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 --- src/components/business/CommentItem.tsx | 4 +- .../business/PostContentRenderer.tsx | 29 ++- src/components/business/PostMentionInput.tsx | 141 ++++++++++-- src/components/business/PostRefCard.tsx | 124 ++++++++++ src/components/business/UserProfileHeader.tsx | 54 ++++- src/screens/create/CreatePostScreen.tsx | 1 + src/screens/home/PostDetailScreen.tsx | 87 +++---- src/screens/message/GroupMembersScreen.tsx | 48 ++-- src/screens/profile/UserProfileScreen.tsx | 215 +++++++++--------- src/services/post/postService.ts | 17 ++ src/types/dto.ts | 27 ++- 11 files changed, 554 insertions(+), 193 deletions(-) create mode 100644 src/components/business/PostRefCard.tsx diff --git a/src/components/business/CommentItem.tsx b/src/components/business/CommentItem.tsx index d5f781f..79be8a6 100644 --- a/src/components/business/CommentItem.tsx +++ b/src/components/business/CommentItem.tsx @@ -493,7 +493,7 @@ const CommentItem: React.FC = ({ {/* 显示回复内容(如果有文字) */} {reply.content ? ( - + {reply.content} ) : null} @@ -625,7 +625,7 @@ const CommentItem: React.FC = ({ textStyle={[styles.text, { color: colors.text.primary }]} /> ) : ( - + {comment.content} )} diff --git a/src/components/business/PostContentRenderer.tsx b/src/components/business/PostContentRenderer.tsx index 0684bd0..d92bdac 100644 --- a/src/components/business/PostContentRenderer.tsx +++ b/src/components/business/PostContentRenderer.tsx @@ -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 = ({ segments, numberOfLines, onMentionPress, + onPostRefPress, style, textStyle, }) => { @@ -32,7 +35,7 @@ const PostContentRenderer: React.FC = ({ if (!segments || segments.length === 0) { return ( - + {content || ''} ); @@ -49,7 +52,7 @@ const PostContentRenderer: React.FC = ({ const text = segment.data?.text || segment.data?.content || ''; if (text) { elements.push( - + {text} ); @@ -65,6 +68,7 @@ const PostContentRenderer: React.FC = ({ elements.push( = ({ 📊 投票({voteData.options.length} 个选项) {voteData.options.map((opt, i) => ( - + {i + 1}. {opt.content} ))} @@ -98,6 +102,19 @@ const PostContentRenderer: React.FC = ({ break; } + case 'post_ref': { + const refData = segment.data as PostRefSegmentData; + elements.push( + + ); + break; + } + case 'image': case 'link': case 'face': @@ -108,7 +125,7 @@ const PostContentRenderer: React.FC = ({ if (elements.length === 0) { return ( - + {content || ''} ); @@ -117,7 +134,7 @@ const PostContentRenderer: React.FC = ({ if (numberOfLines) { return ( - + {elements} diff --git a/src/components/business/PostMentionInput.tsx b/src/components/business/PostMentionInput.tsx index 1aa3ff0..1c72ccd 100644 --- a/src/components/business/PostMentionInput.tsx +++ b/src/components/business/PostMentionInput.tsx @@ -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 = ({ @@ -48,14 +58,17 @@ const PostMentionInput: React.FC = ({ style, minHeight = 100, autoExpand = false, + onPostRefPasted, }) => { const colors = useAppColors(); const currentUser = useAuthStore(s => s.currentUser); const [followingUsers, setFollowingUsers] = useState([]); - const [showMentions, setShowMentions] = useState(false); + const [suggestPosts, setSuggestPosts] = useState([]); + const [suggestionMode, setSuggestionMode] = useState('none'); const [mentionQuery, setMentionQuery] = useState(''); const [mentionStartIndex, setMentionStartIndex] = useState(-1); const [loaded, setLoaded] = useState(false); + const [postSearchTimer, setPostSearchTimer] = useState | null>(null); const inputRef = useRef(null); useEffect(() => { @@ -125,8 +138,42 @@ const PostMentionInput: React.FC = ({ 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 = ({ 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 = ({ }); onChangeText(newText); - setShowMentions(false); + setSuggestionMode('none'); setMentionQuery(''); setMentionStartIndex(-1); @@ -176,7 +223,31 @@ const PostMentionInput: React.FC = ({ 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 = ({ ]} /> - {showMentions && filteredUsers.length > 0 && ( + {suggestionMode === 'mention' && filteredUsers.length > 0 && ( - item.id} + + > + {filteredUsers.map(item => ( + + {renderMentionItem({ item })} + + ))} + )} - {showMentions && filteredUsers.length === 0 && ( + {suggestionMode === 'mention' && filteredUsers.length === 0 && ( 没有找到匹配的关注用户 )} + + {suggestionMode === 'postref' && suggestPosts.length > 0 && ( + + + {suggestPosts.map(item => ( + handleSelectPost(item)} + activeOpacity={0.7} + > + + + + {item.title} + + + 点击引用帖子 + + + + ))} + + + )} + + {suggestionMode === 'postref' && suggestPosts.length === 0 && ( + + + 没有找到匹配的帖子 + + + )} ); }; diff --git a/src/components/business/PostRefCard.tsx b/src/components/business/PostRefCard.tsx new file mode 100644 index 0000000..bc812cf --- /dev/null +++ b/src/components/business/PostRefCard.tsx @@ -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 = ({ 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 ( + + + + {isDeleted ? '该帖子已删除' : isHidden ? '该帖子不可见' : '请登录后查看'} + + + ); + } + + return ( + + + + + + + {data.title || '帖子'} + + {authorName ? ( + + @{authorName} + + ) : null} + + + + ); +}; + +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; \ No newline at end of file diff --git a/src/components/business/UserProfileHeader.tsx b/src/components/business/UserProfileHeader.tsx index f68ecc7..9a7e7e4 100644 --- a/src/components/business/UserProfileHeader.tsx +++ b/src/components/business/UserProfileHeader.tsx @@ -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 = ({ const [menuVisible, setMenuVisible] = useState(false); const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }); const moreButtonRef = useRef(null); + const [galleryVisible, setGalleryVisible] = useState(false); + const [galleryImages, setGalleryImages] = useState([]); + 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 = ({ 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 = ({ {/* 全宽封面背景 */} - + {user.cover_url ? ( = ({ )} - + {/* 顶部导航栏按钮 */} @@ -194,18 +222,23 @@ const UserProfileHeader: React.FC = ({ {/* 头像与操作按钮行 */} - + {isCurrentUser && onAvatarPress && ( - + - + )} - + @@ -291,6 +324,15 @@ const UserProfileHeader: React.FC = ({ {renderDropdownMenu()} + + {/* 图片预览 */} + setGalleryVisible(false)} + enableSave + /> ); }; diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index db9707d..9a1d389 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -795,6 +795,7 @@ export const CreatePostScreen: React.FC = (props) => { contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled" + nestedScrollEnabled > {/* 内容输入区 */} {renderContentSection()} diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index 6017583..9503da4 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -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(null); + const flatListRef = useRef | null>(null); const hasRecordedView = useRef(false); // 是否已记录浏览量 // 举报相关状态 @@ -1136,6 +1136,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>(({ item, index }) => { const authorId = item.author?.id || ''; // 收集当前评论的所有回复,用于根据 target_id 查找被回复用户 const allReplies = item.replies || []; @@ -1597,10 +1598,10 @@ export const PostDetailScreen: React.FC = () => { - {/* Emoji Panel —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿 */} + {/* Emoji Panel —— 使用 FlashList 虚拟化渲染,解决显示不全和卡顿 */} {showEmojiPanel && ( - 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} /> )} @@ -1637,7 +1631,7 @@ export const PostDetailScreen: React.FC = () => { // 渲染评论列表 const renderCommentsList = () => ( - { ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} showsVerticalScrollIndicator={false} - onScrollToIndexFailed={() => { - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); - }, 100); - }} refreshControl={ { } onEndReached={handleLoadMoreComments} onEndReachedThreshold={0.3} - initialNumToRender={10} - maxToRenderPerBatch={10} - updateCellsBatchingPeriod={60} - windowSize={7} - removeClippedSubviews + drawDistance={250} ListFooterComponent={ isCommentsInitialLoading ? ( @@ -1735,8 +1720,13 @@ export const PostDetailScreen: React.FC = () => { sidebarPosition="right" > - { tintColor={colors.primary.main} /> } - > - {renderPostHeader()} - {renderCommentsList()} - + onEndReached={handleLoadMoreComments} + onEndReachedThreshold={0.3} + drawDistance={250} + ListFooterComponent={ + isCommentsInitialLoading ? ( + + + + ) : isCommentsLoadingMore ? ( + + + + ) : hasMoreComments ? ( + + + 加载更多评论 + + + ) : (comments?.length ?? 0) > 0 ? ( + + 没有更多评论了 + + ) : null + } + /> {renderCommentInput()} @@ -1778,7 +1792,7 @@ export const PostDetailScreen: React.FC = () => { behavior={Platform.OS === 'ios' ? 'padding' : undefined} keyboardVerticalOffset={88} > - { ListEmptyComponent={renderEmptyComments} contentContainerStyle={{ paddingBottom: responsiveGap }} showsVerticalScrollIndicator={false} - onScrollToIndexFailed={() => { - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); - }, 100); - }} refreshControl={ { } onEndReached={handleLoadMoreComments} onEndReachedThreshold={0.3} - initialNumToRender={10} - maxToRenderPerBatch={10} - updateCellsBatchingPeriod={60} - windowSize={7} - removeClippedSubviews + drawDistance={250} ListFooterComponent={ isCommentsInitialLoading ? ( diff --git a/src/screens/message/GroupMembersScreen.tsx b/src/screens/message/GroupMembersScreen.tsx index 855dd78..57261e3 100644 --- a/src/screens/message/GroupMembersScreen.tsx +++ b/src/screens/message/GroupMembersScreen.tsx @@ -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 = ({ item }) => { const isSelf = item.user_id === currentUser?.id; const canManage = isAdmin && item.role !== 'owner' && (isOwner || item.role === 'member'); return ( - canManage ? openActionModal(item) : null} - onLongPress={() => isSelf ? openNicknameModal(item) : null} - activeOpacity={canManage ? 0.7 : 1} - > - - + + handleAvatarPress(item)} + activeOpacity={0.7} + > + + + isSelf ? openNicknameModal(item) : handleAvatarPress(item)} + onLongPress={() => isSelf ? openNicknameModal(item) : null} + activeOpacity={0.7} + > {item.nickname || item.user?.nickname || '未知用户'} @@ -422,11 +435,16 @@ const GroupMembersScreen: React.FC = () => { 禁言中 )} - + {canManage && ( - + openActionModal(item)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + )} - + ); }; diff --git a/src/screens/profile/UserProfileScreen.tsx b/src/screens/profile/UserProfileScreen.tsx index b6edb69..775e4cd 100644 --- a/src/screens/profile/UserProfileScreen.tsx +++ b/src/screens/profile/UserProfileScreen.tsx @@ -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 = ({ 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>(({ item, index }) => { + const isPostAuthor = currentUser?.id === item.author?.id; + const isLast = index === currentPosts.length - 1; + return ( + + handlePostAction(item, action)} + isPostAuthor={isPostAuthor} + /> + + ); + }, [currentUser?.id, handlePostAction, currentPosts.length]); + + const postKeyExtractor = useCallback((item: Post) => item.id, []); + + // 渲染空状态 + const renderEmptyPosts = useCallback(() => { + if (loading) return ; + + if (isBlockedProfile && activeTab === 0 && mode === 'other') { return ( ); } - if (postList.length === 0) { - return ( - - ); - } + const emptyTitle = activeTab === 0 + ? (mode === 'self' ? '还没有帖子' : '这个用户还没有发布任何帖子') + : (mode === 'self' ? '还没有收藏' : '这个用户还没有收藏任何帖子'); + const emptyDesc = activeTab === 0 + ? (mode === 'self' ? '分享你的想法,发布第一条帖子吧' : '') + : (mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : ''); return ( - - {postList.map((post, index) => { - const isPostAuthor = currentUser?.id === post.author?.id; - return ( - - handlePostAction(post, action)} - isPostAuthor={isPostAuthor} - /> - - ); - })} - - ); - }, [currentUser?.id, handlePostAction, activeTab, isBlockedProfile]); - - // 渲染内容 - const renderContent = useCallback(() => { - if (loading) return ; - - 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 ( - ); - }, [user, isCurrentUser, isBlocked, handleFollow, handleSettings, handleEditProfile, handleMessage, handleBlock, handleFollowingPress, handleFollowersPress]); + }, [loading, activeTab, mode, isBlockedProfile]); - // 渲染 TabBar 和内容 - const renderTabBarAndContent = useMemo(() => ( - <> - - { + if (!user) return null; + return ( + <> + - - - {renderContent()} - - - ), [activeTab, renderContent]); + + + + + ); + }, [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 = ({ mode, user /> } > - {renderUserHeader} + {renderListHeader()} {/* 右侧:帖子列表 */} - + + + } + ListEmptyComponent={renderEmptyPosts} + contentContainerStyle={{ paddingBottom: scrollBottomInset }} showsVerticalScrollIndicator={false} - contentContainerStyle={[sharedStyles.desktopScrollContent, { paddingBottom: scrollBottomInset }]} - > - {renderTabBarAndContent} - + refreshControl={ + + } + drawDistance={250} + /> @@ -231,7 +231,13 @@ export const UserProfileScreen: React.FC = ({ mode, user // 移动端使用单栏布局 return ( - = ({ mode, user tintColor={colors.primary.main} /> } - contentContainerStyle={[sharedStyles.scrollContent, { paddingBottom: scrollBottomInset }]} - > - {renderUserHeader} - {renderTabBarAndContent} - + drawDistance={250} + /> ); }; diff --git a/src/services/post/postService.ts b/src/services/post/postService.ts index 6b1c9e4..f41b014 100644 --- a/src/services/post/postService.ts +++ b/src/services/post/postService.ts @@ -420,6 +420,23 @@ class PostService { }; } } + + async suggestPosts(keyword: string, limit = 10): Promise> { + try { + const response = await api.get('/posts/suggest', { q: keyword, limit }); + return response.data ?? []; + } catch { + return []; + } + } + + async recordRefClick(sourcePostId: string, targetPostId: string): Promise { + try { + await api.post(`/posts/${sourcePostId}/ref-click`, { target_post_id: targetPostId }); + } catch { + // ignore + } + } } // 导出帖子服务实例 diff --git a/src/types/dto.ts b/src/types/dto.ts index eeadcfa..9166d99 100644 --- a/src/types/dto.ts +++ b/src/types/dto.ts @@ -150,7 +150,7 @@ export type MessageStatus = 'normal' | 'recalled' | 'deleted'; // ==================== Message Segment Types ==================== // Segment类型 -export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link' | 'vote'; +export type SegmentType = 'text' | 'image' | 'voice' | 'video' | 'file' | 'at' | 'reply' | 'face' | 'link' | 'vote' | 'post_ref'; // 文本Segment数据 export interface TextSegmentData { @@ -232,6 +232,20 @@ export interface VoteSegmentData { is_multi_choice?: boolean; } +// 帖子内链Segment数据 +export interface PostRefSegmentData { + post_id: string; + title?: string; + author?: { + id: string; + nickname: string; + avatar?: string; + }; + status?: string; + accessible?: boolean; + click_count?: number; +} + // MessageSegment 联合类型 - 使用 discriminated union export type MessageSegment = | { type: 'text'; data: TextSegmentData } @@ -243,7 +257,8 @@ export type MessageSegment = | { type: 'reply'; data: ReplySegmentData } | { type: 'face'; data: FaceSegmentData } | { type: 'link'; data: LinkSegmentData } - | { type: 'vote'; data: VoteSegmentData }; + | { type: 'vote'; data: VoteSegmentData } + | { type: 'post_ref'; data: PostRefSegmentData }; // 消息响应 export interface MessageResponse { @@ -787,6 +802,10 @@ export function extractTextFromSegments(segments?: MessageSegment[], memberMap?: case 'reply': // 回复不显示在预览中 break; + case 'post_ref': + const refData = segment.data as PostRefSegmentData; + result += refData.title ? `[帖子: ${refData.title}]` : '[帖子引用]'; + break; } } @@ -967,6 +986,10 @@ export async function extractTextFromSegmentsAsync( case 'reply': // 回复不显示在预览中 break; + case 'post_ref': + const refDataAsync = segment.data as PostRefSegmentData; + result += refDataAsync.title ? `[帖子: ${refDataAsync.title}]` : '[帖子引用]'; + break; } }