Files
frontend/src/screens/home/HomeScreen.tsx
lafay be77a9d04c
All checks were successful
Frontend CI / ota (android) (push) Successful in 3m24s
Frontend CI / ota (ios) (push) Successful in 3m22s
Frontend CI / build-and-push-web (push) Successful in 21m12s
Frontend CI / build-android-apk (push) Successful in 41m40s
feat(messaging): implement optimistic updates with retry for failed messages
- Add pending/failed message status to track send state
- Implement optimistic UI: show message immediately, update on server response
- Add retry functionality for failed messages via tap-to-retry
- Change send button from icon to text "发送" for clarity
- Add MessageSendService with temp ID generation to prevent collisions

feat(notification): add JPush notification deduplication and clear on tap

- Deduplicate notificationArrived events from dual JPush/vendor channels
- Clear all notifications on tap (QQ-style behavior)

feat(post): add client-side idempotency key for post creation

- Generate client_request_id per publish intent to prevent duplicates on retry
- Pass idempotency key through to postService and voteService
2026-06-26 17:01:09 +08:00

1124 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 首页 HomeScreen
* 威友 - 首页展示
* 支持列表和多列网格模式(响应式布局)
*/
import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
ScrollView,
Platform,
StyleSheet,
RefreshControl,
StatusBar,
TouchableOpacity,
NativeSyntheticEvent,
NativeScrollEvent,
Alert,
Clipboard,
Modal,
} from 'react-native';
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 { 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 { useChannels } from '../../hooks/useChannels';
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';
import { useDifferentialPosts } from '../../hooks/useDifferentialPosts';
import { postSyncService } from '@/services/post';
import { SearchScreen } from './SearchScreen';
import { CreatePostScreen } from '../create/CreatePostScreen';
import { MarketView } from './MarketView';
import { CreateTradeScreen } from '../trade/CreateTradeScreen';
import type { TradeCategory } from '../../types/trade';
import { ALL_TRADE_CATEGORIES } from '../../types/trade';
import * as hrefs from '../../navigation/hrefs';
import { blurActiveElement } from '../../infrastructure/platform';
const SORT_OPTIONS = ['关注', '推荐', '最新'];
const SORT_ICONS = ['account-heart', 'fire', 'clock-outline'];
const SORT_TYPES: PostType[] = ['follow', 'hot', 'latest'];
const MARKET_FILTER_OPTIONS = [
{ key: 'all' as const, label: '全部', icon: 'view-grid-outline' },
{ key: 'sell' as const, label: '出售', icon: 'tag-outline' },
{ key: 'buy' as const, label: '收购', icon: 'hand-coin-outline' },
];
const DEFAULT_PAGE_SIZE = 20;
const MOBILE_TAB_BAR_HEIGHT = 64;
const MOBILE_TAB_FLOATING_MARGIN = 12;
const MOBILE_FAB_GAP = 12;
/** 首页纵向滑动超过此阈值视为明确向下/向上划,用于隐藏或显示底部 Tab */
const TAB_BAR_SCROLL_DELTA_Y = 10;
/** 停止滑动多久后自动恢复底部 Tab */
const TAB_BAR_IDLE_RESTORE_MS = 2200;
type ViewMode = 'list' | 'grid';
type PostType = 'follow' | 'hot' | 'latest';
type LatestCapsule = { id: string; name: string };
function createHomeStyles(colors: AppColors, responsivePadding: number) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
header: {
backgroundColor: colors.background.paper,
},
searchWrapper: {
paddingTop: spacing.sm,
paddingBottom: spacing.xs,
},
homeTabSwitcher: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.xs,
marginBottom: spacing.xs,
paddingHorizontal: responsivePadding + 4,
},
homeTabSwitcherLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
homeTabSwitcherRight: {
flex: 1,
marginLeft: 16,
},
homeTabItem: {
paddingVertical: spacing.sm,
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
homeTabItemActive: {
borderBottomColor: colors.text.primary,
},
homeTabText: {
fontSize: 20,
fontWeight: '400',
color: colors.text.hint,
},
homeTabTextActive: {
fontSize: 20,
fontWeight: '700',
color: colors.text.primary,
},
homeTabBar: {
marginTop: spacing.sm,
marginBottom: spacing.md,
},
viewToggleBtn: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
sortBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
sortBarCapsuleScroll: {
flex: 1,
},
sortBarCapsuleContent: {
gap: spacing.sm,
alignItems: 'center',
paddingVertical: spacing.xs,
},
capsuleItem: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: 999,
},
capsuleText: {
fontSize: 13,
fontWeight: '600',
color: colors.text.secondary,
},
capsuleTextActive: {
color: colors.primary.main,
},
sortTrigger: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.xs,
},
sortTriggerText: {
fontSize: 13,
fontWeight: '600',
color: colors.text.secondary,
marginLeft: 2,
},
listContent: {
flexGrow: 1,
},
contentContainer: {
flex: 1,
},
listItem: {},
gridContent: {
flexGrow: 1,
},
gridItem: {
marginBottom: 0,
},
floatingButton: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
...shadows.lg,
},
floatingButtonDesktop: {
right: 40,
bottom: 40,
width: 64,
height: 64,
borderRadius: 32,
},
floatingButtonWide: {
right: 60,
bottom: 60,
width: 72,
height: 72,
borderRadius: 36,
},
loadingMoreFooter: {
paddingVertical: 20,
alignItems: 'center',
justifyContent: 'center',
},
});
}
export const HomeScreen: React.FC = () => {
const router = useRouter();
const insets = useSafeAreaInsets();
const storePosts = useUserStore((s) => s.posts);
const currentUser = useCurrentUser();
const isAuthenticated = useIsAuthenticated();
const isVerified = useIsVerified();
const fetchVerificationStatus = useVerificationStore((s) => s.fetchStatus);
const hasFetchedVerification = useVerificationStore((s) => s.hasFetchedStatus);
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
// 使用响应式 hook
const {
width,
isMobile,
isTablet,
isDesktop,
isWideScreen,
} = useResponsive();
// 响应式间距
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 styles = useMemo(() => createHomeStyles(colors, responsivePadding), [colors, responsivePadding]);
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
const [sortIndex, setSortIndex] = useState(2);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const { data: channelList = [] } = useChannels();
const latestCapsules = useMemo<LatestCapsule[]>(
() => [{ id: '', name: '全部' }, ...channelList.map(item => ({ id: item.id, name: item.name }))],
[channelList]
);
const [activeCapsuleId, setActiveCapsuleId] = useState('');
// 图片查看器状态
const [showImageViewer, setShowImageViewer] = useState(false);
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const stableCloseImageViewer = useCallback(() => setShowImageViewer(false), []);
const stableGalleryImages = useMemo(() => postImages.map((img, i) => ({
id: img.id || img.url || `img-${i}`,
url: img.url || img.uri || ''
})), [postImages]);
// 搜索显示状态(用于内嵌搜索页面)
const [showSearch, setShowSearch] = useState(false);
// 发帖弹窗状态
const [showCreatePost, setShowCreatePost] = useState(false);
// 广场/市集 Tab
const [homeTab, setHomeTab] = useState<'square' | 'market'>('square');
const [showCreateTrade, setShowCreateTrade] = useState(false);
// 市集筛选状态
const [marketFilterType, setMarketFilterType] = useState<'all' | 'sell' | 'buy'>('all');
const [marketCategory, setMarketCategory] = useState<TradeCategory | 'all'>('all');
// 分享面板状态
const [showShareSheet, setShowShareSheet] = useState(false);
const [sharePost, setSharePost] = useState<Post | null>(null);
useEffect(() => {
if (isAuthenticated && !hasFetchedVerification) {
fetchVerificationStatus();
}
}, [isAuthenticated, hasFetchedVerification, fetchVerificationStatus]);
useEffect(() => {
if (showCreatePost) {
blurActiveElement();
}
}, [showCreatePost]);
// 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态
const postIdsRef = useRef<Set<string>>(new Set());
const isLoadingMoreRef = useRef(false);
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
const homeListScrollYRef = useRef(0);
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// FlashList 和 ScrollView 的 ref用于滚动到顶部
const flashListRef = useRef<FlashListRef<Post> | null>(null);
const scrollViewRef = useRef<ScrollView | null>(null);
const clearTabBarIdleRestoreTimer = useCallback(() => {
if (tabBarIdleRestoreTimerRef.current != null) {
clearTimeout(tabBarIdleRestoreTimerRef.current);
tabBarIdleRestoreTimerRef.current = null;
}
}, []);
const scheduleTabBarIdleRestore = useCallback(() => {
clearTabBarIdleRestoreTimer();
tabBarIdleRestoreTimerRef.current = setTimeout(() => {
setBottomTabBarHiddenByScroll(false);
tabBarIdleRestoreTimerRef.current = null;
}, TAB_BAR_IDLE_RESTORE_MS);
}, [clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
const handleHomeVerticalScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y;
const dy = y - homeListScrollYRef.current;
homeListScrollYRef.current = y;
scheduleTabBarIdleRestore();
if (y <= 0) {
setBottomTabBarHiddenByScroll(false);
return;
}
if (dy > TAB_BAR_SCROLL_DELTA_Y) {
setBottomTabBarHiddenByScroll(true);
} else if (dy < -TAB_BAR_SCROLL_DELTA_Y) {
setBottomTabBarHiddenByScroll(false);
}
},
[scheduleTabBarIdleRestore, setBottomTabBarHiddenByScroll]
);
useFocusEffect(
useCallback(() => {
return () => {
clearTabBarIdleRestoreTimer();
homeListScrollYRef.current = 0;
setBottomTabBarHiddenByScroll(false);
};
}, [clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll])
);
useEffect(() => {
if (showSearch) {
clearTabBarIdleRestoreTimer();
setBottomTabBarHiddenByScroll(false);
}
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
// 监听首页 Tab 点击事件,滚动到顶部
useEffect(() => {
if (homeTabPressCount > 0) {
// 滚动 FlashList 到顶部
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
// 滚动 ScrollView 到顶部(网格模式)
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 重置底部 Tab 栏状态
setBottomTabBarHiddenByScroll(false);
homeListScrollYRef.current = 0;
}
}, [homeTabPressCount, setBottomTabBarHiddenByScroll]);
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
const capsuleHScrollRef = useRef<ScrollView | null>(null);
const capsuleScrollXRef = useRef(0);
const restoreCapsuleStripScroll = useCallback(() => {
const x = capsuleScrollXRef.current;
if (x <= 0) return;
const scroll = () => capsuleHScrollRef.current?.scrollTo({ x, animated: false });
scroll();
requestAnimationFrame(() => {
requestAnimationFrame(scroll);
});
}, []);
const onCapsuleHorizontalScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
capsuleScrollXRef.current = e.nativeEvent.contentOffset.x;
}, []);
// 构建一个以 postId 为 key 的 map用于快速查找
const postsMap = useMemo(() => {
const map = new Map<string, Post>();
storePosts.forEach(post => {
map.set(post.id, post);
});
return map;
}, [storePosts]);
// 获取当前排序对应的帖子类型
const getPostType = useCallback((): PostType => {
return SORT_TYPES[sortIndex] ?? 'hot';
}, [sortIndex]);
const currentChannelId = activeCapsuleId || undefined;
useEffect(() => {
homeListScrollYRef.current = 0;
setBottomTabBarHiddenByScroll(false);
}, [sortIndex, currentChannelId, setBottomTabBarHiddenByScroll]);
useEffect(() => {
requestAnimationFrame(() => {
flashListRef.current?.scrollToOffset({ offset: 0, animated: false });
scrollViewRef.current?.scrollTo({ y: 0, animated: false });
});
}, [viewMode]);
useLayoutEffect(() => {
restoreCapsuleStripScroll();
}, [activeCapsuleId, latestCapsules.length, restoreCapsuleStripScroll]);
// 使用差异更新 Hook 获取帖子列表
const listKey = useMemo(
() => `home_${getPostType()}_${currentChannelId || 'all'}`,
[getPostType, currentChannelId]
);
const {
posts,
loading: isLoading,
isInitialLoading,
isLoadingMore,
refreshing: isRefreshing,
hasMore,
error,
} = useDifferentialPosts<Post>(
[],
{
listKey,
enableDiff: true,
enableBatching: true,
autoSubscribe: true,
}
);
// 加载更多方法
const loadMore = useCallback(async () => {
if (hasMore && !isLoadingMore && !isLoadingMoreRef.current) {
isLoadingMoreRef.current = true;
const startedAt = Date.now();
try {
await postSyncService.loadMorePosts(listKey);
console.log('[PostPerf] home loadMore', {
listKey,
costMs: Date.now() - startedAt,
totalItems: posts.length,
});
} catch (err) {
console.error('加载更多失败:', err);
} finally {
isLoadingMoreRef.current = false;
}
}
}, [posts.length, listKey, hasMore, isLoadingMore]);
// 网格模式滚动处理 - 底部 Tab 显隐(加载更多由 FlashList onEndReached 处理)
const handleGridScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
handleHomeVerticalScroll(event);
},
[handleHomeVerticalScroll]
);
// 刷新方法 - 先获取正确类型的帖子,再刷新
const refresh = useCallback(async () => {
try {
// 先获取正确类型的帖子
await postSyncService.fetchPosts(
{
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
post_type: getPostType(),
channelId: currentChannelId,
},
listKey
);
} catch (err) {
console.error('刷新失败:', err);
}
}, [listKey, getPostType, currentChannelId]);
useEffect(() => {
let cancelled = false;
const run = async () => {
// 注意:不再在此处调用 reset()。
// fetchPosts 内部会在参数变化时立即清空 store 中的旧数据并设置 loading
// 这样可以避免本地清空后、请求发起前的间隙渲染到上一个分区的帖子造成闪烁。
if (!cancelled) {
await refresh();
}
};
run();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sortIndex, currentChannelId]);
// 将获取到的原始帖子与 store 中的状态合并
// 这样 UI 始终显示的是 store 中的最新状态(包括点赞、收藏等)
const displayPosts = useMemo(() => {
return posts.map(post => {
const storePost = postsMap.get(post.id);
if (storePost) {
// store 同步点赞等状态channel 等列表专用字段在 store 里常缺失,从列表帖合并
return {
...post,
...storePost,
channel: storePost.channel ?? post.channel,
};
}
return post;
});
}, [posts, postsMap]);
/** 按 id 取当前列表中的最新 post配合 PostCard memo 忽略 onAction 引用 */
const postByIdRef = useRef<Map<string, Post>>(new Map());
postByIdRef.current = new Map(displayPosts.map((p) => [p.id, p]));
// 根据屏幕尺寸确定网格列数
const gridColumns = useMemo(() => {
if (isWideScreen || width >= 1440) return 4;
if (isDesktop || width >= 1024) return 3;
if (isTablet || width >= 768) return 2;
return 2; // 移动端瀑布流保持2列
}, [width, isTablet, isDesktop, isWideScreen]);
// 列表模式下始终使用单列,宽屏下居中显示
const useMultiColumnList = useMemo(() => {
return false; // 改为始终返回false使用单列布局
}, []);
// 宽屏下内容最大宽度
const contentMaxWidth = useMemo(() => {
if (isWideScreen) return 1200;
if (isDesktop) return 1000;
if (isTablet) return 800;
return width; // 移动端使用全宽
}, [width, isTablet, isDesktop, isWideScreen]);
// 列表模式横向内边距:移动端适当收窄,减少两侧空白
const listHorizontalPadding = useMemo(() => {
if (isMobile) {
return Math.max(6, responsivePadding - responsiveGap);
}
return responsivePadding;
}, [isMobile, responsivePadding, responsiveGap]);
// 列表模式卡片宽度:宽屏限宽并居中,移动端占用更多可用宽度
const listItemWidth = useMemo(() => {
const availableWidth = Math.max(0, width - listHorizontalPadding * 2);
if (isDesktop || isWideScreen) {
return Math.min(contentMaxWidth, availableWidth);
}
return availableWidth;
}, [width, listHorizontalPadding, isDesktop, isWideScreen, contentMaxWidth]);
const floatingButtonBottom = useMemo(() => {
if (!isMobile) {
return undefined;
}
if (bottomTabBarHiddenByScroll) {
return MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP + insets.bottom;
}
// TabBar 悬浮在底部,发帖按钮需要在 TabBar 上方
return MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN * 2 + MOBILE_FAB_GAP + insets.bottom;
}, [isMobile, insets.bottom, bottomTabBarHiddenByScroll]);
// 切换视图模式
const toggleViewMode = () => {
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
};
// 切换排序
const changeSort = useCallback((nextIndex: number) => {
if (nextIndex < 0 || nextIndex >= SORT_OPTIONS.length || nextIndex === sortIndex) {
return;
}
setSortIndex(nextIndex);
}, [sortIndex]);
const cycleMarketFilter = useCallback(() => {
setMarketFilterType(prev => {
const idx = MARKET_FILTER_OPTIONS.findIndex(f => f.key === prev);
return MARKET_FILTER_OPTIONS[(idx + 1) % MARKET_FILTER_OPTIONS.length].key;
});
}, []);
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
const handleSearchPress = () => {
setShowSearch(true);
};
// 跳转到帖子详情
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
router.push(hrefs.hrefPostDetail(postId, scrollToComments));
};
// 跳转到用户主页
const handleUserPress = (userId: string) => {
router.push(hrefs.hrefUserProfile(userId));
};
// 点赞帖子
const handleLike = async (post: Post) => {
try {
if (post.is_liked) {
await postSyncService.unlikePost(post.id);
} else {
await postSyncService.likePost(post.id);
}
} catch (error) {
console.error('点赞操作失败:', error);
}
};
// 收藏帖子
const handleBookmark = async (post: Post) => {
try {
if (post.is_favorited) {
await postSyncService.unfavoritePost(post.id);
} else {
await postSyncService.favoritePost(post.id);
}
} catch (error) {
console.error('收藏操作失败:', error);
}
};
// 分享帖子 - 打开分享面板
const handleShare = (post: Post) => {
setSharePost(post);
setShowShareSheet(true);
};
// 删除帖子 - 删除后刷新列表
const handleDeletePost = async (postId: string) => {
try {
const success = await postService.deletePost(postId);
if (success) {
// 刷新列表以移除已删除的帖子
refresh();
} else {
console.error('删除帖子失败');
}
} catch (deleteError) {
console.error('删除帖子失败:', deleteError);
throw deleteError; // 重新抛出错误,让 PostCard 处理错误提示
}
};
// 处理图片点击 - 打开图片查看器
const handleImagePress = (images: ImageGridItem[], index: number) => {
setPostImages(images);
setSelectedImageIndex(index);
setShowImageViewer(true);
};
// 统一处理 PostCard 的所有操作
const handlePostAction = (post: Post, action: PostCardAction) => {
switch (action.type) {
case 'press':
handlePostPress(post.id);
break;
case 'userPress':
const authorId = post.author?.id;
if (authorId) handleUserPress(authorId);
break;
case 'like':
case 'unlike':
handleLike(post);
break;
case 'comment':
handlePostPress(post.id, true);
break;
case 'bookmark':
case 'unbookmark':
handleBookmark(post);
break;
case 'share':
handleShare(post);
break;
case 'imagePress':
if (action.payload?.images && action.payload?.imageIndex !== undefined) {
handleImagePress(action.payload.images, action.payload.imageIndex);
}
break;
case 'delete':
handleDeletePost(post.id);
break;
}
};
const handlePostActionRef = useRef(handlePostAction);
handlePostActionRef.current = handlePostAction;
const stableOnPostAction = useCallback((postId: string, action: PostCardAction) => {
const post = postByIdRef.current.get(postId);
if (post) {
handlePostActionRef.current(post, action);
}
}, []);
// 跳转到发帖页面(使用 Modal 方式)
const handleCreatePost = () => {
if (!isAuthenticated) {
router.push(hrefs.hrefAuthLogin());
return;
}
if (!isVerified) {
router.push(hrefs.hrefVerificationGuide());
return;
}
setShowCreatePost(true);
};
const renderChannelCapsules = () => {
return (
<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
}
>
{item.name}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
);
};
// 渲染帖子卡片(列表模式)
const keyExtractor = useCallback((item: Post) => item.id, []);
const renderPostList = useCallback<ListRenderItem<Post>>(({ item }) => {
const authorId = item.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<View style={[
styles.listItem,
{
marginBottom: responsiveGap,
width: listItemWidth,
alignSelf: 'center',
borderRadius: isMobile ? borderRadius.lg : 0,
overflow: isMobile ? 'hidden' : 'visible',
}
]}>
<PostCard
post={item}
onAction={(action) => stableOnPostAction(item.id, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [currentUser?.id, stableOnPostAction, isMobile, listItemWidth, responsiveGap]);
// 渲染网格项(用于 FlashList 多列模式)
const renderGridItem = useCallback<ListRenderItem<Post>>(({ item }) => {
const authorId = item.author?.id || '';
const isPostAuthor = currentUser?.id === authorId;
return (
<View style={{ marginBottom: 2, paddingHorizontal: responsiveGap / 2 }}>
<PostCard
post={item}
variant="grid"
onAction={(action) => stableOnPostAction(item.id, action)}
isPostAuthor={isPostAuthor}
/>
</View>
);
}, [currentUser?.id, stableOnPostAction, responsiveGap]);
// 渲染响应式网格布局(所有端统一使用 FlashList masonry 模式)
const renderResponsiveGrid = () => {
return (
<FlashList
key="home-grid"
ref={scrollViewRef as any}
data={displayPosts}
extraData={listKey}
renderItem={renderGridItem}
keyExtractor={keyExtractor}
numColumns={gridColumns}
masonry
optimizeItemArrangement
contentContainerStyle={{
paddingHorizontal: responsiveGap / 2,
paddingBottom: 80 + responsivePadding,
}}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
onScroll={handleGridScroll}
scrollEventThrottle={16}
ListEmptyComponent={renderEmpty}
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
drawDistance={250}
/>
);
};
// 渲染空状态
const renderEmpty = useCallback(() => {
if (isInitialLoading) return null;
return (
<EmptyState
title="暂无内容"
description="暂无帖子,快去发布第一条内容吧"
/>
);
}, [isInitialLoading]);
// 渲染列表内容
const renderListContent = () => {
if (useMultiColumnList) {
// 平板/桌面端使用多列网格
return renderResponsiveGrid();
}
// 移动端和宽屏都使用单列 FlashList宽屏下居中显示
return (
<FlashList
key="home-list"
ref={flashListRef}
data={displayPosts}
extraData={listKey}
renderItem={renderPostList}
keyExtractor={keyExtractor}
contentContainerStyle={{
paddingHorizontal: listHorizontalPadding,
paddingBottom: 80 + responsivePadding,
}}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={refresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
onScroll={handleHomeVerticalScroll}
scrollEventThrottle={16}
ListEmptyComponent={renderEmpty}
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
drawDistance={250}
/>
);
};
// 搜索页面内嵌模式
if (showSearch) {
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
<SearchScreen
onBack={() => setShowSearch(false)}
homeTab={homeTab}
/>
</SafeAreaView>
);
}
// 正常首页内容
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.paper} />
{/* 顶部Header */}
<View style={styles.header}>
{/* 广场/市集切换 */}
<View style={styles.homeTabSwitcher}>
<View style={styles.homeTabSwitcherLeft}>
<TouchableOpacity
activeOpacity={0.7}
style={[styles.homeTabItem, homeTab === 'square' && styles.homeTabItemActive]}
onPress={() => setHomeTab('square')}
>
<Text style={homeTab === 'square' ? styles.homeTabTextActive : styles.homeTabText}>
广
</Text>
</TouchableOpacity>
<TouchableOpacity
activeOpacity={0.7}
style={[styles.homeTabItem, homeTab === 'market' && styles.homeTabItemActive]}
onPress={() => setHomeTab('market')}
>
<Text style={homeTab === 'market' ? styles.homeTabTextActive : styles.homeTabText}>
</Text>
</TouchableOpacity>
</View>
<View style={styles.homeTabSwitcherRight}>
<SearchBar
value=""
onChangeText={() => {}}
onSubmit={handleSearchPress}
onFocus={handleSearchPress}
placeholder="搜索"
compact
/>
</View>
</View>
{/* 排序筛选 + 频道(仅广场模式显示) */}
{homeTab === 'square' && (
<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>
)}
{/* 市集分类 + 筛选(仅市集模式显示) */}
{homeTab === 'market' && (
<View style={styles.sortBar}>
<FlashList
horizontal
data={[{ key: 'all', label: '全部' }, ...ALL_TRADE_CATEGORIES]}
renderItem={({ item }: { item: { key: string; label: string } }) => (
<TouchableOpacity
activeOpacity={0.7}
style={styles.capsuleItem}
onPress={() => setMarketCategory(item.key as TradeCategory | 'all')}
>
<Text style={[
styles.capsuleText,
marketCategory === item.key ? styles.capsuleTextActive : {},
]}>
{item.label}
</Text>
</TouchableOpacity>
)}
keyExtractor={(item: { key: string; label: string }) => item.key}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.sortBarCapsuleContent}
style={styles.sortBarCapsuleScroll}
/>
<TouchableOpacity
activeOpacity={0.7}
onPress={cycleMarketFilter}
style={styles.sortTrigger}
>
<MaterialCommunityIcons
name={(MARKET_FILTER_OPTIONS.find(f => f.key === marketFilterType)?.icon || 'view-grid-outline') as any}
size={18}
color={colors.text.secondary}
/>
<Text style={styles.sortTriggerText}>
{MARKET_FILTER_OPTIONS.find(f => f.key === marketFilterType)?.label || '全部'}
</Text>
</TouchableOpacity>
</View>
)}
</View>
{/* 内容区域 */}
{homeTab === 'square' ? (
<View style={styles.contentContainer}>
{isInitialLoading ? (
<Loading fullScreen />
) : viewMode === 'list' ? (
renderListContent()
) : (
renderResponsiveGrid()
)}
</View>
) : (
<MarketView
onCreateTrade={() => setShowCreateTrade(true)}
filterType={marketFilterType}
onFilterTypeChange={setMarketFilterType}
selectedCategory={marketCategory}
onSelectedCategoryChange={setMarketCategory}
/>
)}
{/* 漂浮发帖/发布按钮 */}
<TouchableOpacity
style={[
styles.floatingButton,
isDesktop && styles.floatingButtonDesktop,
isWideScreen && styles.floatingButtonWide,
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
]}
onPress={homeTab === 'market' ? () => {
if (!isAuthenticated) { router.push(hrefs.hrefAuthLogin()); return; }
if (!isVerified) { router.push(hrefs.hrefVerificationGuide()); return; }
setShowCreateTrade(true);
} : handleCreatePost}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
</TouchableOpacity>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={stableGalleryImages}
initialIndex={selectedImageIndex}
onClose={stableCloseImageViewer}
enableSave
/>
{/* 发帖弹窗 */}
<Modal
visible={showCreatePost}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowCreatePost(false)}
>
<CreatePostScreen
onClose={() => setShowCreatePost(false)}
/>
</Modal>
{/* 发布交易弹窗 */}
<Modal
visible={showCreateTrade}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={() => setShowCreateTrade(false)}
>
<CreateTradeScreen
onClose={() => setShowCreateTrade(false)}
/>
</Modal>
{/* 分享面板 */}
<ShareSheet
visible={showShareSheet}
postUrl={sharePost ? `https://browser.littlelan.cn/post/${encodeURIComponent(sharePost.id)}` : undefined}
postTitle={sharePost?.title}
onClose={() => {
setShowShareSheet(false);
setSharePost(null);
}}
onShareAction={(actionKey) => {
if (sharePost) {
(async () => {
try {
const res = await postService.sharePost(sharePost.id, { channel: actionKey });
if (res.ok && res.shares_count != null) {
postSyncService.applyShareCountUpdate(sharePost.id, res.shares_count);
}
} catch (e) {
console.error('上报分享次数失败:', e);
}
})();
}
}}
/>
</SafeAreaView>
);
};