feat(ui): add emoji picker with FlatList virtualization and improve input components
- Add full emoji picker with virtualized FlatList to CreatePostScreen and PostDetailScreen - Add autoExpand prop to PostMentionInput for XHS-style content area - Improve mention item styling with @ icon and hint text - Fix HomeScreen tab switching race condition with requestAnimationFrame - Fix PostRepository pagination to prefer cursor over page parameter - Fix user store imports to use explicit UserManager path - Refactor useCurrentUser hook to use sessionStore directly
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
@@ -35,6 +36,7 @@ interface PostMentionInputProps {
|
|||||||
maxLength?: number;
|
maxLength?: number;
|
||||||
style?: any;
|
style?: any;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
|
autoExpand?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
||||||
@@ -45,6 +47,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
maxLength = 2000,
|
maxLength = 2000,
|
||||||
style,
|
style,
|
||||||
minHeight = 100,
|
minHeight = 100,
|
||||||
|
autoExpand = false,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const currentUser = useAuthStore(s => s.currentUser);
|
const currentUser = useAuthStore(s => s.currentUser);
|
||||||
@@ -180,16 +183,23 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
||||||
onPress={() => handleSelectMention(item)}
|
onPress={() => handleSelectMention(item)}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Avatar source={item.avatar} size={32} name={item.nickname} />
|
<Avatar source={item.avatar} size={36} name={item.nickname} />
|
||||||
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
<View style={styles.mentionInfo}>
|
||||||
{item.nickname}
|
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
||||||
</Text>
|
{item.nickname}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
|
||||||
|
点击提及
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={style}>
|
<View style={[styles.container, style]}>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -198,11 +208,15 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
maxLength={maxLength}
|
maxLength={maxLength}
|
||||||
multiline
|
multiline
|
||||||
|
textAlignVertical="top"
|
||||||
|
scrollEnabled={!autoExpand}
|
||||||
style={[
|
style={[
|
||||||
styles.input,
|
styles.input,
|
||||||
|
autoExpand && styles.inputAutoExpand,
|
||||||
{
|
{
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: autoExpand ? 'transparent' : colors.background.default,
|
||||||
|
minHeight,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -232,6 +246,9 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
input: {
|
input: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
padding: spacing.sm,
|
padding: spacing.sm,
|
||||||
@@ -239,6 +256,12 @@ const styles = StyleSheet.create({
|
|||||||
minHeight: 100,
|
minHeight: 100,
|
||||||
textAlignVertical: 'top' as const,
|
textAlignVertical: 'top' as const,
|
||||||
},
|
},
|
||||||
|
inputAutoExpand: {
|
||||||
|
borderRadius: 0,
|
||||||
|
paddingHorizontal: 0,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
paddingBottom: spacing.sm,
|
||||||
|
},
|
||||||
mentionPanel: {
|
mentionPanel: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.md,
|
||||||
@@ -255,9 +278,18 @@ const styles = StyleSheet.create({
|
|||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
|
mentionInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
mentionName: {
|
mentionName: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
marginLeft: spacing.sm,
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
mentionHint: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
|
|||||||
@@ -241,11 +241,12 @@ export class PostRepository implements IPostRepository {
|
|||||||
try {
|
try {
|
||||||
const queryParams: Record<string, any> = {};
|
const queryParams: Record<string, any> = {};
|
||||||
|
|
||||||
if (params?.page !== undefined) queryParams.page = params.page;
|
|
||||||
if (params?.pageSize) queryParams.page_size = params.pageSize;
|
if (params?.pageSize) queryParams.page_size = params.pageSize;
|
||||||
// 支持 cursor 参数:空字符串也传递,用于启动 cursor 模式
|
// cursor 与 page 互斥:cursor 存在(含空字符串)时走游标分页,否则走页码分页
|
||||||
if (params?.cursor !== undefined && params?.cursor !== null) {
|
if (params?.cursor !== undefined && params?.cursor !== null) {
|
||||||
queryParams.cursor = params.cursor;
|
queryParams.cursor = params.cursor;
|
||||||
|
} else if (params?.page !== undefined) {
|
||||||
|
queryParams.page = params.page;
|
||||||
}
|
}
|
||||||
if (params?.post_type) queryParams.tab = params.post_type;
|
if (params?.post_type) queryParams.tab = params.post_type;
|
||||||
if (params?.channelId) queryParams.channel_id = params.channelId;
|
if (params?.channelId) queryParams.channel_id = params.channelId;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
Animated,
|
Animated,
|
||||||
Image,
|
Image,
|
||||||
useWindowDimensions,
|
useWindowDimensions,
|
||||||
|
FlatList,
|
||||||
|
Keyboard,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
|
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
|
||||||
@@ -82,6 +84,22 @@ const EMOJIS = [
|
|||||||
'💯', '💢', '💥', '💫', '💦', '💨', '🕳️',
|
'💯', '💢', '💥', '💫', '💦', '💨', '🕳️',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 每行显示的 emoji 数量
|
||||||
|
const EMOJIS_PER_ROW = 8;
|
||||||
|
const EMOJI_ROW_HEIGHT = 52;
|
||||||
|
|
||||||
|
// 将 emoji 分组成行,用于 FlatList 虚拟化
|
||||||
|
const EMOJI_ROWS = (() => {
|
||||||
|
const rows: { id: string; emojis: string[] }[] = [];
|
||||||
|
for (let i = 0; i < EMOJIS.length; i += EMOJIS_PER_ROW) {
|
||||||
|
rows.push({
|
||||||
|
id: `emoji-row-${i}`,
|
||||||
|
emojis: EMOJIS.slice(i, i + EMOJIS_PER_ROW),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
})();
|
||||||
|
|
||||||
// 动画值
|
// 动画值
|
||||||
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
||||||
|
|
||||||
@@ -136,11 +154,11 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
? Math.min(width, 800) - spacing.lg * 2
|
? Math.min(width, 800) - spacing.lg * 2
|
||||||
: width - spacing.lg * 2;
|
: width - spacing.lg * 2;
|
||||||
const imageSize = Math.floor((availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow);
|
const imageSize = Math.floor((availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow);
|
||||||
|
// 小红书风格:内容区域占满剩余空间,输入框自动扩展
|
||||||
const contentInputMinHeight = Math.max(
|
const contentInputMinHeight = Math.max(
|
||||||
isWideScreen ? 460 : 320,
|
isWideScreen ? 200 : 160,
|
||||||
Math.floor(windowHeight * (isWideScreen ? 0.56 : 0.5))
|
Math.floor(windowHeight * (isWideScreen ? 0.3 : 0.25))
|
||||||
);
|
);
|
||||||
const contentSectionMinHeight = Math.floor(windowHeight * (isWideScreen ? 0.68 : 0.62));
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
@@ -312,8 +330,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
|
|
||||||
// 切换表情面板
|
// 切换表情面板
|
||||||
const handleToggleEmojiPanel = () => {
|
const handleToggleEmojiPanel = () => {
|
||||||
|
const willShow = !showEmojiPanel;
|
||||||
closeAllPanels();
|
closeAllPanels();
|
||||||
setShowEmojiPanel(!showEmojiPanel);
|
if (willShow) {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
}
|
||||||
|
setShowEmojiPanel(willShow);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换频道选择
|
// 切换频道选择
|
||||||
@@ -389,7 +411,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
await voteService.createVotePost({
|
await voteService.createVotePost({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
images: imageUrls,
|
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||||
channel_id: selectedChannelId || undefined,
|
channel_id: selectedChannelId || undefined,
|
||||||
vote_options: validOptions.map(opt => ({ content: opt })),
|
vote_options: validOptions.map(opt => ({ content: opt })),
|
||||||
});
|
});
|
||||||
@@ -405,8 +427,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
const updated = await postService.updatePost(editPostID, {
|
const updated = await postService.updatePost(editPostID, {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
segments: segments.length > 0 ? segments : undefined,
|
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
||||||
images: imageUrls,
|
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||||
});
|
});
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw new Error('更新帖子失败');
|
throw new Error('更新帖子失败');
|
||||||
@@ -422,8 +444,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
await postService.createPost({
|
await postService.createPost({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
segments: segments.length > 0 ? segments : undefined,
|
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
|
||||||
images: imageUrls,
|
images: imageUrls.length > 0 ? imageUrls : undefined,
|
||||||
channel_id: selectedChannelId || undefined,
|
channel_id: selectedChannelId || undefined,
|
||||||
});
|
});
|
||||||
showPrompt({
|
showPrompt({
|
||||||
@@ -583,12 +605,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
|
|
||||||
// 渲染内容输入区
|
// 渲染内容输入区
|
||||||
const renderContentSection = () => (
|
const renderContentSection = () => (
|
||||||
<View
|
<View style={styles.contentSection}>
|
||||||
style={[
|
|
||||||
styles.contentSection,
|
|
||||||
contentSectionMinHeight ? { minHeight: contentSectionMinHeight } : null,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* 标题输入 */}
|
{/* 标题输入 */}
|
||||||
<TextInput
|
<TextInput
|
||||||
style={[
|
style={[
|
||||||
@@ -597,19 +614,20 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
]}
|
]}
|
||||||
value={title}
|
value={title}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
placeholder="请输入标题(必填)"
|
placeholder="填写标题会有更多赞哦~"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
maxLength={MAX_TITLE_LENGTH}
|
maxLength={MAX_TITLE_LENGTH}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 正文输入(支持 @提及) */}
|
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
|
||||||
<PostMentionInput
|
<PostMentionInput
|
||||||
value={content}
|
value={content}
|
||||||
onChangeText={setContent}
|
onChangeText={setContent}
|
||||||
onSegmentsChange={setSegments}
|
onSegmentsChange={setSegments}
|
||||||
placeholder="分享新鲜事..."
|
placeholder="添加正文"
|
||||||
maxLength={MAX_CONTENT_LENGTH}
|
maxLength={MAX_CONTENT_LENGTH}
|
||||||
minHeight={contentInputMinHeight}
|
minHeight={contentInputMinHeight}
|
||||||
|
autoExpand
|
||||||
style={[
|
style={[
|
||||||
styles.contentInput,
|
styles.contentInput,
|
||||||
isWideScreen && styles.contentInputWide,
|
isWideScreen && styles.contentInputWide,
|
||||||
@@ -622,7 +640,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 渲染表情面板
|
// 渲染表情面板 —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿
|
||||||
const renderEmojiPanel = () => {
|
const renderEmojiPanel = () => {
|
||||||
if (!showEmojiPanel) return null;
|
if (!showEmojiPanel) return null;
|
||||||
|
|
||||||
@@ -631,20 +649,34 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
styles.emojiPanel,
|
styles.emojiPanel,
|
||||||
isWideScreen && styles.emojiPanelWide,
|
isWideScreen && styles.emojiPanelWide,
|
||||||
]}>
|
]}>
|
||||||
<ScrollView
|
<FlatList
|
||||||
|
data={EMOJI_ROWS}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.emojiRow}>
|
||||||
|
{item.emojis.map((emoji, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`${item.id}-${index}`}
|
||||||
|
style={styles.emojiItem}
|
||||||
|
onPress={() => handleInsertEmoji(emoji)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.emojiText}>{emoji}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.emojiGrid}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
initialNumToRender={8}
|
||||||
{EMOJIS.map((emoji, index) => (
|
maxToRenderPerBatch={8}
|
||||||
<TouchableOpacity
|
windowSize={5}
|
||||||
key={index}
|
getItemLayout={(_data, index) => ({
|
||||||
style={styles.emojiItem}
|
length: EMOJI_ROW_HEIGHT,
|
||||||
onPress={() => handleInsertEmoji(emoji)}
|
offset: EMOJI_ROW_HEIGHT * index,
|
||||||
>
|
index,
|
||||||
<Text style={styles.emojiText}>{emoji}</Text>
|
})}
|
||||||
</TouchableOpacity>
|
/>
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -874,7 +906,7 @@ function createCreatePostStyles(colors: AppColors) {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
titleInputWide: {
|
titleInputWide: {
|
||||||
fontSize: fontSizes.xl,
|
fontSize: fontSizes.xl,
|
||||||
@@ -883,12 +915,12 @@ function createCreatePostStyles(colors: AppColors) {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
lineHeight: 22,
|
lineHeight: 24,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
},
|
},
|
||||||
contentInputWide: {
|
contentInputWide: {
|
||||||
fontSize: fontSizes.lg,
|
fontSize: fontSizes.lg,
|
||||||
lineHeight: 26,
|
lineHeight: 28,
|
||||||
},
|
},
|
||||||
// 图片网格
|
// 图片网格
|
||||||
imageGrid: {
|
imageGrid: {
|
||||||
@@ -1036,20 +1068,22 @@ function createCreatePostStyles(colors: AppColors) {
|
|||||||
emojiPanelWide: {
|
emojiPanelWide: {
|
||||||
height: 320,
|
height: 320,
|
||||||
},
|
},
|
||||||
emojiGrid: {
|
emojiRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
justifyContent: 'space-around',
|
||||||
padding: spacing.md,
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-start',
|
height: EMOJI_ROW_HEIGHT,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
},
|
},
|
||||||
emojiItem: {
|
emojiItem: {
|
||||||
width: '12.5%',
|
width: 44,
|
||||||
aspectRatio: 1,
|
height: 44,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
emojiText: {
|
emojiText: {
|
||||||
fontSize: 24,
|
fontSize: 26,
|
||||||
|
lineHeight: 32,
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -439,8 +439,18 @@ export const HomeScreen: React.FC = () => {
|
|||||||
|
|
||||||
// Tab 切换时刷新数据并重置列表
|
// Tab 切换时刷新数据并重置列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset();
|
let cancelled = false;
|
||||||
refresh();
|
const run = async () => {
|
||||||
|
reset();
|
||||||
|
// reset 会清空 store 中的状态,但异步订阅可能有延迟;
|
||||||
|
// 等一帧确保 store 的订阅回调已传播到 useDifferentialPosts
|
||||||
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||||
|
if (!cancelled) {
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
return () => { cancelled = true; };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeIndex, currentChannelId]);
|
}, [activeIndex, currentChannelId]);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
Image,
|
Image,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
|
Dimensions,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
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';
|
||||||
@@ -45,6 +46,55 @@ import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../h
|
|||||||
import { handleError } from '@/services/core';
|
import { handleError } from '@/services/core';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
|
||||||
|
const EMOJIS = [
|
||||||
|
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
|
||||||
|
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
|
||||||
|
'😘', '😗', '😚', '😙', '😋', '😛', '😜', '🤪',
|
||||||
|
'😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨',
|
||||||
|
'😐', '😑', '😶', '😏', '😒', '🙄', '😬', '🤥',
|
||||||
|
'😌', '😔', '😪', '🤤', '😴', '😷', '🤒', '🤕',
|
||||||
|
'🤢', '🤮', '🤧', '🥵', '🥶', '🥴', '😵', '🤯',
|
||||||
|
'🤠', '🥳', '🥸', '😎', '🤓', '🧐', '😕', '😟',
|
||||||
|
'🙁', '😮', '😯', '😲', '😳', '🥺', '😦', '😧',
|
||||||
|
'😨', '😰', '😥', '😢', '😭', '😱', '😖', '😣',
|
||||||
|
'😞', '😓', '😩', '😫', '🥱', '😤', '😡', '😠',
|
||||||
|
'🤬', '😈', '👿', '💀', '👋', '🤚', '🖐️', '✋',
|
||||||
|
'🖖', '👌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙',
|
||||||
|
'👈', '👉', '👆', '👍', '👎', '✊', '👊', '🤛',
|
||||||
|
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '💪',
|
||||||
|
'🦾', '🦵', '❤️', '🧡', '💛', '💚', '💙', '💜',
|
||||||
|
'🖤', '🤍', '🤎', '💕', '💞', '💓', '💗', '💖',
|
||||||
|
'💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥',
|
||||||
|
'💯', '💢', '💥', '💫', '💦', '💨', '🐶', '🐱',
|
||||||
|
'🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯',
|
||||||
|
'🦁', '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🐦',
|
||||||
|
'🐤', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴',
|
||||||
|
'🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐜', '🍎',
|
||||||
|
'🍏', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓',
|
||||||
|
'🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅',
|
||||||
|
'🍆', '🥑', '🌽', '🥕', '🍞', '🥐', '🍔', '🍟',
|
||||||
|
'🍕', '🌮', '🌯', '🍜', '🍣', '🍱', '🍰', '🎂',
|
||||||
|
'🍩', '🍪', '☕', '🍵', '🍺', '🍷', '⚽', '🏀',
|
||||||
|
'🏈', '⚾', '🎾', '🏐', '🎮', '🎲', '🚗', '🚕',
|
||||||
|
'✈️', '🚀', '⛵', '🌟', '⭐', '🌈', '☀️', '🌙',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 每行显示的 emoji 数量
|
||||||
|
const EMOJIS_PER_ROW = 8;
|
||||||
|
const EMOJI_ROW_HEIGHT = 52;
|
||||||
|
|
||||||
|
// 将 emoji 分组成行,用于 FlatList 虚拟化
|
||||||
|
const EMOJI_ROWS = (() => {
|
||||||
|
const rows: { id: string; emojis: string[] }[] = [];
|
||||||
|
for (let i = 0; i < EMOJIS.length; i += EMOJIS_PER_ROW) {
|
||||||
|
rows.push({
|
||||||
|
id: `emoji-row-${i}`,
|
||||||
|
emojis: EMOJIS.slice(i, i + EMOJIS_PER_ROW),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
})();
|
||||||
|
|
||||||
function togglePostField(
|
function togglePostField(
|
||||||
post: Post,
|
post: Post,
|
||||||
field: 'is_liked' | 'is_favorited',
|
field: 'is_liked' | 'is_favorited',
|
||||||
@@ -199,6 +249,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
// 评论图片相关状态
|
// 评论图片相关状态
|
||||||
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
||||||
|
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||||
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);
|
||||||
@@ -1359,9 +1410,27 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
const closeComposer = () => {
|
const closeComposer = () => {
|
||||||
setIsComposerVisible(false);
|
setIsComposerVisible(false);
|
||||||
|
setShowEmojiPanel(false);
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAtMention = () => {
|
||||||
|
setCommentText(prev => prev + '@');
|
||||||
|
setShowEmojiPanel(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEmojiPanel = () => {
|
||||||
|
const willShow = !showEmojiPanel;
|
||||||
|
if (willShow) {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
}
|
||||||
|
setShowEmojiPanel(willShow);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInsertEmoji = (emoji: string) => {
|
||||||
|
setCommentText(prev => prev + emoji);
|
||||||
|
};
|
||||||
|
|
||||||
const renderCommentInput = () => (
|
const renderCommentInput = () => (
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.inputContainer,
|
styles.inputContainer,
|
||||||
@@ -1443,14 +1512,15 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Text Input */}
|
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
|
||||||
<PostMentionInput
|
<PostMentionInput
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChangeText={setCommentText}
|
onChangeText={setCommentText}
|
||||||
onSegmentsChange={setCommentSegments}
|
onSegmentsChange={setCommentSegments}
|
||||||
placeholder="来说点什么吧!"
|
placeholder={replyingTo ? `回复 ${replyingTo.author.nickname}...` : '来说点什么吧!'}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
minHeight={40}
|
minHeight={60}
|
||||||
|
autoExpand
|
||||||
style={styles.expandedTextInput}
|
style={styles.expandedTextInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1492,7 +1562,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.expandedToolbarItem}
|
style={styles.expandedToolbarItem}
|
||||||
onPress={() => {}}
|
onPress={handleAtMention}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="at"
|
name="at"
|
||||||
@@ -1502,12 +1572,12 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.expandedToolbarItem}
|
style={styles.expandedToolbarItem}
|
||||||
onPress={() => {}}
|
onPress={handleToggleEmojiPanel}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="gamepad-variant-outline"
|
name={showEmojiPanel ? "emoticon-happy" : "emoticon-happy-outline"}
|
||||||
size={22}
|
size={22}
|
||||||
color={colors.text.secondary}
|
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.expandedCharCount}>
|
<Text variant="caption" color={colors.text.hint} style={styles.expandedCharCount}>
|
||||||
@@ -1526,6 +1596,40 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
<Text style={styles.expandedSendButtonText}>发送</Text>
|
<Text style={styles.expandedSendButtonText}>发送</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Emoji Panel —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿 */}
|
||||||
|
{showEmojiPanel && (
|
||||||
|
<View style={styles.emojiPanel}>
|
||||||
|
<FlatList
|
||||||
|
data={EMOJI_ROWS}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.emojiRow}>
|
||||||
|
{item.emojis.map((emoji, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`${item.id}-${index}`}
|
||||||
|
style={styles.emojiItem}
|
||||||
|
onPress={() => handleInsertEmoji(emoji)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.emojiText}>{emoji}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
initialNumToRender={8}
|
||||||
|
maxToRenderPerBatch={8}
|
||||||
|
windowSize={5}
|
||||||
|
getItemLayout={(_data, index) => ({
|
||||||
|
length: EMOJI_ROW_HEIGHT,
|
||||||
|
offset: EMOJI_ROW_HEIGHT * index,
|
||||||
|
index,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -2043,10 +2147,10 @@ function createPostDetailStyles(colors: AppColors) {
|
|||||||
inputTrigger: {
|
inputTrigger: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
borderRadius: 18,
|
borderRadius: 20,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm + 2,
|
paddingVertical: spacing.sm + 2,
|
||||||
minHeight: 38,
|
minHeight: 40,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
inputTriggerText: {
|
inputTriggerText: {
|
||||||
@@ -2117,13 +2221,12 @@ function createPostDetailStyles(colors: AppColors) {
|
|||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
expandedTextInput: {
|
expandedTextInput: {
|
||||||
minHeight: 80,
|
flexGrow: 1,
|
||||||
maxHeight: 160,
|
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
textAlignVertical: 'top',
|
textAlignVertical: 'top',
|
||||||
lineHeight: 22,
|
lineHeight: 24,
|
||||||
},
|
},
|
||||||
expandedToolbar: {
|
expandedToolbar: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -2163,6 +2266,29 @@ function createPostDetailStyles(colors: AppColors) {
|
|||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
emojiPanel: {
|
||||||
|
height: 220,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
emojiRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: EMOJI_ROW_HEIGHT,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
},
|
||||||
|
emojiItem: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
emojiText: {
|
||||||
|
fontSize: 26,
|
||||||
|
lineHeight: 32,
|
||||||
|
},
|
||||||
modalContainer: {
|
modalContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
// 7. 清除 sessionStore 的 userId
|
// 7. 清除 sessionStore 的 userId
|
||||||
useSessionStore.getState().setUserId(null);
|
useSessionStore.getState().setUserId(null);
|
||||||
// 8. 清除 userManager 的内存缓存
|
// 8. 清除 userManager 的内存缓存
|
||||||
const { userManager } = await import('../user');
|
const { userManager } = await import('../user/UserManager');
|
||||||
userManager.invalidateUsers();
|
userManager.invalidateUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthStore] 登出失败:', error);
|
console.error('[AuthStore] 登出失败:', error);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
import { webrtcManager, ICEServer } from '@/services/webrtc';
|
import { webrtcManager, ICEServer } from '@/services/webrtc';
|
||||||
import { getCurrentUserId } from '../auth/sessionStore';
|
import { getCurrentUserId } from '../auth/sessionStore';
|
||||||
import { useUserStore } from '../userStore';
|
import { useUserStore } from '../userStore';
|
||||||
import { userManager } from '../user';
|
import { userManager } from '../user/UserManager';
|
||||||
|
|
||||||
export type CallStatus =
|
export type CallStatus =
|
||||||
| 'idle' // 空闲状态
|
| 'idle' // 空闲状态
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
|||||||
import { useUserManagerStore } from './userStore';
|
import { useUserManagerStore } from './userStore';
|
||||||
import { userManager } from './UserManager';
|
import { userManager } from './UserManager';
|
||||||
import type { UserDTO } from '../../types/dto';
|
import type { UserDTO } from '../../types/dto';
|
||||||
import { useAuthStore } from '../auth/authStore';
|
import { useSessionStore } from '../auth/sessionStore';
|
||||||
|
|
||||||
export interface UseCurrentUserResult {
|
export interface UseCurrentUserResult {
|
||||||
user: UserDTO | null;
|
user: UserDTO | null;
|
||||||
@@ -16,8 +16,7 @@ export function useCurrentUser(): UseCurrentUserResult {
|
|||||||
const lastFetchedRef = useRef<string | null>(null);
|
const lastFetchedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const authUser = useAuthStore.getState().currentUser;
|
const userId = useSessionStore.getState().userId;
|
||||||
const userId = authUser?.id;
|
|
||||||
if (userId && lastFetchedRef.current !== userId) {
|
if (userId && lastFetchedRef.current !== userId) {
|
||||||
lastFetchedRef.current = userId;
|
lastFetchedRef.current = userId;
|
||||||
useUserManagerStore.getState().setLoadingCurrentUser(true);
|
useUserManagerStore.getState().setLoadingCurrentUser(true);
|
||||||
@@ -28,8 +27,8 @@ export function useCurrentUser(): UseCurrentUserResult {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useAuthStore.subscribe((state, prev) => {
|
return useSessionStore.subscribe((state, prev) => {
|
||||||
if (!state.currentUser && prev.currentUser) {
|
if (!state.userId && prev.userId) {
|
||||||
lastFetchedRef.current = null;
|
lastFetchedRef.current = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
postService,
|
postService,
|
||||||
notificationService,
|
notificationService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
import { userManager } from './user';
|
import { userManager } from './user/UserManager';
|
||||||
import { messageManager } from './message';
|
import { messageManager } from './message';
|
||||||
|
|
||||||
interface UserState {
|
interface UserState {
|
||||||
|
|||||||
Reference in New Issue
Block a user