diff --git a/src/components/business/PostMentionInput.tsx b/src/components/business/PostMentionInput.tsx index ea26219..1aa3ff0 100644 --- a/src/components/business/PostMentionInput.tsx +++ b/src/components/business/PostMentionInput.tsx @@ -13,6 +13,7 @@ import { StyleSheet, Platform, } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; import Text from '../common/Text'; import Avatar from '../common/Avatar'; @@ -35,6 +36,7 @@ interface PostMentionInputProps { maxLength?: number; style?: any; minHeight?: number; + autoExpand?: boolean; } const PostMentionInput: React.FC = ({ @@ -45,6 +47,7 @@ const PostMentionInput: React.FC = ({ maxLength = 2000, style, minHeight = 100, + autoExpand = false, }) => { const colors = useAppColors(); const currentUser = useAuthStore(s => s.currentUser); @@ -180,16 +183,23 @@ const PostMentionInput: React.FC = ({ handleSelectMention(item)} + activeOpacity={0.7} > - - - {item.nickname} - + + + + {item.nickname} + + + 点击提及 + + + ); return ( - + = ({ placeholderTextColor={colors.text.hint} maxLength={maxLength} multiline + textAlignVertical="top" + scrollEnabled={!autoExpand} style={[ styles.input, + autoExpand && styles.inputAutoExpand, { color: colors.text.primary, - backgroundColor: colors.background.default, + backgroundColor: autoExpand ? 'transparent' : colors.background.default, + minHeight, }, ]} /> @@ -232,6 +246,9 @@ const PostMentionInput: React.FC = ({ }; const styles = StyleSheet.create({ + container: { + flexGrow: 1, + }, input: { fontSize: fontSizes.md, padding: spacing.sm, @@ -239,6 +256,12 @@ const styles = StyleSheet.create({ minHeight: 100, textAlignVertical: 'top' as const, }, + inputAutoExpand: { + borderRadius: 0, + paddingHorizontal: 0, + paddingTop: spacing.sm, + paddingBottom: spacing.sm, + }, mentionPanel: { borderWidth: 1, borderRadius: borderRadius.md, @@ -255,9 +278,18 @@ const styles = StyleSheet.create({ paddingVertical: spacing.sm, borderBottomWidth: StyleSheet.hairlineWidth, }, + mentionInfo: { + flex: 1, + marginLeft: spacing.sm, + justifyContent: 'center', + }, mentionName: { fontSize: fontSizes.md, - marginLeft: spacing.sm, + fontWeight: '500', + }, + mentionHint: { + fontSize: fontSizes.xs, + marginTop: 2, }, emptyText: { fontSize: fontSizes.sm, diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index f710698..28f7923 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -241,11 +241,12 @@ export class PostRepository implements IPostRepository { try { const queryParams: Record = {}; - if (params?.page !== undefined) queryParams.page = params.page; if (params?.pageSize) queryParams.page_size = params.pageSize; - // 支持 cursor 参数:空字符串也传递,用于启动 cursor 模式 + // cursor 与 page 互斥:cursor 存在(含空字符串)时走游标分页,否则走页码分页 if (params?.cursor !== undefined && params?.cursor !== null) { queryParams.cursor = params.cursor; + } else if (params?.page !== undefined) { + queryParams.page = params.page; } if (params?.post_type) queryParams.tab = params.post_type; if (params?.channelId) queryParams.channel_id = params.channelId; diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index 0448e9d..db9707d 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -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 = (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 = (props) => { // 切换表情面板 const handleToggleEmojiPanel = () => { + const willShow = !showEmojiPanel; closeAllPanels(); - setShowEmojiPanel(!showEmojiPanel); + if (willShow) { + Keyboard.dismiss(); + } + setShowEmojiPanel(willShow); }; // 切换频道选择 @@ -389,7 +411,7 @@ export const CreatePostScreen: React.FC = (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 = (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 = (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 = (props) => { // 渲染内容输入区 const renderContentSection = () => ( - + {/* 标题输入 */} = (props) => { ]} value={title} onChangeText={setTitle} - placeholder="请输入标题(必填)" + placeholder="填写标题会有更多赞哦~" placeholderTextColor={colors.text.hint} maxLength={MAX_TITLE_LENGTH} /> - {/* 正文输入(支持 @提及) */} + {/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */} = (props) => { ); - // 渲染表情面板 + // 渲染表情面板 —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿 const renderEmojiPanel = () => { if (!showEmojiPanel) return null; @@ -631,20 +649,34 @@ export const CreatePostScreen: React.FC = (props) => { styles.emojiPanel, isWideScreen && styles.emojiPanelWide, ]}> - item.id} + renderItem={({ item }) => ( + + {item.emojis.map((emoji, index) => ( + handleInsertEmoji(emoji)} + activeOpacity={0.7} + > + {emoji} + + ))} + + )} showsVerticalScrollIndicator={false} - contentContainerStyle={styles.emojiGrid} - > - {EMOJIS.map((emoji, index) => ( - handleInsertEmoji(emoji)} - > - {emoji} - - ))} - + keyboardShouldPersistTaps="handled" + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={5} + getItemLayout={(_data, index) => ({ + length: EMOJI_ROW_HEIGHT, + offset: EMOJI_ROW_HEIGHT * index, + index, + })} + /> ); }; @@ -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, diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 0cde7ee..9669476 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -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]); diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index f7fbe27..dbb66af 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -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 = () => ( { )} - {/* Text Input */} + {/* Text Input —— 小红书风格:无边框、自动扩展 */} @@ -1492,7 +1562,7 @@ export const PostDetailScreen: React.FC = () => { {}} + onPress={handleAtMention} > { {}} + onPress={handleToggleEmojiPanel} > @@ -1526,6 +1596,40 @@ export const PostDetailScreen: React.FC = () => { 发送 + + {/* Emoji Panel —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿 */} + {showEmojiPanel && ( + + item.id} + renderItem={({ item }) => ( + + {item.emojis.map((emoji, index) => ( + handleInsertEmoji(emoji)} + activeOpacity={0.7} + > + {emoji} + + ))} + + )} + showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={5} + getItemLayout={(_data, index) => ({ + length: EMOJI_ROW_HEIGHT, + offset: EMOJI_ROW_HEIGHT * index, + index, + })} + /> + + )} )} @@ -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)', diff --git a/src/stores/auth/authStore.ts b/src/stores/auth/authStore.ts index 00b9b0e..70f4041 100644 --- a/src/stores/auth/authStore.ts +++ b/src/stores/auth/authStore.ts @@ -265,7 +265,7 @@ export const useAuthStore = create((set) => ({ // 7. 清除 sessionStore 的 userId useSessionStore.getState().setUserId(null); // 8. 清除 userManager 的内存缓存 - const { userManager } = await import('../user'); + const { userManager } = await import('../user/UserManager'); userManager.invalidateUsers(); } catch (error) { console.error('[AuthStore] 登出失败:', error); diff --git a/src/stores/call/callStore.ts b/src/stores/call/callStore.ts index 65aeab5..f09834e 100644 --- a/src/stores/call/callStore.ts +++ b/src/stores/call/callStore.ts @@ -10,7 +10,7 @@ import { import { webrtcManager, ICEServer } from '@/services/webrtc'; import { getCurrentUserId } from '../auth/sessionStore'; import { useUserStore } from '../userStore'; -import { userManager } from '../user'; +import { userManager } from '../user/UserManager'; export type CallStatus = | 'idle' // 空闲状态 diff --git a/src/stores/user/hooks.ts b/src/stores/user/hooks.ts index 4065ea5..16a1658 100644 --- a/src/stores/user/hooks.ts +++ b/src/stores/user/hooks.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useUserManagerStore } from './userStore'; import { userManager } from './UserManager'; import type { UserDTO } from '../../types/dto'; -import { useAuthStore } from '../auth/authStore'; +import { useSessionStore } from '../auth/sessionStore'; export interface UseCurrentUserResult { user: UserDTO | null; @@ -16,8 +16,7 @@ export function useCurrentUser(): UseCurrentUserResult { const lastFetchedRef = useRef(null); useEffect(() => { - const authUser = useAuthStore.getState().currentUser; - const userId = authUser?.id; + const userId = useSessionStore.getState().userId; if (userId && lastFetchedRef.current !== userId) { lastFetchedRef.current = userId; useUserManagerStore.getState().setLoadingCurrentUser(true); @@ -28,8 +27,8 @@ export function useCurrentUser(): UseCurrentUserResult { }, []); useEffect(() => { - return useAuthStore.subscribe((state, prev) => { - if (!state.currentUser && prev.currentUser) { + return useSessionStore.subscribe((state, prev) => { + if (!state.userId && prev.userId) { lastFetchedRef.current = null; } }); diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index 32d7402..adb4228 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -12,7 +12,7 @@ import { postService, notificationService, } from '../services'; -import { userManager } from './user'; +import { userManager } from './user/UserManager'; import { messageManager } from './message'; interface UserState {