feat(ui): add emoji picker with FlatList virtualization and improve input components
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m43s
Frontend CI / ota-android (push) Successful in 10m32s
Frontend CI / build-android-apk (push) Successful in 47m43s

- 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:
lafay
2026-04-24 16:44:01 +08:00
parent b2c3d5e54e
commit 738e75a79d
9 changed files with 277 additions and 75 deletions

View File

@@ -20,6 +20,8 @@ import {
Animated,
Image,
useWindowDimensions,
FlatList,
Keyboard,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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);
@@ -132,15 +150,15 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 响应式图片网格配置
const imagesPerRow = useResponsiveValue({ xs: 3, sm: 3, md: 4, lg: 5, xl: 6 });
const imageGap = 4;
const availableWidth = isWideScreen
? Math.min(width, 800) - spacing.lg * 2
const availableWidth = isWideScreen
? Math.min(width, 800) - spacing.lg * 2
: width - spacing.lg * 2;
const imageSize = Math.floor((availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow);
// 小红书风格:内容区域占满剩余空间,输入框自动扩展
const contentInputMinHeight = Math.max(
isWideScreen ? 460 : 320,
Math.floor(windowHeight * (isWideScreen ? 0.56 : 0.5))
isWideScreen ? 200 : 160,
Math.floor(windowHeight * (isWideScreen ? 0.3 : 0.25))
);
const contentSectionMinHeight = Math.floor(windowHeight * (isWideScreen ? 0.68 : 0.62));
React.useEffect(() => {
Animated.parallel([
@@ -312,8 +330,12 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 切换表情面板
const handleToggleEmojiPanel = () => {
const willShow = !showEmojiPanel;
closeAllPanels();
setShowEmojiPanel(!showEmojiPanel);
if (willShow) {
Keyboard.dismiss();
}
setShowEmojiPanel(willShow);
};
// 切换频道选择
@@ -389,7 +411,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
await voteService.createVotePost({
title: title.trim(),
content: content.trim(),
images: imageUrls,
images: imageUrls.length > 0 ? imageUrls : undefined,
channel_id: selectedChannelId || undefined,
vote_options: validOptions.map(opt => ({ content: opt })),
});
@@ -405,8 +427,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
const updated = await postService.updatePost(editPostID, {
title: title.trim(),
content: content.trim(),
segments: segments.length > 0 ? segments : undefined,
images: imageUrls,
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
images: imageUrls.length > 0 ? imageUrls : undefined,
});
if (!updated) {
throw new Error('更新帖子失败');
@@ -422,8 +444,8 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
await postService.createPost({
title: title.trim(),
content: content.trim(),
segments: segments.length > 0 ? segments : undefined,
images: imageUrls,
segments: segments.some(s => s.type !== 'text') ? segments : undefined,
images: imageUrls.length > 0 ? imageUrls : undefined,
channel_id: selectedChannelId || undefined,
});
showPrompt({
@@ -583,12 +605,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
// 渲染内容输入区
const renderContentSection = () => (
<View
style={[
styles.contentSection,
contentSectionMinHeight ? { minHeight: contentSectionMinHeight } : null,
]}
>
<View style={styles.contentSection}>
{/* 标题输入 */}
<TextInput
style={[
@@ -597,19 +614,20 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
]}
value={title}
onChangeText={setTitle}
placeholder="请输入标题(必填)"
placeholder="填写标题会有更多赞哦~"
placeholderTextColor={colors.text.hint}
maxLength={MAX_TITLE_LENGTH}
/>
{/* 正文输入(支持 @提及) */}
{/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */}
<PostMentionInput
value={content}
onChangeText={setContent}
onSegmentsChange={setSegments}
placeholder="分享新鲜事..."
placeholder="添加正文"
maxLength={MAX_CONTENT_LENGTH}
minHeight={contentInputMinHeight}
autoExpand
style={[
styles.contentInput,
isWideScreen && styles.contentInputWide,
@@ -622,7 +640,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
</View>
);
// 渲染表情面板
// 渲染表情面板 —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿
const renderEmojiPanel = () => {
if (!showEmojiPanel) return null;
@@ -631,20 +649,34 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
styles.emojiPanel,
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}
contentContainerStyle={styles.emojiGrid}
>
{EMOJIS.map((emoji, index) => (
<TouchableOpacity
key={index}
style={styles.emojiItem}
onPress={() => handleInsertEmoji(emoji)}
>
<Text style={styles.emojiText}>{emoji}</Text>
</TouchableOpacity>
))}
</ScrollView>
keyboardShouldPersistTaps="handled"
initialNumToRender={8}
maxToRenderPerBatch={8}
windowSize={5}
getItemLayout={(_data, index) => ({
length: EMOJI_ROW_HEIGHT,
offset: EMOJI_ROW_HEIGHT * index,
index,
})}
/>
</View>
);
};
@@ -874,7 +906,7 @@ function createCreatePostStyles(colors: AppColors) {
fontWeight: '600',
color: colors.text.primary,
paddingVertical: spacing.sm,
marginBottom: spacing.sm,
marginBottom: spacing.xs,
},
titleInputWide: {
fontSize: fontSizes.xl,
@@ -883,12 +915,12 @@ function createCreatePostStyles(colors: AppColors) {
flexGrow: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
lineHeight: 22,
lineHeight: 24,
paddingVertical: spacing.sm,
},
contentInputWide: {
fontSize: fontSizes.lg,
lineHeight: 26,
lineHeight: 28,
},
// 图片网格
imageGrid: {
@@ -1036,20 +1068,22 @@ function createCreatePostStyles(colors: AppColors) {
emojiPanelWide: {
height: 320,
},
emojiGrid: {
emojiRow: {
flexDirection: 'row',
flexWrap: 'wrap',
padding: spacing.md,
justifyContent: 'flex-start',
justifyContent: 'space-around',
alignItems: 'center',
height: EMOJI_ROW_HEIGHT,
paddingHorizontal: spacing.md,
},
emojiItem: {
width: '12.5%',
aspectRatio: 1,
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
emojiText: {
fontSize: 24,
fontSize: 26,
lineHeight: 32,
},
loadingContainer: {
flex: 1,

View File

@@ -439,8 +439,18 @@ export const HomeScreen: React.FC = () => {
// Tab 切换时刷新数据并重置列表
useEffect(() => {
reset();
refresh();
let cancelled = false;
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
}, [activeIndex, currentChannelId]);

View File

@@ -21,6 +21,7 @@ import {
ScrollView,
Image,
Clipboard,
Dimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRouter, useLocalSearchParams } from 'expo-router';
@@ -45,6 +46,55 @@ import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../h
import { handleError } from '@/services/core';
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(
post: Post,
field: 'is_liked' | 'is_favorited',
@@ -199,6 +249,7 @@ export const PostDetailScreen: React.FC = () => {
const [isDeleting, setIsDeleting] = useState(false);
// 评论图片相关状态
const [commentImages, setCommentImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [isFollowing, setIsFollowing] = useState(false);
const [isFollowingMe, setIsFollowingMe] = useState(false);
const [isFollowLoading, setIsFollowLoading] = useState(false);
@@ -1359,9 +1410,27 @@ export const PostDetailScreen: React.FC = () => {
const closeComposer = () => {
setIsComposerVisible(false);
setShowEmojiPanel(false);
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 = () => (
<View style={[
styles.inputContainer,
@@ -1443,14 +1512,15 @@ export const PostDetailScreen: React.FC = () => {
</View>
)}
{/* Text Input */}
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
<PostMentionInput
value={commentText}
onChangeText={setCommentText}
onSegmentsChange={setCommentSegments}
placeholder="来说点什么吧!"
placeholder={replyingTo ? `回复 ${replyingTo.author.nickname}...` : '来说点什么吧!'}
maxLength={500}
minHeight={40}
minHeight={60}
autoExpand
style={styles.expandedTextInput}
/>
@@ -1492,7 +1562,7 @@ export const PostDetailScreen: React.FC = () => {
</TouchableOpacity>
<TouchableOpacity
style={styles.expandedToolbarItem}
onPress={() => {}}
onPress={handleAtMention}
>
<MaterialCommunityIcons
name="at"
@@ -1502,12 +1572,12 @@ export const PostDetailScreen: React.FC = () => {
</TouchableOpacity>
<TouchableOpacity
style={styles.expandedToolbarItem}
onPress={() => {}}
onPress={handleToggleEmojiPanel}
>
<MaterialCommunityIcons
name="gamepad-variant-outline"
name={showEmojiPanel ? "emoticon-happy" : "emoticon-happy-outline"}
size={22}
color={colors.text.secondary}
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
/>
</TouchableOpacity>
<Text variant="caption" color={colors.text.hint} style={styles.expandedCharCount}>
@@ -1526,6 +1596,40 @@ export const PostDetailScreen: React.FC = () => {
<Text style={styles.expandedSendButtonText}></Text>
</TouchableOpacity>
</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>
@@ -2043,10 +2147,10 @@ function createPostDetailStyles(colors: AppColors) {
inputTrigger: {
flex: 1,
backgroundColor: colors.background.default,
borderRadius: 18,
borderRadius: 20,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
minHeight: 38,
minHeight: 40,
justifyContent: 'center',
},
inputTriggerText: {
@@ -2117,13 +2221,12 @@ function createPostDetailStyles(colors: AppColors) {
marginBottom: spacing.sm,
},
expandedTextInput: {
minHeight: 80,
maxHeight: 160,
flexGrow: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
paddingVertical: spacing.sm,
textAlignVertical: 'top',
lineHeight: 22,
lineHeight: 24,
},
expandedToolbar: {
flexDirection: 'row',
@@ -2163,6 +2266,29 @@ function createPostDetailStyles(colors: AppColors) {
fontSize: fontSizes.sm,
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: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.9)',