refactor(home): redesign home screen with sort bar and add recommendation feed
- Replace PagerView-based tabs with modern sort bar navigation - Add "推荐" (recommended) feed option with "hot" sort type - Update tab icons and labels to match new sort paradigm - Optimize PostCard, MessageBubble conditional styles perf(chat): migrate FlatList to FlashList for emoji/sticker panels - Improve virtualized rendering performance for emoji and sticker grids - Fix jump-to-latest scroll behavior for inverted FlashList perf(profile): extract user header and tab bar into separate memoized renders - Prevent unnecessary re-renders when switching tabs feat(api): add channel_id filter support for post queries - Include channel_id in cursor pagination requests - Update CursorPaginationRequest type definition style(chat-info): redesign group and private chat info screens with flat layout - Remove card borders and shadows for cleaner appearance - Adjust avatar sizes and spacing for consistency
This commit is contained in:
@@ -23,13 +23,13 @@ import { FlashList, ListRenderItem, FlashListRef } from '@shopify/flash-list';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter, useFocusEffect } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { PagerView } from '../../components/common';
|
||||
|
||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||
import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth';
|
||||
import { channelService, postService } from '../../services';
|
||||
import { PostCard, TabBar, SearchBar, ShareSheet } from '../../components/business';
|
||||
import { PostCard, SearchBar, ShareSheet } from '../../components/business';
|
||||
import type { PostCardAction } from '../../components/business/PostCard';
|
||||
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
@@ -40,8 +40,9 @@ import { CreatePostScreen } from '../create/CreatePostScreen';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const TABS = ['关注', '最新', '热门'];
|
||||
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
|
||||
const SORT_OPTIONS = ['关注', '推荐', '最新'];
|
||||
const SORT_ICONS = ['account-heart', 'fire', 'clock-outline'];
|
||||
const SORT_TYPES: PostType[] = ['follow', 'hot', 'latest'];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const MOBILE_TAB_BAR_HEIGHT = 64;
|
||||
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
||||
@@ -83,13 +84,20 @@ function createHomeStyles(colors: AppColors) {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
capsuleWrapper: {
|
||||
paddingBottom: spacing.sm,
|
||||
sortBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
capsuleList: {
|
||||
sortBarCapsuleScroll: {
|
||||
flex: 1,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
sortBarCapsuleContent: {
|
||||
gap: spacing.xs,
|
||||
paddingRight: spacing.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
capsuleItem: {
|
||||
paddingHorizontal: spacing.md,
|
||||
@@ -104,12 +112,21 @@ function createHomeStyles(colors: AppColors) {
|
||||
capsuleTextActive: {
|
||||
color: colors.primary.main,
|
||||
},
|
||||
sortTrigger: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
sortTriggerText: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: colors.text.secondary,
|
||||
marginLeft: 2,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
listHeader: {
|
||||
width: '100%',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -181,7 +198,7 @@ export const HomeScreen: React.FC = () => {
|
||||
const responsiveGap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 });
|
||||
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(1);
|
||||
const [sortIndex, setSortIndex] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [latestCapsules, setLatestCapsules] = useState<LatestCapsule[]>([{ id: '', name: '全部' }]);
|
||||
const [activeCapsuleId, setActiveCapsuleId] = useState('');
|
||||
@@ -217,9 +234,6 @@ export const HomeScreen: React.FC = () => {
|
||||
const postIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pagerRef = useRef<any>(null);
|
||||
|
||||
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
|
||||
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
@@ -325,28 +339,28 @@ export const HomeScreen: React.FC = () => {
|
||||
return map;
|
||||
}, [storePosts]);
|
||||
|
||||
// 获取当前 tab 对应的帖子类型
|
||||
// 获取当前排序对应的帖子类型
|
||||
const getPostType = useCallback((): PostType => {
|
||||
switch (activeIndex) {
|
||||
case 0: return 'follow';
|
||||
case 1: return 'latest';
|
||||
case 2: return 'hot';
|
||||
default: return 'latest';
|
||||
}
|
||||
}, [activeIndex]);
|
||||
return SORT_TYPES[sortIndex] ?? 'hot';
|
||||
}, [sortIndex]);
|
||||
|
||||
const isLatestTab = activeIndex === 1;
|
||||
const currentChannelId = isLatestTab && activeCapsuleId ? activeCapsuleId : undefined;
|
||||
const currentChannelId = activeCapsuleId || undefined;
|
||||
|
||||
useEffect(() => {
|
||||
homeListScrollYRef.current = 0;
|
||||
setBottomTabBarHiddenByScroll(false);
|
||||
}, [activeIndex, currentChannelId, viewMode, setBottomTabBarHiddenByScroll]);
|
||||
}, [sortIndex, currentChannelId, setBottomTabBarHiddenByScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: false });
|
||||
});
|
||||
}, [viewMode]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isLatestTab) return;
|
||||
restoreCapsuleStripScroll();
|
||||
}, [isLatestTab, activeCapsuleId, latestCapsules.length, restoreCapsuleStripScroll]);
|
||||
}, [activeCapsuleId, latestCapsules.length, restoreCapsuleStripScroll]);
|
||||
|
||||
// 使用差异更新 Hook 获取帖子列表
|
||||
const listKey = useMemo(
|
||||
@@ -419,13 +433,10 @@ export const HomeScreen: React.FC = () => {
|
||||
}
|
||||
}, [listKey, getPostType, currentChannelId]);
|
||||
|
||||
// Tab 切换时刷新数据并重置列表
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
reset();
|
||||
// reset 会清空 store 中的状态,但异步订阅可能有延迟;
|
||||
// 等一帧确保 store 的订阅回调已传播到 useDifferentialPosts
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
if (!cancelled) {
|
||||
await refresh();
|
||||
@@ -434,7 +445,7 @@ export const HomeScreen: React.FC = () => {
|
||||
run();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeIndex, currentChannelId]);
|
||||
}, [sortIndex, currentChannelId]);
|
||||
|
||||
// 将获取到的原始帖子与 store 中的状态合并
|
||||
// 这样 UI 始终显示的是 store 中的最新状态(包括点赞、收藏等)
|
||||
@@ -511,23 +522,13 @@ export const HomeScreen: React.FC = () => {
|
||||
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
||||
};
|
||||
|
||||
// 切换Tab(点击 TabBar 或手势滑动)
|
||||
const changeTab = useCallback((nextIndex: number) => {
|
||||
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
|
||||
// 切换排序
|
||||
const changeSort = useCallback((nextIndex: number) => {
|
||||
if (nextIndex < 0 || nextIndex >= SORT_OPTIONS.length || nextIndex === sortIndex) {
|
||||
return;
|
||||
}
|
||||
setActiveIndex(nextIndex);
|
||||
if (Platform.OS !== 'web') {
|
||||
pagerRef.current?.setPage(nextIndex);
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
const onPagerPageSelected = useCallback((e: { nativeEvent: { position: number } }) => {
|
||||
const nextIndex = e.nativeEvent.position;
|
||||
if (nextIndex !== activeIndex) {
|
||||
setActiveIndex(nextIndex);
|
||||
}
|
||||
}, [activeIndex]);
|
||||
setSortIndex(nextIndex);
|
||||
}, [sortIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadChannels = async () => {
|
||||
@@ -669,46 +670,41 @@ export const HomeScreen: React.FC = () => {
|
||||
setShowCreatePost(true);
|
||||
};
|
||||
|
||||
const renderLatestCapsules = () => {
|
||||
if (!isLatestTab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderChannelCapsules = () => {
|
||||
return (
|
||||
<View style={[styles.capsuleWrapper, { paddingHorizontal: responsivePadding }]}>
|
||||
<ScrollView
|
||||
ref={capsuleHScrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
nestedScrollEnabled
|
||||
contentContainerStyle={styles.capsuleList}
|
||||
onScroll={onCapsuleHorizontalScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={restoreCapsuleStripScroll}
|
||||
>
|
||||
{latestCapsules.map((item) => {
|
||||
const isActive = item.id === activeCapsuleId;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id || 'all'}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setActiveCapsuleId(item.id)}
|
||||
style={styles.capsuleItem}
|
||||
<ScrollView
|
||||
ref={capsuleHScrollRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
nestedScrollEnabled
|
||||
style={styles.sortBarCapsuleScroll}
|
||||
contentContainerStyle={styles.sortBarCapsuleContent}
|
||||
onScroll={onCapsuleHorizontalScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={restoreCapsuleStripScroll}
|
||||
>
|
||||
{latestCapsules.map((item) => {
|
||||
const isActive = item.id === activeCapsuleId;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.id || 'all'}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => setActiveCapsuleId(item.id)}
|
||||
style={styles.capsuleItem}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
isActive
|
||||
? { ...styles.capsuleText, ...styles.capsuleTextActive }
|
||||
: styles.capsuleText
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
isActive
|
||||
? { ...styles.capsuleText, ...styles.capsuleTextActive }
|
||||
: styles.capsuleText
|
||||
}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -758,6 +754,7 @@ export const HomeScreen: React.FC = () => {
|
||||
const renderResponsiveGrid = () => {
|
||||
return (
|
||||
<FlashList
|
||||
key={`${listKey}_grid`}
|
||||
ref={scrollViewRef as any}
|
||||
data={displayPosts}
|
||||
renderItem={renderGridItem}
|
||||
@@ -765,8 +762,6 @@ export const HomeScreen: React.FC = () => {
|
||||
numColumns={gridColumns}
|
||||
masonry
|
||||
optimizeItemArrangement
|
||||
ListHeaderComponent={renderLatestCapsules()}
|
||||
ListHeaderComponentStyle={styles.listHeader}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
@@ -798,10 +793,10 @@ export const HomeScreen: React.FC = () => {
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无内容"
|
||||
description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'}
|
||||
description="暂无帖子,快去发布第一条内容吧"
|
||||
/>
|
||||
);
|
||||
}, [activeIndex, isInitialLoading]);
|
||||
}, [isInitialLoading]);
|
||||
|
||||
// 渲染列表内容
|
||||
const renderListContent = () => {
|
||||
@@ -813,12 +808,11 @@ export const HomeScreen: React.FC = () => {
|
||||
// 移动端和宽屏都使用单列 FlashList,宽屏下居中显示
|
||||
return (
|
||||
<FlashList
|
||||
key={listKey}
|
||||
ref={flashListRef}
|
||||
data={displayPosts}
|
||||
renderItem={renderPostList}
|
||||
keyExtractor={keyExtractor}
|
||||
ListHeaderComponent={renderLatestCapsules()}
|
||||
ListHeaderComponentStyle={styles.listHeader}
|
||||
contentContainerStyle={{
|
||||
paddingHorizontal: listHorizontalPadding,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
@@ -873,74 +867,45 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Tab切换 */}
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
icons={TAB_ICONS}
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={changeTab}
|
||||
variant="modern"
|
||||
style={styles.homeTabBar}
|
||||
rightContent={
|
||||
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
|
||||
<MaterialCommunityIcons
|
||||
name={viewMode === 'list' ? 'view-grid-outline' : 'view-list'}
|
||||
size={22}
|
||||
color="#666"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
{/* 排序筛选 + 频道 */}
|
||||
<View style={styles.sortBar}>
|
||||
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
|
||||
<MaterialCommunityIcons
|
||||
name={viewMode === 'list' ? 'view-grid-outline' : 'view-list'}
|
||||
size={22}
|
||||
color="#666"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{renderChannelCapsules()}
|
||||
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => changeSort((sortIndex + 1) % SORT_OPTIONS.length)}
|
||||
style={styles.sortTrigger}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={SORT_ICONS[sortIndex] as any}
|
||||
size={18}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
<Text style={styles.sortTriggerText}>
|
||||
{SORT_OPTIONS[sortIndex]}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 帖子列表 */}
|
||||
{Platform.OS === 'web' ? (
|
||||
<View style={styles.contentContainer}>
|
||||
{isInitialLoading ? (
|
||||
<Loading fullScreen />
|
||||
) : viewMode === 'list' ? (
|
||||
renderListContent()
|
||||
) : (
|
||||
renderResponsiveGrid()
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<PagerView
|
||||
ref={pagerRef}
|
||||
style={styles.contentContainer}
|
||||
initialPage={activeIndex}
|
||||
onPageSelected={onPagerPageSelected}
|
||||
overdrag
|
||||
>
|
||||
<View key="follow" collapsable={false} style={styles.contentContainer}>
|
||||
{isInitialLoading && activeIndex === 0 ? (
|
||||
<Loading fullScreen />
|
||||
) : activeIndex === 0 ? (
|
||||
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||
) : (
|
||||
<View style={styles.contentContainer} />
|
||||
)}
|
||||
</View>
|
||||
<View key="latest" collapsable={false} style={styles.contentContainer}>
|
||||
{isInitialLoading && activeIndex === 1 ? (
|
||||
<Loading fullScreen />
|
||||
) : activeIndex === 1 ? (
|
||||
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||
) : (
|
||||
<View style={styles.contentContainer} />
|
||||
)}
|
||||
</View>
|
||||
<View key="hot" collapsable={false} style={styles.contentContainer}>
|
||||
{isInitialLoading && activeIndex === 2 ? (
|
||||
<Loading fullScreen />
|
||||
) : activeIndex === 2 ? (
|
||||
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||
) : (
|
||||
<View style={styles.contentContainer} />
|
||||
)}
|
||||
</View>
|
||||
</PagerView>
|
||||
)}
|
||||
<View style={styles.contentContainer}>
|
||||
{isInitialLoading ? (
|
||||
<Loading fullScreen />
|
||||
) : viewMode === 'list' ? (
|
||||
renderListContent()
|
||||
) : (
|
||||
renderResponsiveGrid()
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 漂浮发帖按钮 */}
|
||||
<TouchableOpacity
|
||||
|
||||
Reference in New Issue
Block a user