From a921aacefd1379d1f1ed77b2c4a6a141a57efb92 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 18 Jun 2026 02:29:54 +0800 Subject: [PATCH] refactor: streamline post sync, fix image gallery, and clean up chat screen - **Post sync optimization**: Clear store immediately when params change to prevent flashing old posts; replace instead of merge on refresh - **ImageGallery fix**: Use idempotent download option, migrate to Asset.create() API, fix Android file path requirement, ensure proper cleanup - **UserProfileScreen**: Add ImageGallery for post image viewing with consistent implementation - **SearchScreen**: Add entrance animation and empty state - **ChatScreen**: Remove unused state variables (lastSeq, firstSeq, isProgrammaticScrollRef) and dead imports --- app.config.js | 2 +- package-lock.json | 4 +- src/components/common/ImageGallery.tsx | 22 ++- src/hooks/useDifferentialPosts.ts | 22 ++- src/screens/home/HomeScreen.tsx | 6 +- src/screens/home/SearchScreen.tsx | 133 ++++++++++++++---- src/screens/message/ChatScreen.tsx | 2 - .../message/components/ChatScreen/types.ts | 38 +---- .../components/ChatScreen/useChatScreen.ts | 117 +++------------ src/screens/profile/UserProfileScreen.tsx | 58 +++++++- src/services/post/PostSyncService.ts | 34 +++-- 11 files changed, 248 insertions(+), 190 deletions(-) diff --git a/app.config.js b/app.config.js index 7abd266..a2ee39d 100644 --- a/app.config.js +++ b/app.config.js @@ -17,7 +17,7 @@ function getCommitCount() { // 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion // 始终大于历史最大值(之前的最后一个版本是 5547),避免被系统/Play Store // 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。 -const BUILD_NUMBER_OFFSET = 10000; +const BUILD_NUMBER_OFFSET = 100000; const commitCount = getCommitCount(); const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET); diff --git a/package-lock.json b/package-lock.json index 18b7ff5..c3f9606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "with_you", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "with_you", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@expo/ui": "~56.0.17", "@expo/vector-icons": "^15.1.1", diff --git a/src/components/common/ImageGallery.tsx b/src/components/common/ImageGallery.tsx index 3678587..4f606eb 100644 --- a/src/components/common/ImageGallery.tsx +++ b/src/components/common/ImageGallery.tsx @@ -213,6 +213,7 @@ export const ImageGallery: React.FC = ({ } setSaving(true); + let downloaded: InstanceType | null = null; try { const urlPath = currentImage.url.split('?')[0]; const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg'; @@ -222,13 +223,14 @@ export const ImageGallery: React.FC = ({ const fileName = `withyou_${Date.now()}.${fileExt}`; const destination = new File(Paths.cache, fileName); - // File.downloadFileAsync 是新版 expo-file-system/next 的静态方法 - const downloaded = await File.downloadFileAsync(currentImage.url, destination); + // File.downloadFileAsync 是新版 expo-file-system 的静态方法 + downloaded = await File.downloadFileAsync(currentImage.url, destination, { + idempotent: true, + }); - await MediaLibrary.saveToLibraryAsync(downloaded.uri); - - // 清理缓存文件 - downloaded.delete(); + // expo-media-library v17+ 已移除顶层 saveToLibraryAsync,改用 Asset.create() + // 在 Android 上 filePath 必须以 file:/// 开头 + await MediaLibrary.Asset.create(downloaded.uri); onSave?.(currentImage.url); showToast('success'); @@ -236,6 +238,14 @@ export const ImageGallery: React.FC = ({ console.error('[ImageGallery] 保存图片失败:', err); showToast('error'); } finally { + // 清理缓存文件(无论成功失败都清理,避免残留) + if (downloaded) { + try { + downloaded.delete(); + } catch (cleanupErr) { + console.warn('[ImageGallery] 清理缓存文件失败:', cleanupErr); + } + } setSaving(false); } }, [currentImage, saving, onSave, showToast]); diff --git a/src/hooks/useDifferentialPosts.ts b/src/hooks/useDifferentialPosts.ts index 1888891..1ea4903 100644 --- a/src/hooks/useDifferentialPosts.ts +++ b/src/hooks/useDifferentialPosts.ts @@ -236,6 +236,14 @@ export function useDifferentialPosts( const currentState = usePostListStore.getState().getPostsState(listKey); syncFromStore(currentState); + // listKey 切换时,如果目标列表在 store 中还没有有效数据(首次访问), + // 立即进入 loading 态,避免先渲染空列表再切到 loading 造成闪烁。 + // syncFromStore 会用 store 的 isLoading 覆盖,因此在其之后补设。 + // 如果已有数据(曾访问过),则保留旧数据,由后续 fetch 静默刷新。 + if (!currentState.posts || currentState.posts.length === 0) { + setLoading(true); + } + const unsubscribe = usePostListStore.subscribe(state => { const postsState = state.postsStateMap.get(listKey); if (postsState) syncFromStore(postsState); @@ -277,6 +285,18 @@ export function useDifferentialPosts( const reset = useCallback(() => { calculatorRef.current?.reset(); batcherRef.current?.clearPending(); + // 同步清空 store 中对应 listKey 的数据,避免 subscribe 回调 + // 在切换 listKey 时把上一个 key 的残留数据重新同步回本地造成闪烁 + usePostListStore.getState().updatePostsState(listKey, { + posts: [], + cursor: null, + currentPage: 1, + hasMore: true, + isLoading: false, + isRefreshing: false, + error: null, + lastParams: undefined, + }); setPosts([]); setLoading(false); setRefreshing(false); @@ -284,7 +304,7 @@ export function useDifferentialPosts( setHasMore(true); previousPostsRef.current = []; setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 }); - }, []); + }, [listKey]); const forceUpdate = useCallback((newPosts: T[]) => { setPosts(newPosts); diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 382bb0d..1136fda 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -437,7 +437,6 @@ export const HomeScreen: React.FC = () => { refreshing: isRefreshing, hasMore, error, - reset, } = useDifferentialPosts( [], { @@ -497,8 +496,9 @@ export const HomeScreen: React.FC = () => { useEffect(() => { let cancelled = false; const run = async () => { - reset(); - await new Promise(resolve => requestAnimationFrame(resolve)); + // 注意:不再在此处调用 reset()。 + // fetchPosts 内部会在参数变化时立即清空 store 中的旧数据并设置 loading, + // 这样可以避免本地清空后、请求发起前的间隙渲染到上一个分区的帖子造成闪烁。 if (!cancelled) { await refresh(); } diff --git a/src/screens/home/SearchScreen.tsx b/src/screens/home/SearchScreen.tsx index 81b41aa..4b14a05 100644 --- a/src/screens/home/SearchScreen.tsx +++ b/src/screens/home/SearchScreen.tsx @@ -12,6 +12,7 @@ import { TouchableOpacity, ScrollView, RefreshControl, + Animated, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; @@ -23,7 +24,7 @@ import { useUserStore } from '../../stores'; import { postService, authService } from '../../services'; import { tradeService } from '../../services/trade/tradeService'; import { postSyncService } from '@/services/post'; -import { PostCard, TabBar, SearchBar } from '../../components/business'; +import { PostCard, SearchBar } from '../../components/business'; import { TradeCard } from '../../components/business/TradeCard/TradeCard'; import type { PostCardAction } from '../../components/business/PostCard'; import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common'; @@ -94,6 +95,25 @@ export const SearchScreen: React.FC = ({ onBack, homeTab = 's // 保存当前搜索关键词,用于Tab切换时重新搜索 const [currentKeyword, setCurrentKeyword] = useState(''); + // 入场动画 + const fadeAnim = useRef(new Animated.Value(0)).current; + const slideAnim = useRef(new Animated.Value(20)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(slideAnim, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }), + ]).start(); + }, []); + const searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]); const { @@ -508,10 +528,16 @@ export const SearchScreen: React.FC = ({ onBack, homeTab = 's if (hasSearched) return null; return ( - + {/* 搜索历史 */} {history.length > 0 && ( - + = ({ onBack, homeTab = 's > 搜索历史 - + @@ -540,6 +566,7 @@ export const SearchScreen: React.FC = ({ onBack, homeTab = 's } ]} onPress={() => handleHistoryPress(keyword)} + activeOpacity={0.7} > = ({ onBack, homeTab = 's )} - + + {/* 空状态提示 */} + {history.length === 0 && ( + + + + )} + ); }; @@ -581,6 +619,7 @@ export const SearchScreen: React.FC = ({ onBack, homeTab = 's onSubmit={handleSearch} placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"} autoFocus + compact /> = ({ onBack, homeTab = 's - {/* Tab切换 */} + {/* Tab切换 - 与主页风格一致的下划线样式 */} - { - setActiveIndex(index); - if (currentKeyword && hasSearched) { - performSearch(currentKeyword); - } - }} - variant="modern" - icons={isMarket ? ['shopping-outline', 'account-outline'] : ['file-document-outline', 'account-outline']} - /> + + {TABS.map((tab, index) => { + const isActive = activeIndex === index; + return ( + { + setActiveIndex(index); + if (currentKeyword && hasSearched) { + performSearch(currentKeyword); + } + }} + > + + {tab} + + + ); + })} + {/* 内容区域 */} @@ -629,15 +678,12 @@ function createSearchScreenStyles(colors: AppColors) { searchHeader: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: `${colors.divider}70`, + backgroundColor: colors.background.default, }, searchShell: { flex: 1, }, cancelButton: { - backgroundColor: `${colors.primary.main}12`, borderRadius: borderRadius.full, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, @@ -647,13 +693,43 @@ function createSearchScreenStyles(colors: AppColors) { fontWeight: '600', }, tabWrapper: { - backgroundColor: colors.background.paper, - borderBottomWidth: 1, - borderBottomColor: `${colors.divider}50`, + backgroundColor: colors.background.default, + paddingVertical: spacing.xs, + }, + homeTabSwitcher: { + flexDirection: 'row', + alignItems: 'center', + gap: 20, + }, + homeTabItem: { + paddingVertical: spacing.sm, + alignItems: 'center', + justifyContent: 'center', + borderBottomWidth: 2, + borderBottomColor: 'transparent', + }, + homeTabItemActive: { + borderBottomColor: colors.text.primary, + }, + homeTabText: { + fontSize: 18, + fontWeight: '400', + color: colors.text.hint, + }, + homeTabTextActive: { + fontSize: 18, + fontWeight: '700', + color: colors.text.primary, }, suggestionsContainer: { flex: 1, }, + emptySuggestions: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + marginTop: -40, + }, section: { marginTop: spacing.md, }, @@ -674,13 +750,14 @@ function createSearchScreenStyles(colors: AppColors) { tag: { flexDirection: 'row', alignItems: 'center', - backgroundColor: `${colors.primary.main}10`, + backgroundColor: colors.background.paper, borderRadius: borderRadius.full, - borderWidth: 0, + borderWidth: 1, + borderColor: colors.divider, }, tagText: { marginLeft: spacing.xs, - color: colors.primary.main, + color: colors.text.secondary, fontWeight: '500', }, userCard: { diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index ac8360a..8bcfeb2 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -159,7 +159,6 @@ export const ChatScreen: React.FC = (props) => { longPressMenuVisible, selectedMessage, selectedMessageId, - setSelectedMessageId, menuPosition, isGroupChat, groupInfo, @@ -207,7 +206,6 @@ export const ChatScreen: React.FC = (props) => { handleMentionAll, getSenderInfo, getTypingHint, - getInputBottom, handleDismiss, navigateToInfo, navigateToChatSettings, diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 403570d..8095bbe 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -2,7 +2,7 @@ * ChatScreen 类型定义 */ -import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto'; +import { MessageResponse, UserDTO, GroupMemberResponse } from '../../../../types/dto'; // 面板类型 export type PanelType = 'none' | 'emoji' | 'more' | 'mention'; @@ -215,39 +215,3 @@ export interface SwipeableMessageBubbleProps { onReply: () => void; enabled?: boolean; } - -// ChatScreen 状态接口 -export interface ChatScreenState { - // 基础状态 - messages: GroupMessage[]; - conversationId: string | null; - inputText: string; - otherUser: UserDTO | null; - currentUser: UserDTO | null; - keyboardHeight: number; - loading: boolean; - sending: boolean; - currentUserId: string; - lastSeq: number; - otherUserLastReadSeq: number; - activePanel: PanelType; - sendingImage: boolean; - - // 回复消息状态 - replyingTo: GroupMessage | null; - - // 长按菜单状态 - longPressMenuVisible: boolean; - selectedMessage: GroupMessage | null; - - // 群聊相关状态 - groupInfo: GroupResponse | null; - groupMembers: GroupMemberResponse[]; - typingUsers: string[]; - currentUserRole: UserRole; - mentionQuery: string; - selectedMentions: string[]; - mentionAll: boolean; - isMuted: boolean; - muteAll: boolean; -} diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 999d0ea..42dccc7 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -183,8 +183,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { const [keyboardHeight, setKeyboardHeight] = useState(0); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); - const [lastSeq, setLastSeq] = useState(0); - const [firstSeq, setFirstSeq] = useState(0); const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState(0); const [activePanel, setActivePanel] = useState('none'); const [sendingImage, setSendingImage] = useState(false); @@ -198,11 +196,9 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点) const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 }); const hasInitialAnchorDoneRef = useRef(false); - const prevMessageCountRef = useRef(0); const prevLatestSeqRef = useRef(0); const prevMarkedReadSeqRef = useRef(0); const enterMarkedKeyRef = useRef(''); - const isProgrammaticScrollRef = useRef(false); const suppressAutoFollowRef = useRef(false); const isBrowsingHistoryRef = useRef(false); const hasShownMessageListRef = useRef(false); @@ -231,6 +227,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null }); } }, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]); + const [currentUserRole, setCurrentUserRole] = useState('member'); const [mentionQuery, setMentionQuery] = useState(''); const [selectedMentions, setSelectedMentions] = useState([]); @@ -371,10 +368,9 @@ export const useChatScreen = (props?: ChatScreenProps) => { }; }, [isGroupChat, otherUserId]); - // 进入新会话时重置滚动状态 + // 进入新会话时重置滚动/草稿状态 useEffect(() => { hasInitialAnchorDoneRef.current = false; - prevMessageCountRef.current = 0; prevLatestSeqRef.current = 0; prevMarkedReadSeqRef.current = 0; enterMarkedKeyRef.current = ''; @@ -383,9 +379,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { hasShownMessageListRef.current = false; historyLoadingLockUntilRef.current = 0; scrollToSeqRef.current = routeScrollToSeq ?? null; - }, [conversationId]); - - useEffect(() => { setPendingAttachments([]); }, [conversationId]); @@ -405,14 +398,10 @@ export const useChatScreen = (props?: ChatScreenProps) => { const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => { if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return; // inverted 列表下,最新消息端对应 offset=0 - isProgrammaticScrollRef.current = true; flatListRef.current?.scrollToOffset({ offset: 0, animated, }); - setTimeout(() => { - isProgrammaticScrollRef.current = false; - }, animated ? 220 : 32); }, [flatListRef, isHistoryLoadingLocked]); const isNearBottom = useCallback(() => { @@ -422,18 +411,23 @@ export const useChatScreen = (props?: ChatScreenProps) => { }, [scrollPositionRef]); // 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过) + // 注意:必须在消息首次出现时立刻把 hasInitialAnchorDoneRef 置为 true, + // 不能再依赖 viewportHeight 等通过 ref 同步的值——否则后续首次 loadMoreHistory + // 让 messages.length 变化时此 effect 会被再度命中,并以 force=true 调用 + // scrollToLatest 强行把列表拉回最底端,造成"上滑首次加载更多就回底"的问题。 useEffect(() => { if ( loading || loadingMore || messages.length === 0 || - hasInitialAnchorDoneRef.current || - scrollPositionRef.current.viewportHeight <= 0 + hasInitialAnchorDoneRef.current ) { return; } hasInitialAnchorDoneRef.current = true; if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理 + // inverted FlashList 天然从 offset=0(最新端)开始; + // 这里仅做一次保险锚定,即使布局尚未就绪也不会有副作用。 const timer = setTimeout(() => { scrollToLatest(false, true, 'initial-anchor'); }, 0); @@ -444,10 +438,8 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随; // 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。 useEffect(() => { - const currentCount = messages.length; - const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0; + const latestSeq = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0; const prevLatestSeq = prevLatestSeqRef.current; - prevMessageCountRef.current = currentCount; prevLatestSeqRef.current = latestSeq; if (loading || loadingMore) return; @@ -577,33 +569,20 @@ export const useChatScreen = (props?: ChatScreenProps) => { }; }, [routeUserId, conversationId, currentUserId, isGroupChat]); - // 加载更多历史消息(inverted 下保持阅读锚点) + // 加载更多历史消息(inverted + maintainVisibleContentPosition 由列表原生保持位置) const loadMoreHistory = useCallback(async () => { if (!conversationId || !hasMoreHistory || loadingMore) { return; } - // 历史加载期间禁止“新消息自动跟随到底” + // 历史加载期间禁止"新消息自动跟随到底" suppressAutoFollowRef.current = true; isBrowsingHistoryRef.current = true; historyLoadingLockUntilRef.current = Date.now() + 3000; - - // 保存加载前的滚动位置和内容高度 - const scrollYBefore = scrollPositionRef.current.scrollY; - const contentHeightBefore = scrollPositionRef.current.contentHeight; setLoadingMore(true); try { await loadMoreMessages(); - - // 更新 firstSeq - if (messages.length > 0) { - const minSeq = Math.min(...messages.map(m => m.seq)); - setFirstSeq(minSeq); - } - - // inverted + maintainVisibleContentPosition 下由列表原生保持位置 - // 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部 } catch (error) { console.error('加载历史消息失败:', error); } finally { @@ -611,7 +590,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底 historyLoadingLockUntilRef.current = Date.now() + 800; } - }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]); + }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages]); // 从搜索结果跳转:滚动到目标 seq useEffect(() => { @@ -660,13 +639,9 @@ export const useChatScreen = (props?: ChatScreenProps) => { isBrowsingHistoryRef.current = false; // FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部, // 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底,再补偿回 0。 - isProgrammaticScrollRef.current = true; flatListRef.current?.scrollToOffset({ offset: -99999, animated: false }); requestAnimationFrame(() => { flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); - setTimeout(() => { - isProgrammaticScrollRef.current = false; - }, 220); }); }, [flatListRef]); @@ -700,6 +675,9 @@ export const useChatScreen = (props?: ChatScreenProps) => { // 自动标记已读(QQ/Telegram 风格): // 仅当出现更大的 latest seq,且用户在最新端附近时才上报。 // 历史加载/浏览历史不会触发 read。 + // 注意:不能复用 prevLatestSeqRef 做判定——它被新消息跟随 effect 无条件推进, + // 会导致这里的去重判断永远成立,标记已读逻辑事实上不会执行。 + // 这里只依赖专属的 prevMarkedReadSeqRef 做去重。 useEffect(() => { if (!conversationId || messages.length === 0) return; if (loading || loadingMore) return; @@ -709,10 +687,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { const latestSeq = Math.max(...messages.map(m => m.seq), 0); if (latestSeq <= 0) return; - // 没有新增最新消息,不重复上报 - if (latestSeq <= prevLatestSeqRef.current) return; - prevLatestSeqRef.current = latestSeq; - // 避免对同一 seq 重复标记 if (latestSeq <= prevMarkedReadSeqRef.current) return; prevMarkedReadSeqRef.current = latestSeq; @@ -722,18 +696,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { }); }, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]); - // 使用 ref 存储 groupMembers - const groupMembersRef = useRef(groupMembers); - useEffect(() => { - groupMembersRef.current = groupMembers; - }, [groupMembers]); - - // 使用 ref 存储 currentUserId - const currentUserIdRef = useRef(currentUserId); - useEffect(() => { - currentUserIdRef.current = currentUserId; - }, [currentUserId]); - // 监听键盘事件 useEffect(() => { const keyboardWillShow = (e: KeyboardEvent) => { @@ -1476,20 +1438,15 @@ export const useChatScreen = (props?: ChatScreenProps) => { setActivePanel('none'); }, []); - // 撤回消息 - 现在通过 MessageManager 处理 + // 撤回消息 - 现在通过 MessageManager 处理(撤回事件由 MessageManager 同步状态) const handleRecall = useCallback(async (messageId: string) => { try { - if (isGroupChat && effectiveGroupId) { - await messageService.recallMessage(messageId); - } else { - await messageService.recallMessage(messageId); - } - // 不需要手动更新状态,MessageManager 会处理撤回事件 + await messageService.recallMessage(messageId); } catch (error) { console.error('撤回消息失败:', error); Alert.alert('撤回失败', '无法撤回消息'); } - }, [isGroupChat, effectiveGroupId, conversationId]); + }, []); // 长按消息显示操作菜单 const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => { @@ -1528,8 +1485,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { if (!conversationId) return; try { - setLastSeq(0); - setFirstSeq(0); setHasMoreHistory(true); await messageRepository.clearConversation(conversationId); // 刷新消息列表 @@ -1538,9 +1493,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { console.error('清空会话失败:', error); Alert.alert('清空失败', '无法清空聊天记录'); } - }, [conversationId, refreshMessages]); - - // 回复消息 + }, [conversationId, refreshMessages]); // 回复消息 const handleReplyMessage = useCallback((message: GroupMessage) => { setReplyingTo(message); textInputRef.current?.focus(); @@ -1593,26 +1546,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { } }, [groupTypingUsers, groupMembers]); - // 计算底部面板高度 - const getPanelHeight = useCallback(() => { - if (activePanel === 'none') return 0; - if (activePanel === 'mention') return 250; - return 350; - }, [activePanel]); - - // 计算输入框的bottom值 - const getInputBottom = useCallback(() => { - if (keyboardHeight > 0) return keyboardHeight; - if (activePanel !== 'none') return getPanelHeight(); - return 0; - }, [keyboardHeight, activePanel, getPanelHeight]); - - // 计算消息列表的底部padding - const getListPaddingBottom = useCallback(() => { - if (activePanel !== 'none' && keyboardHeight === 0) return getPanelHeight(); - return 0; - }, [keyboardHeight, activePanel, getPanelHeight]); - // 关闭所有面板 const handleDismiss = useCallback(() => { Keyboard.dismiss(); @@ -1656,7 +1589,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { currentUserId, keyboardHeight, loading, - sending, activePanel, sendingImage, uploadingAttachments, @@ -1666,16 +1598,12 @@ export const useChatScreen = (props?: ChatScreenProps) => { longPressMenuVisible, selectedMessage, selectedMessageId, - setSelectedMessageId, menuPosition, isGroupChat, groupInfo, groupMembers, - // 【改造】使用 MessageManager 的输入状态 - typingUsers: groupTypingUsers, currentUserRole, mentionQuery, - selectedMentions, isMuted, muteAll, followRestrictionHint, @@ -1697,9 +1625,7 @@ export const useChatScreen = (props?: ChatScreenProps) => { shouldShowTime, handleInputChange, handleSend, - handlePickImage, removePendingAttachment, - handleTakePhoto, handleMoreAction, handleInsertEmoji, handleSendSticker, @@ -1718,9 +1644,6 @@ export const useChatScreen = (props?: ChatScreenProps) => { handleMentionAll, getSenderInfo, getTypingHint, - getPanelHeight, - getInputBottom, - getListPaddingBottom, handleDismiss, navigateToInfo, navigateToChatSettings, diff --git a/src/screens/profile/UserProfileScreen.tsx b/src/screens/profile/UserProfileScreen.tsx index b6446c7..ec560f9 100644 --- a/src/screens/profile/UserProfileScreen.tsx +++ b/src/screens/profile/UserProfileScreen.tsx @@ -16,7 +16,8 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useAppColors } from '../../theme'; import { Post } from '../../types'; import { PostCard, TabBar, UserProfileHeader } from '../../components/business'; -import { Loading, EmptyState, ResponsiveContainer } from '../../components/common'; +import type { PostCardAction } from '../../components/business/PostCard'; +import { Loading, EmptyState, ResponsiveContainer, ImageGallery, ImageGridItem } from '../../components/common'; import { useResponsive } from '../../hooks'; import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile'; @@ -116,6 +117,39 @@ export const UserProfileScreen: React.FC = ({ mode, user currentUser, } = useUserProfile({ mode, userId, isDesktop, isTablet }); + // 图片查看器状态(与 HomeScreen / PostDetailScreen 一致) + const [showImageViewer, setShowImageViewer] = useState(false); + const [postImages, setPostImages] = useState([]); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); + + const closeImageViewer = useCallback(() => setShowImageViewer(false), []); + const stableGalleryImages = useMemo( + () => + postImages.map((img, i) => ({ + id: img.id || img.url || `img-${i}`, + url: img.url || img.uri || '', + })), + [postImages] + ); + + // 包装 handlePostAction:在 hook 通用逻辑之上处理 imagePress + const onPostAction = useCallback( + (post: Post, action: PostCardAction) => { + if (action.type === 'imagePress') { + const images = action.payload?.images; + const imageIndex = action.payload?.imageIndex; + if (images && imageIndex !== undefined) { + setPostImages(images); + setSelectedImageIndex(imageIndex); + setShowImageViewer(true); + } + return; + } + handlePostAction(post, action); + }, + [handlePostAction] + ); + // 当前显示的帖子列表 const currentPosts = activeTab === 0 ? posts : favorites; @@ -130,12 +164,12 @@ export const UserProfileScreen: React.FC = ({ mode, user ]}> handlePostAction(item, action)} + onAction={(action) => onPostAction(item, action)} isPostAuthor={isPostAuthor} /> ); - }, [currentUser?.id, handlePostAction, currentPosts.length]); + }, [currentUser?.id, onPostAction, currentPosts.length]); const postKeyExtractor = useCallback((item: Post) => item.id, []); @@ -269,6 +303,15 @@ export const UserProfileScreen: React.FC = ({ mode, user + + {/* 图片查看器 */} + ); } @@ -310,6 +353,15 @@ export const UserProfileScreen: React.FC = ({ mode, user } drawDistance={250} /> + + {/* 图片查看器 */} + ); }; diff --git a/src/services/post/PostSyncService.ts b/src/services/post/PostSyncService.ts index 3d6dd1c..b6d04b4 100644 --- a/src/services/post/PostSyncService.ts +++ b/src/services/post/PostSyncService.ts @@ -62,23 +62,36 @@ class PostSyncService { } async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise { - const store = usePostListStore.getState(); - store.updatePostsState(key, { isLoading: true, error: null }); + const queryParams: GetPostsParams = { + pageSize: params?.pageSize || DEFAULT_PAGE_SIZE, + ...params, + cursor: params?.cursor !== undefined ? params.cursor : '', + }; + + // 判断参数是否变化:参数变化(切换分区/排序/筛选)时直接清空旧数据, + // 避免渲染期间残留上一个分区的帖子造成闪烁 + const prevState = usePostListStore.getState().getPostsState(key); + const paramsChanged = JSON.stringify(prevState.lastParams) !== JSON.stringify(queryParams); + + // 参数变化时立即写入空数据 + loading 状态,确保 UI 第一时间进入加载态 + // 而不是先渲染上一个分区的旧帖子 + usePostListStore.getState().updatePostsState(key, { + isLoading: true, + error: null, + ...(paramsChanged ? { posts: [], cursor: null, currentPage: 1, hasMore: true } : {}), + }); try { - const queryParams: GetPostsParams = { - pageSize: params?.pageSize || DEFAULT_PAGE_SIZE, - ...params, - cursor: params?.cursor !== undefined ? params.cursor : '', - }; - const result = await postRepository.getPosts(queryParams); const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; const currentState = usePostListStore.getState().getPostsState(key); const incomingPosts = Array.isArray(result.posts) ? result.posts : []; const mergeStart = Date.now(); - const mergedPosts = mergeRefreshWindow(currentState.posts, incomingPosts); + // 参数变化时直接替换;同参数刷新则合并以保留滚动位置上的帖子 + const mergedPosts = paramsChanged + ? incomingPosts + : mergeRefreshWindow(currentState.posts, incomingPosts); const mergeCost = Date.now() - mergeStart; if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) { console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length }); @@ -116,7 +129,8 @@ class PostSyncService { const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; const incomingPosts = Array.isArray(result.posts) ? result.posts : []; const mergeStart = Date.now(); - const mergedPosts = mergeRefreshWindow(state.posts, incomingPosts); + // refreshPosts 是刷新操作,应直接替换当前列表,避免旧数据残留 + const mergedPosts = incomingPosts; const mergeCost = Date.now() - mergeStart; if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) { console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length });