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:
@@ -17,7 +17,7 @@ function getCommitCount() {
|
|||||||
// 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion
|
// 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion
|
||||||
// 始终大于历史最大值(之前的最后一个版本是 5547),避免被系统/Play Store
|
// 始终大于历史最大值(之前的最后一个版本是 5547),避免被系统/Play Store
|
||||||
// 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。
|
// 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。
|
||||||
const BUILD_NUMBER_OFFSET = 10000;
|
const BUILD_NUMBER_OFFSET = 100000;
|
||||||
|
|
||||||
const commitCount = getCommitCount();
|
const commitCount = getCommitCount();
|
||||||
const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET);
|
const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET);
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "with_you",
|
"name": "with_you",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "with_you",
|
"name": "with_you",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ui": "~56.0.17",
|
"@expo/ui": "~56.0.17",
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
let downloaded: InstanceType<typeof File> | null = null;
|
||||||
try {
|
try {
|
||||||
const urlPath = currentImage.url.split('?')[0];
|
const urlPath = currentImage.url.split('?')[0];
|
||||||
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
|
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
|
||||||
@@ -222,13 +223,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
const fileName = `withyou_${Date.now()}.${fileExt}`;
|
const fileName = `withyou_${Date.now()}.${fileExt}`;
|
||||||
const destination = new File(Paths.cache, fileName);
|
const destination = new File(Paths.cache, fileName);
|
||||||
|
|
||||||
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
|
// File.downloadFileAsync 是新版 expo-file-system 的静态方法
|
||||||
const downloaded = await File.downloadFileAsync(currentImage.url, destination);
|
downloaded = await File.downloadFileAsync(currentImage.url, destination, {
|
||||||
|
idempotent: true,
|
||||||
|
});
|
||||||
|
|
||||||
await MediaLibrary.saveToLibraryAsync(downloaded.uri);
|
// expo-media-library v17+ 已移除顶层 saveToLibraryAsync,改用 Asset.create()
|
||||||
|
// 在 Android 上 filePath 必须以 file:/// 开头
|
||||||
// 清理缓存文件
|
await MediaLibrary.Asset.create(downloaded.uri);
|
||||||
downloaded.delete();
|
|
||||||
|
|
||||||
onSave?.(currentImage.url);
|
onSave?.(currentImage.url);
|
||||||
showToast('success');
|
showToast('success');
|
||||||
@@ -236,6 +238,14 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
console.error('[ImageGallery] 保存图片失败:', err);
|
console.error('[ImageGallery] 保存图片失败:', err);
|
||||||
showToast('error');
|
showToast('error');
|
||||||
} finally {
|
} finally {
|
||||||
|
// 清理缓存文件(无论成功失败都清理,避免残留)
|
||||||
|
if (downloaded) {
|
||||||
|
try {
|
||||||
|
downloaded.delete();
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.warn('[ImageGallery] 清理缓存文件失败:', cleanupErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [currentImage, saving, onSave, showToast]);
|
}, [currentImage, saving, onSave, showToast]);
|
||||||
|
|||||||
@@ -236,6 +236,14 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
|||||||
const currentState = usePostListStore.getState().getPostsState(listKey);
|
const currentState = usePostListStore.getState().getPostsState(listKey);
|
||||||
syncFromStore(currentState);
|
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 unsubscribe = usePostListStore.subscribe(state => {
|
||||||
const postsState = state.postsStateMap.get(listKey);
|
const postsState = state.postsStateMap.get(listKey);
|
||||||
if (postsState) syncFromStore(postsState);
|
if (postsState) syncFromStore(postsState);
|
||||||
@@ -277,6 +285,18 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
|||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
calculatorRef.current?.reset();
|
calculatorRef.current?.reset();
|
||||||
batcherRef.current?.clearPending();
|
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([]);
|
setPosts([]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@@ -284,7 +304,7 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
|||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
previousPostsRef.current = [];
|
previousPostsRef.current = [];
|
||||||
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
|
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
|
||||||
}, []);
|
}, [listKey]);
|
||||||
|
|
||||||
const forceUpdate = useCallback((newPosts: T[]) => {
|
const forceUpdate = useCallback((newPosts: T[]) => {
|
||||||
setPosts(newPosts);
|
setPosts(newPosts);
|
||||||
|
|||||||
@@ -437,7 +437,6 @@ export const HomeScreen: React.FC = () => {
|
|||||||
refreshing: isRefreshing,
|
refreshing: isRefreshing,
|
||||||
hasMore,
|
hasMore,
|
||||||
error,
|
error,
|
||||||
reset,
|
|
||||||
} = useDifferentialPosts<Post>(
|
} = useDifferentialPosts<Post>(
|
||||||
[],
|
[],
|
||||||
{
|
{
|
||||||
@@ -497,8 +496,9 @@ export const HomeScreen: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
reset();
|
// 注意:不再在此处调用 reset()。
|
||||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
// fetchPosts 内部会在参数变化时立即清空 store 中的旧数据并设置 loading,
|
||||||
|
// 这样可以避免本地清空后、请求发起前的间隙渲染到上一个分区的帖子造成闪烁。
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
RefreshControl,
|
RefreshControl,
|
||||||
|
Animated,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
@@ -23,7 +24,7 @@ import { useUserStore } from '../../stores';
|
|||||||
import { postService, authService } from '../../services';
|
import { postService, authService } from '../../services';
|
||||||
import { tradeService } from '../../services/trade/tradeService';
|
import { tradeService } from '../../services/trade/tradeService';
|
||||||
import { postSyncService } from '@/services/post';
|
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 { TradeCard } from '../../components/business/TradeCard/TradeCard';
|
||||||
import type { PostCardAction } from '../../components/business/PostCard';
|
import type { PostCardAction } from '../../components/business/PostCard';
|
||||||
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
||||||
@@ -94,6 +95,25 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
||||||
const [currentKeyword, setCurrentKeyword] = useState('');
|
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 searchExtraParams = useMemo(() => ({ query: currentKeyword }), [currentKeyword]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -508,10 +528,16 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
if (hasSearched) return null;
|
if (hasSearched) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.suggestionsContainer, { paddingHorizontal: responsivePadding }]}>
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.suggestionsContainer,
|
||||||
|
{ paddingHorizontal: responsivePadding },
|
||||||
|
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] }
|
||||||
|
]}
|
||||||
|
>
|
||||||
{/* 搜索历史 */}
|
{/* 搜索历史 */}
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<View style={[styles.section, { marginTop: responsiveGap }]}>
|
<View style={[styles.section, { marginTop: responsiveGap * 1.5 }]}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
@@ -522,7 +548,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
>
|
>
|
||||||
搜索历史
|
搜索历史
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity onPress={handleClearHistory}>
|
<TouchableOpacity onPress={handleClearHistory} activeOpacity={0.7}>
|
||||||
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
|
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -540,6 +566,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
onPress={() => handleHistoryPress(keyword)}
|
onPress={() => handleHistoryPress(keyword)}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
|
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
|
||||||
<Text
|
<Text
|
||||||
@@ -557,7 +584,18 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
</View>
|
</View>
|
||||||
</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}
|
onSubmit={handleSearch}
|
||||||
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -598,20 +637,30 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Tab切换 */}
|
{/* Tab切换 - 与主页风格一致的下划线样式 */}
|
||||||
<View style={styles.tabWrapper}>
|
<View style={styles.tabWrapper}>
|
||||||
<TabBar
|
<View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
|
||||||
tabs={TABS}
|
{TABS.map((tab, index) => {
|
||||||
activeIndex={activeIndex}
|
const isActive = activeIndex === index;
|
||||||
onTabChange={(index) => {
|
return (
|
||||||
setActiveIndex(index);
|
<TouchableOpacity
|
||||||
if (currentKeyword && hasSearched) {
|
key={tab}
|
||||||
performSearch(currentKeyword);
|
activeOpacity={0.7}
|
||||||
}
|
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
|
||||||
}}
|
onPress={() => {
|
||||||
variant="modern"
|
setActiveIndex(index);
|
||||||
icons={isMarket ? ['shopping-outline', 'account-outline'] : ['file-document-outline', 'account-outline']}
|
if (currentKeyword && hasSearched) {
|
||||||
/>
|
performSearch(currentKeyword);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
|
||||||
|
{tab}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 内容区域 */}
|
{/* 内容区域 */}
|
||||||
@@ -629,15 +678,12 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
searchHeader: {
|
searchHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.default,
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: `${colors.divider}70`,
|
|
||||||
},
|
},
|
||||||
searchShell: {
|
searchShell: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
cancelButton: {
|
cancelButton: {
|
||||||
backgroundColor: `${colors.primary.main}12`,
|
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.sm,
|
||||||
@@ -647,13 +693,43 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
tabWrapper: {
|
tabWrapper: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.default,
|
||||||
borderBottomWidth: 1,
|
paddingVertical: spacing.xs,
|
||||||
borderBottomColor: `${colors.divider}50`,
|
},
|
||||||
|
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: {
|
suggestionsContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
emptySuggestions: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: -40,
|
||||||
|
},
|
||||||
section: {
|
section: {
|
||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
@@ -674,13 +750,14 @@ function createSearchScreenStyles(colors: AppColors) {
|
|||||||
tag: {
|
tag: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: `${colors.primary.main}10`,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
borderWidth: 0,
|
borderWidth: 1,
|
||||||
|
borderColor: colors.divider,
|
||||||
},
|
},
|
||||||
tagText: {
|
tagText: {
|
||||||
marginLeft: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
color: colors.primary.main,
|
color: colors.text.secondary,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
userCard: {
|
userCard: {
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
longPressMenuVisible,
|
longPressMenuVisible,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
setSelectedMessageId,
|
|
||||||
menuPosition,
|
menuPosition,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
@@ -207,7 +206,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
|
|||||||
handleMentionAll,
|
handleMentionAll,
|
||||||
getSenderInfo,
|
getSenderInfo,
|
||||||
getTypingHint,
|
getTypingHint,
|
||||||
getInputBottom,
|
|
||||||
handleDismiss,
|
handleDismiss,
|
||||||
navigateToInfo,
|
navigateToInfo,
|
||||||
navigateToChatSettings,
|
navigateToChatSettings,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* ChatScreen 类型定义
|
* ChatScreen 类型定义
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto';
|
import { MessageResponse, UserDTO, GroupMemberResponse } from '../../../../types/dto';
|
||||||
|
|
||||||
// 面板类型
|
// 面板类型
|
||||||
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
|
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
|
||||||
@@ -215,39 +215,3 @@ export interface SwipeableMessageBubbleProps {
|
|||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
enabled?: boolean;
|
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 [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [lastSeq, setLastSeq] = useState<number>(0);
|
|
||||||
const [firstSeq, setFirstSeq] = useState<number>(0);
|
|
||||||
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
|
const [otherUserLastReadSeq, setOtherUserLastReadSeq] = useState<number>(0);
|
||||||
const [activePanel, setActivePanel] = useState<PanelType>('none');
|
const [activePanel, setActivePanel] = useState<PanelType>('none');
|
||||||
const [sendingImage, setSendingImage] = useState(false);
|
const [sendingImage, setSendingImage] = useState(false);
|
||||||
@@ -198,11 +196,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点)
|
// 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点)
|
||||||
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
|
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
|
||||||
const hasInitialAnchorDoneRef = useRef(false);
|
const hasInitialAnchorDoneRef = useRef(false);
|
||||||
const prevMessageCountRef = useRef(0);
|
|
||||||
const prevLatestSeqRef = useRef(0);
|
const prevLatestSeqRef = useRef(0);
|
||||||
const prevMarkedReadSeqRef = useRef(0);
|
const prevMarkedReadSeqRef = useRef(0);
|
||||||
const enterMarkedKeyRef = useRef<string>('');
|
const enterMarkedKeyRef = useRef<string>('');
|
||||||
const isProgrammaticScrollRef = useRef(false);
|
|
||||||
const suppressAutoFollowRef = useRef(false);
|
const suppressAutoFollowRef = useRef(false);
|
||||||
const isBrowsingHistoryRef = useRef(false);
|
const isBrowsingHistoryRef = useRef(false);
|
||||||
const hasShownMessageListRef = 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 });
|
setGroupInfo(prev => prev ? prev : { name: name || undefined, avatar: avatar || null });
|
||||||
}
|
}
|
||||||
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
|
}, [isGroupChat, effectiveGroupName, effectiveGroupAvatar]);
|
||||||
|
|
||||||
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
|
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('member');
|
||||||
const [mentionQuery, setMentionQuery] = useState<string>('');
|
const [mentionQuery, setMentionQuery] = useState<string>('');
|
||||||
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
const [selectedMentions, setSelectedMentions] = useState<string[]>([]);
|
||||||
@@ -371,10 +368,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
};
|
};
|
||||||
}, [isGroupChat, otherUserId]);
|
}, [isGroupChat, otherUserId]);
|
||||||
|
|
||||||
// 进入新会话时重置滚动状态
|
// 进入新会话时重置滚动/草稿状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasInitialAnchorDoneRef.current = false;
|
hasInitialAnchorDoneRef.current = false;
|
||||||
prevMessageCountRef.current = 0;
|
|
||||||
prevLatestSeqRef.current = 0;
|
prevLatestSeqRef.current = 0;
|
||||||
prevMarkedReadSeqRef.current = 0;
|
prevMarkedReadSeqRef.current = 0;
|
||||||
enterMarkedKeyRef.current = '';
|
enterMarkedKeyRef.current = '';
|
||||||
@@ -383,9 +379,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
hasShownMessageListRef.current = false;
|
hasShownMessageListRef.current = false;
|
||||||
historyLoadingLockUntilRef.current = 0;
|
historyLoadingLockUntilRef.current = 0;
|
||||||
scrollToSeqRef.current = routeScrollToSeq ?? null;
|
scrollToSeqRef.current = routeScrollToSeq ?? null;
|
||||||
}, [conversationId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPendingAttachments([]);
|
setPendingAttachments([]);
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
@@ -405,14 +398,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
||||||
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
|
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current || isHistoryLoadingLocked())) return;
|
||||||
// inverted 列表下,最新消息端对应 offset=0
|
// inverted 列表下,最新消息端对应 offset=0
|
||||||
isProgrammaticScrollRef.current = true;
|
|
||||||
flatListRef.current?.scrollToOffset({
|
flatListRef.current?.scrollToOffset({
|
||||||
offset: 0,
|
offset: 0,
|
||||||
animated,
|
animated,
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
|
||||||
isProgrammaticScrollRef.current = false;
|
|
||||||
}, animated ? 220 : 32);
|
|
||||||
}, [flatListRef, isHistoryLoadingLocked]);
|
}, [flatListRef, isHistoryLoadingLocked]);
|
||||||
|
|
||||||
const isNearBottom = useCallback(() => {
|
const isNearBottom = useCallback(() => {
|
||||||
@@ -422,18 +411,23 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
}, [scrollPositionRef]);
|
}, [scrollPositionRef]);
|
||||||
|
|
||||||
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
|
// 首屏加载完成后仅锚定一次到最新消息端(有 scrollToSeq 时跳过)
|
||||||
|
// 注意:必须在消息首次出现时立刻把 hasInitialAnchorDoneRef 置为 true,
|
||||||
|
// 不能再依赖 viewportHeight 等通过 ref 同步的值——否则后续首次 loadMoreHistory
|
||||||
|
// 让 messages.length 变化时此 effect 会被再度命中,并以 force=true 调用
|
||||||
|
// scrollToLatest 强行把列表拉回最底端,造成"上滑首次加载更多就回底"的问题。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
loading ||
|
loading ||
|
||||||
loadingMore ||
|
loadingMore ||
|
||||||
messages.length === 0 ||
|
messages.length === 0 ||
|
||||||
hasInitialAnchorDoneRef.current ||
|
hasInitialAnchorDoneRef.current
|
||||||
scrollPositionRef.current.viewportHeight <= 0
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasInitialAnchorDoneRef.current = true;
|
hasInitialAnchorDoneRef.current = true;
|
||||||
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
|
if (scrollToSeqRef.current != null) return; // 由下方 scrollToSeq effect 处理
|
||||||
|
// inverted FlashList 天然从 offset=0(最新端)开始;
|
||||||
|
// 这里仅做一次保险锚定,即使布局尚未就绪也不会有副作用。
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
scrollToLatest(false, true, 'initial-anchor');
|
scrollToLatest(false, true, 'initial-anchor');
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -444,10 +438,8 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
|
// 仅当"最新端消息 seq 增长"且用户在底部附近时才跟随;
|
||||||
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentCount = messages.length;
|
const latestSeq = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||||||
const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
|
||||||
const prevLatestSeq = prevLatestSeqRef.current;
|
const prevLatestSeq = prevLatestSeqRef.current;
|
||||||
prevMessageCountRef.current = currentCount;
|
|
||||||
prevLatestSeqRef.current = latestSeq;
|
prevLatestSeqRef.current = latestSeq;
|
||||||
|
|
||||||
if (loading || loadingMore) return;
|
if (loading || loadingMore) return;
|
||||||
@@ -577,33 +569,20 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
};
|
};
|
||||||
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
|
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
|
||||||
|
|
||||||
// 加载更多历史消息(inverted 下保持阅读锚点)
|
// 加载更多历史消息(inverted + maintainVisibleContentPosition 由列表原生保持位置)
|
||||||
const loadMoreHistory = useCallback(async () => {
|
const loadMoreHistory = useCallback(async () => {
|
||||||
if (!conversationId || !hasMoreHistory || loadingMore) {
|
if (!conversationId || !hasMoreHistory || loadingMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 历史加载期间禁止“新消息自动跟随到底”
|
// 历史加载期间禁止"新消息自动跟随到底"
|
||||||
suppressAutoFollowRef.current = true;
|
suppressAutoFollowRef.current = true;
|
||||||
isBrowsingHistoryRef.current = true;
|
isBrowsingHistoryRef.current = true;
|
||||||
historyLoadingLockUntilRef.current = Date.now() + 3000;
|
historyLoadingLockUntilRef.current = Date.now() + 3000;
|
||||||
|
|
||||||
// 保存加载前的滚动位置和内容高度
|
|
||||||
const scrollYBefore = scrollPositionRef.current.scrollY;
|
|
||||||
const contentHeightBefore = scrollPositionRef.current.contentHeight;
|
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadMoreMessages();
|
await loadMoreMessages();
|
||||||
|
|
||||||
// 更新 firstSeq
|
|
||||||
if (messages.length > 0) {
|
|
||||||
const minSeq = Math.min(...messages.map(m => m.seq));
|
|
||||||
setFirstSeq(minSeq);
|
|
||||||
}
|
|
||||||
|
|
||||||
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
|
|
||||||
// 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载历史消息失败:', error);
|
console.error('加载历史消息失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -611,7 +590,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
|
// 加载结束后继续保留短暂锁窗,避免布局结算阶段误触自动回底
|
||||||
historyLoadingLockUntilRef.current = Date.now() + 800;
|
historyLoadingLockUntilRef.current = Date.now() + 800;
|
||||||
}
|
}
|
||||||
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages]);
|
||||||
|
|
||||||
// 从搜索结果跳转:滚动到目标 seq
|
// 从搜索结果跳转:滚动到目标 seq
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -660,13 +639,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
isBrowsingHistoryRef.current = false;
|
isBrowsingHistoryRef.current = false;
|
||||||
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
|
// FlashList 在 inverted 模式下 scrollToOffset({ offset: 0 }) 可能无法真正滚到最底部,
|
||||||
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底,再补偿回 0。
|
// 因为列表内容高度变化后最小 offset 可能不是 0。先尝试滚到负值确保到底,再补偿回 0。
|
||||||
isProgrammaticScrollRef.current = true;
|
|
||||||
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
|
flatListRef.current?.scrollToOffset({ offset: -99999, animated: false });
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
setTimeout(() => {
|
|
||||||
isProgrammaticScrollRef.current = false;
|
|
||||||
}, 220);
|
|
||||||
});
|
});
|
||||||
}, [flatListRef]);
|
}, [flatListRef]);
|
||||||
|
|
||||||
@@ -700,6 +675,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
// 自动标记已读(QQ/Telegram 风格):
|
// 自动标记已读(QQ/Telegram 风格):
|
||||||
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
||||||
// 历史加载/浏览历史不会触发 read。
|
// 历史加载/浏览历史不会触发 read。
|
||||||
|
// 注意:不能复用 prevLatestSeqRef 做判定——它被新消息跟随 effect 无条件推进,
|
||||||
|
// 会导致这里的去重判断永远成立,标记已读逻辑事实上不会执行。
|
||||||
|
// 这里只依赖专属的 prevMarkedReadSeqRef 做去重。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId || messages.length === 0) return;
|
if (!conversationId || messages.length === 0) return;
|
||||||
if (loading || loadingMore) return;
|
if (loading || loadingMore) return;
|
||||||
@@ -709,10 +687,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
|
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
|
||||||
if (latestSeq <= 0) return;
|
if (latestSeq <= 0) return;
|
||||||
|
|
||||||
// 没有新增最新消息,不重复上报
|
|
||||||
if (latestSeq <= prevLatestSeqRef.current) return;
|
|
||||||
prevLatestSeqRef.current = latestSeq;
|
|
||||||
|
|
||||||
// 避免对同一 seq 重复标记
|
// 避免对同一 seq 重复标记
|
||||||
if (latestSeq <= prevMarkedReadSeqRef.current) return;
|
if (latestSeq <= prevMarkedReadSeqRef.current) return;
|
||||||
prevMarkedReadSeqRef.current = latestSeq;
|
prevMarkedReadSeqRef.current = latestSeq;
|
||||||
@@ -722,18 +696,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
});
|
});
|
||||||
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const keyboardWillShow = (e: KeyboardEvent) => {
|
const keyboardWillShow = (e: KeyboardEvent) => {
|
||||||
@@ -1476,20 +1438,15 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 撤回消息 - 现在通过 MessageManager 处理
|
// 撤回消息 - 现在通过 MessageManager 处理(撤回事件由 MessageManager 同步状态)
|
||||||
const handleRecall = useCallback(async (messageId: string) => {
|
const handleRecall = useCallback(async (messageId: string) => {
|
||||||
try {
|
try {
|
||||||
if (isGroupChat && effectiveGroupId) {
|
await messageService.recallMessage(messageId);
|
||||||
await messageService.recallMessage(messageId);
|
|
||||||
} else {
|
|
||||||
await messageService.recallMessage(messageId);
|
|
||||||
}
|
|
||||||
// 不需要手动更新状态,MessageManager 会处理撤回事件
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('撤回消息失败:', error);
|
console.error('撤回消息失败:', error);
|
||||||
Alert.alert('撤回失败', '无法撤回消息');
|
Alert.alert('撤回失败', '无法撤回消息');
|
||||||
}
|
}
|
||||||
}, [isGroupChat, effectiveGroupId, conversationId]);
|
}, []);
|
||||||
|
|
||||||
// 长按消息显示操作菜单
|
// 长按消息显示操作菜单
|
||||||
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
|
const handleLongPressMessage = useCallback((message: GroupMessage, position?: MenuPosition) => {
|
||||||
@@ -1528,8 +1485,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
if (!conversationId) return;
|
if (!conversationId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLastSeq(0);
|
|
||||||
setFirstSeq(0);
|
|
||||||
setHasMoreHistory(true);
|
setHasMoreHistory(true);
|
||||||
await messageRepository.clearConversation(conversationId);
|
await messageRepository.clearConversation(conversationId);
|
||||||
// 刷新消息列表
|
// 刷新消息列表
|
||||||
@@ -1538,9 +1493,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
console.error('清空会话失败:', error);
|
console.error('清空会话失败:', error);
|
||||||
Alert.alert('清空失败', '无法清空聊天记录');
|
Alert.alert('清空失败', '无法清空聊天记录');
|
||||||
}
|
}
|
||||||
}, [conversationId, refreshMessages]);
|
}, [conversationId, refreshMessages]); // 回复消息
|
||||||
|
|
||||||
// 回复消息
|
|
||||||
const handleReplyMessage = useCallback((message: GroupMessage) => {
|
const handleReplyMessage = useCallback((message: GroupMessage) => {
|
||||||
setReplyingTo(message);
|
setReplyingTo(message);
|
||||||
textInputRef.current?.focus();
|
textInputRef.current?.focus();
|
||||||
@@ -1593,26 +1546,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
}
|
}
|
||||||
}, [groupTypingUsers, groupMembers]);
|
}, [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(() => {
|
const handleDismiss = useCallback(() => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
@@ -1656,7 +1589,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
currentUserId,
|
currentUserId,
|
||||||
keyboardHeight,
|
keyboardHeight,
|
||||||
loading,
|
loading,
|
||||||
sending,
|
|
||||||
activePanel,
|
activePanel,
|
||||||
sendingImage,
|
sendingImage,
|
||||||
uploadingAttachments,
|
uploadingAttachments,
|
||||||
@@ -1666,16 +1598,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
longPressMenuVisible,
|
longPressMenuVisible,
|
||||||
selectedMessage,
|
selectedMessage,
|
||||||
selectedMessageId,
|
selectedMessageId,
|
||||||
setSelectedMessageId,
|
|
||||||
menuPosition,
|
menuPosition,
|
||||||
isGroupChat,
|
isGroupChat,
|
||||||
groupInfo,
|
groupInfo,
|
||||||
groupMembers,
|
groupMembers,
|
||||||
// 【改造】使用 MessageManager 的输入状态
|
|
||||||
typingUsers: groupTypingUsers,
|
|
||||||
currentUserRole,
|
currentUserRole,
|
||||||
mentionQuery,
|
mentionQuery,
|
||||||
selectedMentions,
|
|
||||||
isMuted,
|
isMuted,
|
||||||
muteAll,
|
muteAll,
|
||||||
followRestrictionHint,
|
followRestrictionHint,
|
||||||
@@ -1697,9 +1625,7 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
shouldShowTime,
|
shouldShowTime,
|
||||||
handleInputChange,
|
handleInputChange,
|
||||||
handleSend,
|
handleSend,
|
||||||
handlePickImage,
|
|
||||||
removePendingAttachment,
|
removePendingAttachment,
|
||||||
handleTakePhoto,
|
|
||||||
handleMoreAction,
|
handleMoreAction,
|
||||||
handleInsertEmoji,
|
handleInsertEmoji,
|
||||||
handleSendSticker,
|
handleSendSticker,
|
||||||
@@ -1718,9 +1644,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
handleMentionAll,
|
handleMentionAll,
|
||||||
getSenderInfo,
|
getSenderInfo,
|
||||||
getTypingHint,
|
getTypingHint,
|
||||||
getPanelHeight,
|
|
||||||
getInputBottom,
|
|
||||||
getListPaddingBottom,
|
|
||||||
handleDismiss,
|
handleDismiss,
|
||||||
navigateToInfo,
|
navigateToInfo,
|
||||||
navigateToChatSettings,
|
navigateToChatSettings,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
|||||||
import { useAppColors } from '../../theme';
|
import { useAppColors } from '../../theme';
|
||||||
import { Post } from '../../types';
|
import { Post } from '../../types';
|
||||||
import { PostCard, TabBar, UserProfileHeader } from '../../components/business';
|
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 { useResponsive } from '../../hooks';
|
||||||
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
|
import { useUserProfile, ProfileMode, TABS, TAB_ICONS, createSharedProfileStyles } from './useUserProfile';
|
||||||
|
|
||||||
@@ -116,6 +117,39 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
currentUser,
|
currentUser,
|
||||||
} = useUserProfile({ mode, userId, isDesktop, isTablet });
|
} = 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;
|
const currentPosts = activeTab === 0 ? posts : favorites;
|
||||||
|
|
||||||
@@ -130,12 +164,12 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
]}>
|
]}>
|
||||||
<PostCard
|
<PostCard
|
||||||
post={item}
|
post={item}
|
||||||
onAction={(action) => handlePostAction(item, action)}
|
onAction={(action) => onPostAction(item, action)}
|
||||||
isPostAuthor={isPostAuthor}
|
isPostAuthor={isPostAuthor}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}, [currentUser?.id, handlePostAction, currentPosts.length]);
|
}, [currentUser?.id, onPostAction, currentPosts.length]);
|
||||||
|
|
||||||
const postKeyExtractor = useCallback((item: Post) => item.id, []);
|
const postKeyExtractor = useCallback((item: Post) => item.id, []);
|
||||||
|
|
||||||
@@ -269,6 +303,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* 图片查看器 */}
|
||||||
|
<ImageGallery
|
||||||
|
visible={showImageViewer}
|
||||||
|
images={stableGalleryImages}
|
||||||
|
initialIndex={selectedImageIndex}
|
||||||
|
onClose={closeImageViewer}
|
||||||
|
enableSave
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -310,6 +353,15 @@ export const UserProfileScreen: React.FC<UserProfileScreenProps> = ({ mode, user
|
|||||||
}
|
}
|
||||||
drawDistance={250}
|
drawDistance={250}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 图片查看器 */}
|
||||||
|
<ImageGallery
|
||||||
|
visible={showImageViewer}
|
||||||
|
images={stableGalleryImages}
|
||||||
|
initialIndex={selectedImageIndex}
|
||||||
|
onClose={closeImageViewer}
|
||||||
|
enableSave
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -62,23 +62,36 @@ class PostSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise<PostsResult> {
|
async fetchPosts(params?: GetPostsParams, key: string = 'default'): Promise<PostsResult> {
|
||||||
const store = usePostListStore.getState();
|
const queryParams: GetPostsParams = {
|
||||||
store.updatePostsState(key, { isLoading: true, error: null });
|
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 {
|
try {
|
||||||
const queryParams: GetPostsParams = {
|
|
||||||
pageSize: params?.pageSize || DEFAULT_PAGE_SIZE,
|
|
||||||
...params,
|
|
||||||
cursor: params?.cursor !== undefined ? params.cursor : '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await postRepository.getPosts(queryParams);
|
const result = await postRepository.getPosts(queryParams);
|
||||||
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
|
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
|
||||||
|
|
||||||
const currentState = usePostListStore.getState().getPostsState(key);
|
const currentState = usePostListStore.getState().getPostsState(key);
|
||||||
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
|
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
|
||||||
const mergeStart = Date.now();
|
const mergeStart = Date.now();
|
||||||
const mergedPosts = mergeRefreshWindow(currentState.posts, incomingPosts);
|
// 参数变化时直接替换;同参数刷新则合并以保留滚动位置上的帖子
|
||||||
|
const mergedPosts = paramsChanged
|
||||||
|
? incomingPosts
|
||||||
|
: mergeRefreshWindow(currentState.posts, incomingPosts);
|
||||||
const mergeCost = Date.now() - mergeStart;
|
const mergeCost = Date.now() - mergeStart;
|
||||||
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
|
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
|
||||||
console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length });
|
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 isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
|
||||||
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
|
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
|
||||||
const mergeStart = Date.now();
|
const mergeStart = Date.now();
|
||||||
const mergedPosts = mergeRefreshWindow(state.posts, incomingPosts);
|
// refreshPosts 是刷新操作,应直接替换当前列表,避免旧数据残留
|
||||||
|
const mergedPosts = incomingPosts;
|
||||||
const mergeCost = Date.now() - mergeStart;
|
const mergeCost = Date.now() - mergeStart;
|
||||||
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
|
if (mergeCost >= MERGE_PERF_WARN_THRESHOLD_MS) {
|
||||||
console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length });
|
console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length });
|
||||||
|
|||||||
Reference in New Issue
Block a user