- 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
1124 lines
35 KiB
TypeScript
1124 lines
35 KiB
TypeScript
/**
|
||
* 首页 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>
|
||
);
|
||
};
|