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
This commit is contained in:
@@ -437,7 +437,6 @@ export const HomeScreen: React.FC = () => {
|
||||
refreshing: isRefreshing,
|
||||
hasMore,
|
||||
error,
|
||||
reset,
|
||||
} = useDifferentialPosts<Post>(
|
||||
[],
|
||||
{
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<SearchScreenProps> = ({ 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<SearchScreenProps> = ({ onBack, homeTab = 's
|
||||
if (hasSearched) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.suggestionsContainer, { paddingHorizontal: responsivePadding }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.suggestionsContainer,
|
||||
{ paddingHorizontal: responsivePadding },
|
||||
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
|
||||
]}
|
||||
>
|
||||
{/* 搜索历史 */}
|
||||
{history.length > 0 && (
|
||||
<View style={[styles.section, { marginTop: responsiveGap }]}>
|
||||
<View style={[styles.section, { marginTop: responsiveGap * 1.5 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text
|
||||
variant="body"
|
||||
@@ -522,7 +548,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
||||
>
|
||||
搜索历史
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleClearHistory}>
|
||||
<TouchableOpacity onPress={handleClearHistory} activeOpacity={0.7}>
|
||||
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -540,6 +566,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
||||
}
|
||||
]}
|
||||
onPress={() => handleHistoryPress(keyword)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
|
||||
<Text
|
||||
@@ -557,7 +584,18 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 空状态提示 */}
|
||||
{history.length === 0 && (
|
||||
<View style={styles.emptySuggestions}>
|
||||
<EmptyState
|
||||
title="开始搜索"
|
||||
description="输入关键词搜索帖子、用户或商品"
|
||||
icon="magnify"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -581,6 +619,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
||||
onSubmit={handleSearch}
|
||||
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
||||
autoFocus
|
||||
compact
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
@@ -598,20 +637,30 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab切换 */}
|
||||
{/* Tab切换 - 与主页风格一致的下划线样式 */}
|
||||
<View style={styles.tabWrapper}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(index) => {
|
||||
setActiveIndex(index);
|
||||
if (currentKeyword && hasSearched) {
|
||||
performSearch(currentKeyword);
|
||||
}
|
||||
}}
|
||||
variant="modern"
|
||||
icons={isMarket ? ['shopping-outline', 'account-outline'] : ['file-document-outline', 'account-outline']}
|
||||
/>
|
||||
<View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
|
||||
{TABS.map((tab, index) => {
|
||||
const isActive = activeIndex === index;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
|
||||
onPress={() => {
|
||||
setActiveIndex(index);
|
||||
if (currentKeyword && hasSearched) {
|
||||
performSearch(currentKeyword);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
|
||||
{tab}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 内容区域 */}
|
||||
@@ -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: {
|
||||
|
||||
@@ -159,7 +159,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
longPressMenuVisible,
|
||||
selectedMessage,
|
||||
selectedMessageId,
|
||||
setSelectedMessageId,
|
||||
menuPosition,
|
||||
isGroupChat,
|
||||
groupInfo,
|
||||
@@ -207,7 +206,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
||||
handleMentionAll,
|
||||
getSenderInfo,
|
||||
getTypingHint,
|
||||
getInputBottom,
|
||||
handleDismiss,
|
||||
navigateToInfo,
|
||||
navigateToChatSettings,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<number>(0);
|
||||
const [firstSeq, setFirstSeq] = useState<number>(0);
|
||||
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
|
||||
const [activePanel, setActivePanel] = useState<PanelType>('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<string>('');
|
||||
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<UserRole>('member');
|
||||
const [mentionQuery, setMentionQuery] = useState<string>('');
|
||||
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UserProfileScreenProps> = ({ mode, user
|
||||
currentUser,
|
||||
} = useUserProfile({ mode, userId, isDesktop, isTablet });
|
||||
|
||||
// 图片查看器状态(与 HomeScreen / PostDetailScreen 一致)
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
|
||||
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<UserProfileScreenProps> = ({ mode, user
|
||||
]}>
|
||||
<PostCard
|
||||
post={item}
|
||||
onAction={(action) => handlePostAction(item, action)}
|
||||
onAction={(action) => onPostAction(item, action)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [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<UserProfileScreenProps> = ({ mode, user
|
||||
</View>
|
||||
</View>
|
||||
</ResponsiveContainer>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={stableGalleryImages}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={closeImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -310,6 +353,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
||||
}
|
||||
drawDistance={250}
|
||||
/>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={stableGalleryImages}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={closeImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user