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

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

View File

@@ -420,6 +420,23 @@ class PostService {
};
}
}
async suggestPosts(keyword: string, limit = 10): Promise<Array<{ id: string; title: string; channel?: string }>> {
try {
const response = await api.get<any>('/posts/suggest', { q: keyword, limit });
return response.data ?? [];
} catch {
return [];
}
}
async recordRefClick(sourcePostId: string, targetPostId: string): Promise<void> {
try {
await api.post(`/posts/${sourcePostId}/ref-click`, { target_post_id: targetPostId });
} catch {
// ignore
}
}
}
// 导出帖子服务实例

View File

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