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
This commit is contained in:
@@ -493,7 +493,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
{/* 显示回复内容(如果有文字) */}
|
{/* 显示回复内容(如果有文字) */}
|
||||||
{reply.content ? (
|
{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}
|
{reply.content}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -625,7 +625,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
textStyle={[styles.text, { color: colors.text.primary }]}
|
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}
|
{comment.content}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
|||||||
|
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import { useAppColors } from '../../theme';
|
import { useAppColors } from '../../theme';
|
||||||
import { MessageSegment, AtSegmentData, VoteSegmentData } from '../../types';
|
import { MessageSegment, AtSegmentData, VoteSegmentData, PostRefSegmentData } from '../../types';
|
||||||
|
import PostRefCard from './PostRefCard';
|
||||||
|
|
||||||
interface PostContentRendererProps {
|
interface PostContentRendererProps {
|
||||||
content?: string;
|
content?: string;
|
||||||
segments?: MessageSegment[];
|
segments?: MessageSegment[];
|
||||||
numberOfLines?: number;
|
numberOfLines?: number;
|
||||||
onMentionPress?: (userId: string) => void;
|
onMentionPress?: (userId: string) => void;
|
||||||
|
onPostRefPress?: (postId: string) => void;
|
||||||
style?: any;
|
style?: any;
|
||||||
textStyle?: any;
|
textStyle?: any;
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
segments,
|
segments,
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
onMentionPress,
|
onMentionPress,
|
||||||
|
onPostRefPress,
|
||||||
style,
|
style,
|
||||||
textStyle,
|
textStyle,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -32,7 +35,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
|
|
||||||
if (!segments || segments.length === 0) {
|
if (!segments || segments.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
{content || ''}
|
{content || ''}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -49,7 +52,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
const text = segment.data?.text || segment.data?.content || '';
|
const text = segment.data?.text || segment.data?.content || '';
|
||||||
if (text) {
|
if (text) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<Text key={key} style={textStyle}>
|
<Text key={key} selectable style={textStyle}>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -65,6 +68,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
elements.push(
|
elements.push(
|
||||||
<Text
|
<Text
|
||||||
key={key}
|
key={key}
|
||||||
|
selectable
|
||||||
style={[
|
style={[
|
||||||
textStyle,
|
textStyle,
|
||||||
{
|
{
|
||||||
@@ -88,7 +92,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
📊 投票({voteData.options.length} 个选项)
|
📊 投票({voteData.options.length} 个选项)
|
||||||
</Text>
|
</Text>
|
||||||
{voteData.options.map((opt, i) => (
|
{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}
|
{i + 1}. {opt.content}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
@@ -98,6 +102,19 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
break;
|
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 'image':
|
||||||
case 'link':
|
case 'link':
|
||||||
case 'face':
|
case 'face':
|
||||||
@@ -108,7 +125,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
|
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
{content || ''}
|
{content || ''}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -117,7 +134,7 @@ const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|||||||
if (numberOfLines) {
|
if (numberOfLines) {
|
||||||
return (
|
return (
|
||||||
<View style={style}>
|
<View style={style}>
|
||||||
<Text numberOfLines={numberOfLines} style={textStyle}>
|
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
||||||
{elements}
|
{elements}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
TextInput,
|
TextInput,
|
||||||
FlatList,
|
ScrollView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Platform,
|
Platform,
|
||||||
@@ -20,6 +20,7 @@ import Avatar from '../common/Avatar';
|
|||||||
import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme';
|
import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { authService } from '../../services/auth';
|
import { authService } from '../../services/auth';
|
||||||
|
import { postService } from '../../services/post/postService';
|
||||||
import { MessageSegment, AtSegmentData } from '../../types';
|
import { MessageSegment, AtSegmentData } from '../../types';
|
||||||
|
|
||||||
interface MentionUser {
|
interface MentionUser {
|
||||||
@@ -28,6 +29,14 @@ interface MentionUser {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SuggestPost {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
channel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SuggestionMode = 'none' | 'mention' | 'postref';
|
||||||
|
|
||||||
interface PostMentionInputProps {
|
interface PostMentionInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
@@ -37,6 +46,7 @@ interface PostMentionInputProps {
|
|||||||
style?: any;
|
style?: any;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
autoExpand?: boolean;
|
autoExpand?: boolean;
|
||||||
|
onPostRefPasted?: (postId: string, title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
||||||
@@ -48,14 +58,17 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
style,
|
style,
|
||||||
minHeight = 100,
|
minHeight = 100,
|
||||||
autoExpand = false,
|
autoExpand = false,
|
||||||
|
onPostRefPasted,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const currentUser = useAuthStore(s => s.currentUser);
|
const currentUser = useAuthStore(s => s.currentUser);
|
||||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
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 [mentionQuery, setMentionQuery] = useState('');
|
||||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,8 +138,42 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
onChangeText(text);
|
onChangeText(text);
|
||||||
|
|
||||||
const cursorPos = text.length;
|
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--) {
|
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||||
if (text[i] === '@') {
|
if (text[i] === '@') {
|
||||||
atStart = i;
|
atStart = i;
|
||||||
@@ -142,15 +189,15 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
if (!query.includes(' ') && !query.includes('\n')) {
|
if (!query.includes(' ') && !query.includes('\n')) {
|
||||||
setMentionQuery(query);
|
setMentionQuery(query);
|
||||||
setMentionStartIndex(atStart);
|
setMentionStartIndex(atStart);
|
||||||
setShowMentions(true);
|
setSuggestionMode('mention');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowMentions(false);
|
setSuggestionMode('none');
|
||||||
rebuildSegments(text);
|
rebuildSegments(text);
|
||||||
},
|
},
|
||||||
[onChangeText, rebuildSegments]
|
[onChangeText, rebuildSegments, postSearchTimer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectMention = useCallback(
|
const handleSelectMention = useCallback(
|
||||||
@@ -168,7 +215,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
onChangeText(newText);
|
onChangeText(newText);
|
||||||
setShowMentions(false);
|
setSuggestionMode('none');
|
||||||
setMentionQuery('');
|
setMentionQuery('');
|
||||||
setMentionStartIndex(-1);
|
setMentionStartIndex(-1);
|
||||||
|
|
||||||
@@ -176,7 +223,31 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
|
|
||||||
inputRef.current?.focus();
|
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 }) => (
|
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 }]}>
|
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||||
<FlatList
|
<ScrollView
|
||||||
data={filteredUsers}
|
|
||||||
renderItem={renderMentionItem}
|
|
||||||
keyExtractor={item => item.id}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
style={styles.mentionList}
|
style={styles.mentionList}
|
||||||
/>
|
>
|
||||||
|
{filteredUsers.map(item => (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
{renderMentionItem({ item })}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showMentions && filteredUsers.length === 0 && (
|
{suggestionMode === 'mention' && filteredUsers.length === 0 && (
|
||||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
||||||
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
||||||
没有找到匹配的关注用户
|
没有找到匹配的关注用户
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
124
src/components/business/PostRefCard.tsx
Normal file
124
src/components/business/PostRefCard.tsx
Normal 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;
|
||||||
@@ -20,6 +20,8 @@ import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '
|
|||||||
import { User } from '../../types';
|
import { User } from '../../types';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
|
import { ImageGallery } from '../common';
|
||||||
|
import type { GalleryImageItem } from '../common';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive } from '../../hooks';
|
||||||
|
|
||||||
interface UserProfileHeaderProps {
|
interface UserProfileHeaderProps {
|
||||||
@@ -55,6 +57,9 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
const [menuVisible, setMenuVisible] = useState(false);
|
const [menuVisible, setMenuVisible] = useState(false);
|
||||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
||||||
const moreButtonRef = useRef<View>(null);
|
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 => {
|
const formatCount = (count: number | undefined): string => {
|
||||||
if (count === undefined || count === null) return '0';
|
if (count === undefined || count === null) return '0';
|
||||||
@@ -106,6 +111,24 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
onBlock?.();
|
onBlock?.();
|
||||||
}, [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) => {
|
const renderStatLink = (count: number, label: string, onPress?: () => void) => {
|
||||||
if (!onPress) {
|
if (!onPress) {
|
||||||
return (
|
return (
|
||||||
@@ -165,7 +188,12 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* 全宽封面背景 */}
|
{/* 全宽封面背景 */}
|
||||||
<View style={[styles.coverContainer, { height: coverHeight }]}>
|
<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 ? (
|
{user.cover_url ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: user.cover_url }}
|
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)" />
|
<MaterialCommunityIcons name="image-outline" size={48} color="rgba(255,255,255,0.4)" />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* 顶部导航栏按钮 */}
|
{/* 顶部导航栏按钮 */}
|
||||||
<View style={styles.topBar}>
|
<View style={styles.topBar}>
|
||||||
@@ -194,18 +222,23 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
{/* 头像与操作按钮行 */}
|
{/* 头像与操作按钮行 */}
|
||||||
<View style={styles.avatarActionRow}>
|
<View style={styles.avatarActionRow}>
|
||||||
<View style={[styles.avatarWrapper, { marginTop: -avatarSize / 2 }]}>
|
<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
|
<Avatar
|
||||||
source={user.avatar}
|
source={user.avatar}
|
||||||
size={avatarSize - 6}
|
size={avatarSize - 6}
|
||||||
name={user.nickname}
|
name={user.nickname}
|
||||||
/>
|
/>
|
||||||
{isCurrentUser && onAvatarPress && (
|
{isCurrentUser && onAvatarPress && (
|
||||||
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
|
<View style={styles.editAvatarButton} pointerEvents="none">
|
||||||
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.actionButtons}>
|
<View style={styles.actionButtons}>
|
||||||
@@ -291,6 +324,15 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{renderDropdownMenu()}
|
{renderDropdownMenu()}
|
||||||
|
|
||||||
|
{/* 图片预览 */}
|
||||||
|
<ImageGallery
|
||||||
|
visible={galleryVisible}
|
||||||
|
images={galleryImages}
|
||||||
|
initialIndex={galleryIndex}
|
||||||
|
onClose={() => setGalleryVisible(false)}
|
||||||
|
enableSave
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -795,6 +795,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
nestedScrollEnabled
|
||||||
>
|
>
|
||||||
{/* 内容输入区 */}
|
{/* 内容输入区 */}
|
||||||
{renderContentSection()}
|
{renderContentSection()}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
FlatList,
|
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -23,6 +22,7 @@ import {
|
|||||||
Clipboard,
|
Clipboard,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router';
|
import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -253,7 +253,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
const [isFollowing, setIsFollowing] = useState(false);
|
const [isFollowing, setIsFollowing] = useState(false);
|
||||||
const [isFollowingMe, setIsFollowingMe] = useState(false);
|
const [isFollowingMe, setIsFollowingMe] = useState(false);
|
||||||
const [isFollowLoading, setIsFollowLoading] = useState(false);
|
const [isFollowLoading, setIsFollowLoading] = useState(false);
|
||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlashListRef<Comment> | null>(null);
|
||||||
const hasRecordedView = useRef(false); // 是否已记录浏览量
|
const hasRecordedView = useRef(false); // 是否已记录浏览量
|
||||||
|
|
||||||
// 举报相关状态
|
// 举报相关状态
|
||||||
@@ -1136,6 +1136,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<Text
|
<Text
|
||||||
variant="h2"
|
variant="h2"
|
||||||
|
selectable
|
||||||
style={[
|
style={[
|
||||||
styles.title,
|
styles.title,
|
||||||
{
|
{
|
||||||
@@ -1361,7 +1362,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
// 渲染评论 - 带楼层号
|
// 渲染评论 - 带楼层号
|
||||||
const commentKeyExtractor = useCallback((item: Comment) => item.id, []);
|
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 || '';
|
const authorId = item.author?.id || '';
|
||||||
// 收集当前评论的所有回复,用于根据 target_id 查找被回复用户
|
// 收集当前评论的所有回复,用于根据 target_id 查找被回复用户
|
||||||
const allReplies = item.replies || [];
|
const allReplies = item.replies || [];
|
||||||
@@ -1597,10 +1598,10 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Emoji Panel —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿 */}
|
{/* Emoji Panel —— 使用 FlashList 虚拟化渲染,解决显示不全和卡顿 */}
|
||||||
{showEmojiPanel && (
|
{showEmojiPanel && (
|
||||||
<View style={styles.emojiPanel}>
|
<View style={styles.emojiPanel}>
|
||||||
<FlatList
|
<FlashList
|
||||||
data={EMOJI_ROWS}
|
data={EMOJI_ROWS}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
@@ -1619,14 +1620,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
initialNumToRender={8}
|
drawDistance={100}
|
||||||
maxToRenderPerBatch={8}
|
|
||||||
windowSize={5}
|
|
||||||
getItemLayout={(_data, index) => ({
|
|
||||||
length: EMOJI_ROW_HEIGHT,
|
|
||||||
offset: EMOJI_ROW_HEIGHT * index,
|
|
||||||
index,
|
|
||||||
})}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1637,7 +1631,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 渲染评论列表
|
// 渲染评论列表
|
||||||
const renderCommentsList = () => (
|
const renderCommentsList = () => (
|
||||||
<FlatList
|
<FlashList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={comments}
|
data={comments}
|
||||||
renderItem={renderComment}
|
renderItem={renderComment}
|
||||||
@@ -1645,11 +1639,6 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
ListEmptyComponent={renderEmptyComments}
|
ListEmptyComponent={renderEmptyComments}
|
||||||
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScrollToIndexFailed={() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToEnd({ animated: true });
|
|
||||||
}, 100);
|
|
||||||
}}
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isPostRefreshing || isCommentsRefreshing}
|
refreshing={isPostRefreshing || isCommentsRefreshing}
|
||||||
@@ -1660,11 +1649,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
onEndReached={handleLoadMoreComments}
|
onEndReached={handleLoadMoreComments}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
initialNumToRender={10}
|
drawDistance={250}
|
||||||
maxToRenderPerBatch={10}
|
|
||||||
updateCellsBatchingPeriod={60}
|
|
||||||
windowSize={7}
|
|
||||||
removeClippedSubviews
|
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
isCommentsInitialLoading ? (
|
isCommentsInitialLoading ? (
|
||||||
<View style={styles.commentsLoadingFooter}>
|
<View style={styles.commentsLoadingFooter}>
|
||||||
@@ -1735,8 +1720,13 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
sidebarPosition="right"
|
sidebarPosition="right"
|
||||||
>
|
>
|
||||||
<View style={styles.flex}>
|
<View style={styles.flex}>
|
||||||
<ScrollView
|
<FlashList
|
||||||
style={styles.flex}
|
ref={flatListRef}
|
||||||
|
data={comments}
|
||||||
|
renderItem={renderComment}
|
||||||
|
keyExtractor={commentKeyExtractor}
|
||||||
|
ListHeaderComponent={renderPostHeader}
|
||||||
|
ListEmptyComponent={renderEmptyComments}
|
||||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
@@ -1747,10 +1737,34 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
onEndReached={handleLoadMoreComments}
|
||||||
{renderPostHeader()}
|
onEndReachedThreshold={0.3}
|
||||||
{renderCommentsList()}
|
drawDistance={250}
|
||||||
</ScrollView>
|
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()}
|
{renderCommentInput()}
|
||||||
</View>
|
</View>
|
||||||
</AdaptiveLayout>
|
</AdaptiveLayout>
|
||||||
@@ -1778,7 +1792,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
keyboardVerticalOffset={88}
|
keyboardVerticalOffset={88}
|
||||||
>
|
>
|
||||||
<FlatList
|
<FlashList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={comments}
|
data={comments}
|
||||||
renderItem={renderComment}
|
renderItem={renderComment}
|
||||||
@@ -1787,11 +1801,6 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
ListEmptyComponent={renderEmptyComments}
|
ListEmptyComponent={renderEmptyComments}
|
||||||
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScrollToIndexFailed={() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
flatListRef.current?.scrollToEnd({ animated: true });
|
|
||||||
}, 100);
|
|
||||||
}}
|
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isPostRefreshing || isCommentsRefreshing}
|
refreshing={isPostRefreshing || isCommentsRefreshing}
|
||||||
@@ -1802,11 +1811,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
onEndReached={handleLoadMoreComments}
|
onEndReached={handleLoadMoreComments}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
initialNumToRender={10}
|
drawDistance={250}
|
||||||
maxToRenderPerBatch={10}
|
|
||||||
updateCellsBatchingPeriod={60}
|
|
||||||
windowSize={7}
|
|
||||||
removeClippedSubviews
|
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
isCommentsInitialLoading ? (
|
isCommentsInitialLoading ? (
|
||||||
<View style={styles.commentsLoadingFooter}>
|
<View style={styles.commentsLoadingFooter}>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { hrefUserProfile } from '../../navigation/hrefs';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
import { blurActiveElement } from '../../infrastructure/platform';
|
||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
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 renderMember: ListRenderItem<GroupMemberResponse> = ({ item }) => {
|
||||||
const isSelf = item.user_id === currentUser?.id;
|
const isSelf = item.user_id === currentUser?.id;
|
||||||
const canManage = isAdmin && item.role !== 'owner' && (isOwner || item.role === 'member');
|
const canManage = isAdmin && item.role !== 'owner' && (isOwner || item.role === 'member');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<View style={styles.memberItem}>
|
||||||
style={styles.memberItem}
|
<TouchableOpacity
|
||||||
onPress={() => canManage ? openActionModal(item) : null}
|
onPress={() => handleAvatarPress(item)}
|
||||||
onLongPress={() => isSelf ? openNicknameModal(item) : null}
|
activeOpacity={0.7}
|
||||||
activeOpacity={canManage ? 0.7 : 1}
|
>
|
||||||
>
|
<Avatar
|
||||||
<Avatar
|
source={item.user?.avatar}
|
||||||
source={item.user?.avatar}
|
size={48}
|
||||||
size={48}
|
name={item.user?.nickname || item.nickname}
|
||||||
name={item.user?.nickname || item.nickname}
|
/>
|
||||||
/>
|
</TouchableOpacity>
|
||||||
<View style={styles.memberInfo}>
|
<TouchableOpacity
|
||||||
|
style={styles.memberInfo}
|
||||||
|
onPress={() => isSelf ? openNicknameModal(item) : handleAvatarPress(item)}
|
||||||
|
onLongPress={() => isSelf ? openNicknameModal(item) : null}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
<View style={styles.memberNameRow}>
|
<View style={styles.memberNameRow}>
|
||||||
<Text variant="body" style={styles.memberName}>
|
<Text variant="body" style={styles.memberName}>
|
||||||
{item.nickname || item.user?.nickname || '未知用户'}
|
{item.nickname || item.user?.nickname || '未知用户'}
|
||||||
@@ -422,11 +435,16 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
<Text variant="label" color={colors.error.main}> 禁言中</Text>
|
<Text variant="label" color={colors.error.main}> 禁言中</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
{canManage && (
|
{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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
* 支持桌面端双栏布局
|
* 支持桌面端双栏布局
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { FlashList, ListRenderItem } from '@shopify/flash-list';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useAppColors } from '../../theme';
|
import { useAppColors } from '../../theme';
|
||||||
import { Post } from '../../types';
|
import { Post } from '../../types';
|
||||||
@@ -54,113 +55,90 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
currentUser,
|
currentUser,
|
||||||
} = useUserProfile({ mode, userId, isDesktop, isTablet });
|
} = useUserProfile({ mode, userId, isDesktop, isTablet });
|
||||||
|
|
||||||
// 渲染帖子列表 - Twitter 风格无边框
|
// 当前显示的帖子列表
|
||||||
const renderPostList = useCallback((postList: Post[], emptyTitle: string, emptyDesc: string, blockedTitle?: string, blockedDesc?: string) => {
|
const currentPosts = activeTab === 0 ? posts : favorites;
|
||||||
if (isBlockedProfile && blockedTitle) {
|
|
||||||
|
// 渲染帖子项
|
||||||
|
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 (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={blockedTitle}
|
title="已将该用户拉黑"
|
||||||
description={blockedDesc || ''}
|
description="你已将此用户拉黑,不再显示其帖子"
|
||||||
icon="account-off-outline"
|
icon="account-off-outline"
|
||||||
variant="modern"
|
variant="modern"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (postList.length === 0) {
|
const emptyTitle = activeTab === 0
|
||||||
return (
|
? (mode === 'self' ? '还没有帖子' : '这个用户还没有发布任何帖子')
|
||||||
<EmptyState
|
: (mode === 'self' ? '还没有收藏' : '这个用户还没有收藏任何帖子');
|
||||||
title={emptyTitle}
|
const emptyDesc = activeTab === 0
|
||||||
description={emptyDesc}
|
? (mode === 'self' ? '分享你的想法,发布第一条帖子吧' : '')
|
||||||
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-heart-outline'}
|
: (mode === 'self' ? '发现喜欢的内容,点击收藏按钮保存' : '');
|
||||||
variant="modern"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={sharedStyles.postsContainer}>
|
<EmptyState
|
||||||
{postList.map((post, index) => {
|
title={emptyTitle}
|
||||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
description={emptyDesc}
|
||||||
return (
|
icon={activeTab === 0 ? 'file-document-edit-outline' : 'bookmark-heart-outline'}
|
||||||
<View key={post.id} style={[
|
variant="modern"
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [user, isCurrentUser, isBlocked, handleFollow, handleSettings, handleEditProfile, handleMessage, handleBlock, handleFollowingPress, handleFollowersPress]);
|
}, [loading, activeTab, mode, isBlockedProfile]);
|
||||||
|
|
||||||
// 渲染 TabBar 和内容
|
// 渲染 FlashList 头部(用户信息 + TabBar)
|
||||||
const renderTabBarAndContent = useMemo(() => (
|
const renderListHeader = useCallback(() => {
|
||||||
<>
|
if (!user) return null;
|
||||||
<View style={sharedStyles.tabBarContainer}>
|
return (
|
||||||
<TabBar
|
<>
|
||||||
tabs={TABS}
|
<UserProfileHeader
|
||||||
activeIndex={activeTab}
|
user={user}
|
||||||
onTabChange={setActiveTab}
|
isCurrentUser={isCurrentUser}
|
||||||
variant="modern"
|
isBlocked={isBlocked}
|
||||||
icons={TAB_ICONS}
|
onFollow={handleFollow}
|
||||||
|
onSettings={handleSettings}
|
||||||
|
onEditProfile={handleEditProfile}
|
||||||
|
onMessage={handleMessage}
|
||||||
|
onBlock={handleBlock}
|
||||||
|
onFollowingPress={handleFollowingPress}
|
||||||
|
onFollowersPress={handleFollowersPress}
|
||||||
/>
|
/>
|
||||||
</View>
|
<View style={sharedStyles.tabBarContainer}>
|
||||||
<View style={sharedStyles.contentContainer}>
|
<TabBar
|
||||||
{renderContent()}
|
tabs={TABS}
|
||||||
</View>
|
activeIndex={activeTab}
|
||||||
</>
|
onTabChange={setActiveTab}
|
||||||
), [activeTab, renderContent]);
|
variant="modern"
|
||||||
|
icons={TAB_ICONS}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [user, isCurrentUser, isBlocked, handleFollow, handleSettings, handleEditProfile, handleMessage, handleBlock, handleFollowingPress, handleFollowersPress, activeTab, setActiveTab]);
|
||||||
|
|
||||||
// 未登录/用户不存在状态
|
// 未登录/用户不存在状态
|
||||||
if (mode === 'self' && !currentUser) {
|
if (mode === 'self' && !currentUser) {
|
||||||
@@ -209,18 +187,40 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{renderUserHeader}
|
{renderListHeader()}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 右侧:帖子列表 */}
|
{/* 右侧:帖子列表 */}
|
||||||
<View style={sharedStyles.desktopContent}>
|
<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}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={[sharedStyles.desktopScrollContent, { paddingBottom: scrollBottomInset }]}
|
refreshControl={
|
||||||
>
|
<RefreshControl
|
||||||
{renderTabBarAndContent}
|
refreshing={refreshing}
|
||||||
</ScrollView>
|
onRefresh={onRefresh}
|
||||||
|
colors={[colors.primary.main]}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
drawDistance={250}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -231,7 +231,13 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
// 移动端使用单栏布局
|
// 移动端使用单栏布局
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={sharedStyles.container} edges={safeAreaEdges as any}>
|
<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}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -241,11 +247,8 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
contentContainerStyle={[sharedStyles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
drawDistance={250}
|
||||||
>
|
/>
|
||||||
{renderUserHeader}
|
|
||||||
{renderTabBarAndContent}
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出帖子服务实例
|
// 导出帖子服务实例
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export type MessageStatus = 'normal' | 'recalled' | 'deleted';
|
|||||||
// ==================== Message Segment Types ====================
|
// ==================== Message Segment Types ====================
|
||||||
|
|
||||||
// Segment类型
|
// 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数据
|
// 文本Segment数据
|
||||||
export interface TextSegmentData {
|
export interface TextSegmentData {
|
||||||
@@ -232,6 +232,20 @@ export interface VoteSegmentData {
|
|||||||
is_multi_choice?: boolean;
|
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
|
// MessageSegment 联合类型 - 使用 discriminated union
|
||||||
export type MessageSegment =
|
export type MessageSegment =
|
||||||
| { type: 'text'; data: TextSegmentData }
|
| { type: 'text'; data: TextSegmentData }
|
||||||
@@ -243,7 +257,8 @@ export type MessageSegment =
|
|||||||
| { type: 'reply'; data: ReplySegmentData }
|
| { type: 'reply'; data: ReplySegmentData }
|
||||||
| { type: 'face'; data: FaceSegmentData }
|
| { type: 'face'; data: FaceSegmentData }
|
||||||
| { type: 'link'; data: LinkSegmentData }
|
| { type: 'link'; data: LinkSegmentData }
|
||||||
| { type: 'vote'; data: VoteSegmentData };
|
| { type: 'vote'; data: VoteSegmentData }
|
||||||
|
| { type: 'post_ref'; data: PostRefSegmentData };
|
||||||
|
|
||||||
// 消息响应
|
// 消息响应
|
||||||
export interface MessageResponse {
|
export interface MessageResponse {
|
||||||
@@ -787,6 +802,10 @@ export function extractTextFromSegments(segments?: MessageSegment[], memberMap?:
|
|||||||
case 'reply':
|
case 'reply':
|
||||||
// 回复不显示在预览中
|
// 回复不显示在预览中
|
||||||
break;
|
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':
|
case 'reply':
|
||||||
// 回复不显示在预览中
|
// 回复不显示在预览中
|
||||||
break;
|
break;
|
||||||
|
case 'post_ref':
|
||||||
|
const refDataAsync = segment.data as PostRefSegmentData;
|
||||||
|
result += refDataAsync.title ? `[帖子: ${refDataAsync.title}]` : '[帖子引用]';
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user