2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 首页 HomeScreen
|
|
|
|
|
|
* 胡萝卜BBS - 首页展示
|
|
|
|
|
|
* 支持列表和多列网格模式(响应式布局)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
FlatList,
|
|
|
|
|
|
ScrollView,
|
|
|
|
|
|
StyleSheet,
|
|
|
|
|
|
RefreshControl,
|
|
|
|
|
|
StatusBar,
|
|
|
|
|
|
TouchableOpacity,
|
|
|
|
|
|
NativeScrollEvent,
|
|
|
|
|
|
NativeSyntheticEvent,
|
|
|
|
|
|
} from 'react-native';
|
|
|
|
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
|
|
import { useNavigation } from '@react-navigation/native';
|
|
|
|
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
|
|
|
|
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
|
|
|
|
|
import { Post } from '../../types';
|
|
|
|
|
|
import { useUserStore } from '../../stores';
|
|
|
|
|
|
import { useCurrentUser } from '../../stores/authStore';
|
|
|
|
|
|
import { postService } from '../../services';
|
|
|
|
|
|
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
|
|
|
|
|
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
|
|
|
|
|
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
|
|
|
|
|
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
|
|
|
|
|
|
|
|
|
|
|
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
|
|
|
|
|
|
|
|
|
|
|
|
const TABS = ['推荐', '关注', '热门', '最新'];
|
|
|
|
|
|
const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline'];
|
|
|
|
|
|
const DEFAULT_PAGE_SIZE = 20;
|
|
|
|
|
|
const SCROLL_BOTTOM_THRESHOLD = 240;
|
|
|
|
|
|
const LOAD_MORE_COOLDOWN_MS = 800;
|
|
|
|
|
|
const SWIPE_TRANSLATION_THRESHOLD = 40;
|
|
|
|
|
|
const SWIPE_COOLDOWN_MS = 300;
|
|
|
|
|
|
const MOBILE_TAB_BAR_HEIGHT = 64;
|
|
|
|
|
|
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
|
|
|
|
|
const MOBILE_FAB_GAP = 12;
|
|
|
|
|
|
|
|
|
|
|
|
type ViewMode = 'list' | 'grid';
|
|
|
|
|
|
|
|
|
|
|
|
export const HomeScreen: React.FC = () => {
|
|
|
|
|
|
const navigation = useNavigation<NavigationProp>();
|
|
|
|
|
|
const insets = useSafeAreaInsets();
|
|
|
|
|
|
const { fetchPosts, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
|
|
|
|
|
|
const currentUser = useCurrentUser();
|
|
|
|
|
|
|
|
|
|
|
|
// 使用响应式 hook
|
|
|
|
|
|
const {
|
|
|
|
|
|
width,
|
|
|
|
|
|
isMobile,
|
|
|
|
|
|
isTablet,
|
|
|
|
|
|
isDesktop,
|
|
|
|
|
|
isWideScreen,
|
|
|
|
|
|
breakpoint,
|
|
|
|
|
|
orientation,
|
|
|
|
|
|
isLandscape
|
|
|
|
|
|
} = 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 [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
|
|
const [posts, setPosts] = useState<Post[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
|
|
const [hasMore, setHasMore] = useState(true);
|
|
|
|
|
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
|
|
|
|
|
|
|
|
|
|
|
// 图片查看器状态
|
|
|
|
|
|
const [showImageViewer, setShowImageViewer] = useState(false);
|
|
|
|
|
|
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
|
|
|
|
|
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
|
|
|
|
|
const postIdsRef = React.useRef<Set<string>>(new Set());
|
|
|
|
|
|
const inFlightRequestKeysRef = React.useRef<Set<string>>(new Set());
|
|
|
|
|
|
const lastLoadMoreTriggerAtRef = useRef(0);
|
|
|
|
|
|
const lastSwipeAtRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
|
|
// 用 ref 同步关键状态,避免 onWaterfallScroll 的陈旧闭包问题
|
|
|
|
|
|
const pageRef = useRef(page);
|
|
|
|
|
|
const loadingMoreRef = useRef(loadingMore);
|
|
|
|
|
|
const hasMoreRef = useRef(hasMore);
|
|
|
|
|
|
pageRef.current = page;
|
|
|
|
|
|
loadingMoreRef.current = loadingMore;
|
|
|
|
|
|
hasMoreRef.current = hasMore;
|
|
|
|
|
|
|
|
|
|
|
|
// 根据屏幕尺寸确定网格列数
|
|
|
|
|
|
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 800;
|
|
|
|
|
|
if (isDesktop) return 720;
|
|
|
|
|
|
if (isTablet) return 640;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP;
|
|
|
|
|
|
}, [isMobile, insets.bottom]);
|
|
|
|
|
|
|
|
|
|
|
|
const appendUniquePosts = useCallback((prevPosts: Post[], incomingPosts: Post[]) => {
|
|
|
|
|
|
if (incomingPosts.length === 0) return prevPosts;
|
|
|
|
|
|
const seenIds = new Set(prevPosts.map(item => item.id));
|
|
|
|
|
|
const dedupedIncoming = incomingPosts.filter(item => {
|
|
|
|
|
|
if (seenIds.has(item.id)) return false;
|
|
|
|
|
|
seenIds.add(item.id);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
return dedupedIncoming.length > 0 ? [...prevPosts, ...dedupedIncoming] : prevPosts;
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const uniquePostsById = useCallback((items: Post[]) => {
|
|
|
|
|
|
if (items.length <= 1) return items;
|
|
|
|
|
|
const map = new Map<string, Post>();
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
|
map.set(item.id, item);
|
|
|
|
|
|
}
|
|
|
|
|
|
return Array.from(map.values());
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const getPostType = (): 'recommend' | 'follow' | 'hot' | 'latest' => {
|
|
|
|
|
|
switch (activeIndex) {
|
|
|
|
|
|
case 0: return 'recommend';
|
|
|
|
|
|
case 1: return 'follow';
|
|
|
|
|
|
case 2: return 'hot';
|
|
|
|
|
|
case 3: return 'latest';
|
|
|
|
|
|
default: return 'recommend';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 加载帖子列表
|
|
|
|
|
|
const loadPosts = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => {
|
|
|
|
|
|
const postType = getPostType();
|
|
|
|
|
|
const requestKey = `${postType}:${pageNum}`;
|
|
|
|
|
|
if (inFlightRequestKeysRef.current.has(requestKey)) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
inFlightRequestKeysRef.current.add(requestKey);
|
|
|
|
|
|
if (isRefresh) {
|
|
|
|
|
|
setRefreshing(true);
|
|
|
|
|
|
} else if (pageNum === 1) {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setLoadingMore(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetchPosts(postType, pageNum);
|
|
|
|
|
|
const newPosts = response.list || [];
|
|
|
|
|
|
|
|
|
|
|
|
if (isRefresh) {
|
|
|
|
|
|
setPosts(uniquePostsById(newPosts));
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
} else if (pageNum === 1) {
|
|
|
|
|
|
setPosts(uniquePostsById(newPosts));
|
|
|
|
|
|
setPage(1);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPosts(prev => appendUniquePosts(prev, newPosts));
|
|
|
|
|
|
setPage(pageNum);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const hasMoreByPage = response.total_pages > 0 ? response.page < response.total_pages : false;
|
|
|
|
|
|
const hasMoreBySize = newPosts.length >= (response.page_size || DEFAULT_PAGE_SIZE);
|
|
|
|
|
|
setHasMore(hasMoreByPage || hasMoreBySize);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load posts:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
inFlightRequestKeysRef.current.delete(requestKey);
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
setRefreshing(false);
|
|
|
|
|
|
setLoadingMore(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [fetchPosts, activeIndex, appendUniquePosts, uniquePostsById]);
|
|
|
|
|
|
|
|
|
|
|
|
// 切换Tab时重新加载
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadPosts(1, true);
|
|
|
|
|
|
}, [activeIndex]);
|
|
|
|
|
|
|
|
|
|
|
|
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (posts.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 更新 postIdsRef
|
|
|
|
|
|
const currentPostIds = new Set(posts.map(p => p.id));
|
|
|
|
|
|
postIdsRef.current = currentPostIds;
|
|
|
|
|
|
|
|
|
|
|
|
// 从 store 中找到对应的帖子并同步状态
|
|
|
|
|
|
let hasChanges = false;
|
|
|
|
|
|
const updatedPosts = posts.map(localPost => {
|
|
|
|
|
|
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
|
|
|
|
|
if (storePost && (
|
|
|
|
|
|
storePost.is_liked !== localPost.is_liked ||
|
|
|
|
|
|
storePost.is_favorited !== localPost.is_favorited ||
|
|
|
|
|
|
storePost.likes_count !== localPost.likes_count ||
|
|
|
|
|
|
storePost.favorites_count !== localPost.favorites_count
|
|
|
|
|
|
)) {
|
|
|
|
|
|
hasChanges = true;
|
|
|
|
|
|
return {
|
|
|
|
|
|
...localPost,
|
|
|
|
|
|
is_liked: storePost.is_liked,
|
|
|
|
|
|
is_favorited: storePost.is_favorited,
|
|
|
|
|
|
likes_count: storePost.likes_count,
|
|
|
|
|
|
favorites_count: storePost.favorites_count,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return localPost;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (hasChanges) {
|
|
|
|
|
|
setPosts(updatedPosts);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [storePosts]);
|
|
|
|
|
|
|
|
|
|
|
|
// 下拉刷新
|
|
|
|
|
|
const onRefresh = useCallback(() => {
|
|
|
|
|
|
loadPosts(1, true);
|
|
|
|
|
|
}, [loadPosts]);
|
|
|
|
|
|
|
|
|
|
|
|
// 上拉加载更多
|
|
|
|
|
|
const onEndReached = useCallback(() => {
|
|
|
|
|
|
if (!loadingMoreRef.current && hasMoreRef.current) {
|
|
|
|
|
|
loadPosts(pageRef.current + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [loadPosts]);
|
|
|
|
|
|
|
|
|
|
|
|
const onWaterfallScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
|
|
|
|
if (loadingMoreRef.current || !hasMoreRef.current) return;
|
|
|
|
|
|
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
|
|
|
|
|
const distanceToBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height);
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (distanceToBottom <= SCROLL_BOTTOM_THRESHOLD) {
|
|
|
|
|
|
if (now - lastLoadMoreTriggerAtRef.current < LOAD_MORE_COOLDOWN_MS) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
lastLoadMoreTriggerAtRef.current = now;
|
|
|
|
|
|
loadPosts(pageRef.current + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [loadPosts]);
|
|
|
|
|
|
|
|
|
|
|
|
// 切换视图模式
|
|
|
|
|
|
const toggleViewMode = () => {
|
|
|
|
|
|
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 切换Tab(手势/点击共用)
|
|
|
|
|
|
const changeTab = useCallback((nextIndex: number) => {
|
|
|
|
|
|
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setActiveIndex(nextIndex);
|
|
|
|
|
|
}, [activeIndex]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleSwipeTabChange = useCallback((translationX: number) => {
|
|
|
|
|
|
setActiveIndex(prev => (
|
|
|
|
|
|
translationX < 0
|
|
|
|
|
|
? Math.min(prev + 1, TABS.length - 1)
|
|
|
|
|
|
: Math.max(prev - 1, 0)
|
|
|
|
|
|
));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const swipeGesture = useMemo(() => (
|
|
|
|
|
|
Gesture.Pan()
|
|
|
|
|
|
.runOnJS(true)
|
|
|
|
|
|
.activeOffsetX([-15, 15])
|
|
|
|
|
|
.failOffsetY([-20, 20])
|
|
|
|
|
|
.onEnd((event) => {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Math.abs(event.translationX) < SWIPE_TRANSLATION_THRESHOLD) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lastSwipeAtRef.current = now;
|
|
|
|
|
|
handleSwipeTabChange(event.translationX);
|
|
|
|
|
|
})
|
|
|
|
|
|
), [handleSwipeTabChange]);
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到搜索页
|
|
|
|
|
|
const handleSearchPress = () => {
|
|
|
|
|
|
navigation.navigate('Search');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到帖子详情
|
|
|
|
|
|
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
|
|
|
|
|
navigation.getParent()?.navigate('PostDetail', { postId, scrollToComments });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到用户主页
|
|
|
|
|
|
const handleUserPress = (userId: string) => {
|
|
|
|
|
|
navigation.getParent()?.navigate('UserProfile', { userId });
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 点赞帖子
|
|
|
|
|
|
const handleLike = (post: Post) => {
|
|
|
|
|
|
if (post.is_liked) {
|
|
|
|
|
|
unlikePost(post.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
likePost(post.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 收藏帖子
|
|
|
|
|
|
const handleBookmark = (post: Post) => {
|
|
|
|
|
|
if (post.is_favorited) {
|
|
|
|
|
|
unfavoritePost(post.id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
favoritePost(post.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 分享帖子
|
|
|
|
|
|
const handleShare = (post: Post) => {
|
2026-03-09 22:18:47 +08:00
|
|
|
|
void post;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 删除帖子
|
|
|
|
|
|
const handleDeletePost = async (postId: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const success = await postService.deletePost(postId);
|
|
|
|
|
|
if (success) {
|
|
|
|
|
|
// 从列表中移除已删除的帖子
|
|
|
|
|
|
setPosts(prev => prev.filter(p => p.id !== postId));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('删除帖子失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('删除帖子失败:', error);
|
|
|
|
|
|
throw error; // 重新抛出错误,让 PostCard 处理错误提示
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理图片点击 - 打开图片查看器
|
|
|
|
|
|
const handleImagePress = (images: ImageGridItem[], index: number) => {
|
|
|
|
|
|
setPostImages(images);
|
|
|
|
|
|
setSelectedImageIndex(index);
|
|
|
|
|
|
setShowImageViewer(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 跳转到发帖页面
|
|
|
|
|
|
const handleCreatePost = () => {
|
|
|
|
|
|
navigation.getParent()?.navigate('CreatePost');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染帖子卡片(列表模式)
|
|
|
|
|
|
const renderPostList = ({ item }: { item: Post }) => {
|
|
|
|
|
|
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}
|
|
|
|
|
|
onPress={() => handlePostPress(item.id)}
|
|
|
|
|
|
onUserPress={() => authorId && handleUserPress(authorId)}
|
|
|
|
|
|
onLike={() => handleLike(item)}
|
|
|
|
|
|
onComment={() => handlePostPress(item.id, true)}
|
|
|
|
|
|
onBookmark={() => handleBookmark(item)}
|
|
|
|
|
|
onShare={() => handleShare(item)}
|
|
|
|
|
|
onImagePress={(images, index) => handleImagePress(images, index)}
|
|
|
|
|
|
onDelete={() => handleDeletePost(item.id)}
|
|
|
|
|
|
isPostAuthor={isPostAuthor}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 估算帖子在瀑布流中的高度(用于均匀分配)
|
|
|
|
|
|
const estimatePostHeight = (post: Post, columnWidth: number): number => {
|
|
|
|
|
|
const hasImage = post.images && post.images.length > 0;
|
|
|
|
|
|
const hasTitle = !!post.title;
|
|
|
|
|
|
const hasContent = !!post.content;
|
|
|
|
|
|
|
|
|
|
|
|
let height = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 图片区域高度(如果有图)
|
|
|
|
|
|
if (hasImage) {
|
|
|
|
|
|
// 使用帖子 ID 生成一致的宽高比
|
|
|
|
|
|
const hash = post.id.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
|
|
|
|
|
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
|
|
|
|
|
|
const aspectRatio = aspectRatios[hash % aspectRatios.length];
|
|
|
|
|
|
height += columnWidth / aspectRatio;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 无图帖子显示正文预览区域
|
|
|
|
|
|
if (hasContent) {
|
|
|
|
|
|
// 根据内容长度估算高度(每行约20像素,最多6行)
|
|
|
|
|
|
const contentLength = post.content?.length || 0;
|
|
|
|
|
|
const estimatedLines = Math.min(6, Math.max(3, Math.ceil(contentLength / 20)));
|
|
|
|
|
|
height += 16 + estimatedLines * 20; // padding + 文本高度
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 标题高度
|
|
|
|
|
|
if (hasTitle) {
|
|
|
|
|
|
const titleLines = hasImage ? 2 : 3;
|
|
|
|
|
|
height += 8 + titleLines * 20; // paddingTop + 文本高度
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 底部信息栏高度
|
|
|
|
|
|
height += 40; // 用户信息 + 点赞数
|
|
|
|
|
|
|
|
|
|
|
|
// 间距
|
|
|
|
|
|
height += 2; // marginBottom
|
|
|
|
|
|
|
|
|
|
|
|
return height;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 将帖子分成多列(瀑布流)- 使用贪心算法使各列高度尽量均匀
|
|
|
|
|
|
const distributePostsToColumns = useMemo(() => {
|
|
|
|
|
|
const columns: Post[][] = Array.from({ length: gridColumns }, () => []);
|
|
|
|
|
|
const columnHeights: number[] = Array(gridColumns).fill(0);
|
|
|
|
|
|
|
|
|
|
|
|
// 计算单列宽度
|
|
|
|
|
|
const totalGap = (gridColumns - 1) * responsiveGap;
|
|
|
|
|
|
const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns;
|
|
|
|
|
|
|
|
|
|
|
|
posts.forEach((post) => {
|
|
|
|
|
|
const postHeight = estimatePostHeight(post, columnWidth);
|
|
|
|
|
|
|
|
|
|
|
|
// 找到当前高度最小的列
|
|
|
|
|
|
const minHeightIndex = columnHeights.indexOf(Math.min(...columnHeights));
|
|
|
|
|
|
columns[minHeightIndex].push(post);
|
|
|
|
|
|
columnHeights[minHeightIndex] += postHeight;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return columns;
|
|
|
|
|
|
}, [posts, gridColumns, width, responsiveGap, responsivePadding]);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染单列帖子
|
|
|
|
|
|
const renderWaterfallColumn = (column: Post[], columnIndex: number) => (
|
|
|
|
|
|
<View key={`column-${columnIndex}`} style={[styles.waterfallColumn, { marginRight: columnIndex < gridColumns - 1 ? responsiveGap : 0 }]}>
|
|
|
|
|
|
{column.map(post => {
|
|
|
|
|
|
const authorId = post.author?.id || '';
|
|
|
|
|
|
const isPostAuthor = currentUser?.id === authorId;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<View key={post.id} style={[styles.waterfallItem, { marginBottom: responsiveGap }]}>
|
|
|
|
|
|
<PostCard
|
|
|
|
|
|
post={post}
|
|
|
|
|
|
variant="grid"
|
|
|
|
|
|
onPress={() => handlePostPress(post.id)}
|
|
|
|
|
|
onUserPress={() => authorId && handleUserPress(authorId)}
|
|
|
|
|
|
onLike={() => handleLike(post)}
|
|
|
|
|
|
onComment={() => handlePostPress(post.id, true)}
|
|
|
|
|
|
onBookmark={() => handleBookmark(post)}
|
|
|
|
|
|
onShare={() => handleShare(post)}
|
|
|
|
|
|
onImagePress={(images, index) => handleImagePress(images, index)}
|
|
|
|
|
|
onDelete={() => handleDeletePost(post.id)}
|
|
|
|
|
|
isPostAuthor={isPostAuthor}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染响应式网格布局(平板/桌面端使用多列)
|
|
|
|
|
|
const renderResponsiveGrid = () => {
|
|
|
|
|
|
if (isMobile && !isTablet) {
|
|
|
|
|
|
// 移动端使用瀑布流布局(2列)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ScrollView
|
|
|
|
|
|
style={styles.waterfallScroll}
|
|
|
|
|
|
contentContainerStyle={[
|
|
|
|
|
|
styles.waterfallContainer,
|
|
|
|
|
|
{
|
|
|
|
|
|
paddingHorizontal: responsivePadding,
|
|
|
|
|
|
paddingBottom: 80 + responsivePadding,
|
|
|
|
|
|
}
|
|
|
|
|
|
]}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
onScroll={onWaterfallScroll}
|
|
|
|
|
|
scrollEventThrottle={100}
|
|
|
|
|
|
refreshControl={
|
|
|
|
|
|
<RefreshControl
|
|
|
|
|
|
refreshing={refreshing}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
colors={[colors.primary.main]}
|
|
|
|
|
|
tintColor={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
|
|
|
|
|
|
</ScrollView>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 平板/桌面端使用 ResponsiveGrid 组件
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ResponsiveGrid
|
|
|
|
|
|
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
|
|
|
|
|
gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }}
|
|
|
|
|
|
containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{posts.map(post => {
|
|
|
|
|
|
const authorId = post.author?.id || '';
|
|
|
|
|
|
const isPostAuthor = currentUser?.id === authorId;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<PostCard
|
|
|
|
|
|
key={post.id}
|
|
|
|
|
|
post={post}
|
|
|
|
|
|
variant={viewMode === 'grid' ? 'grid' : 'default'}
|
|
|
|
|
|
onPress={() => handlePostPress(post.id)}
|
|
|
|
|
|
onUserPress={() => authorId && handleUserPress(authorId)}
|
|
|
|
|
|
onLike={() => handleLike(post)}
|
|
|
|
|
|
onComment={() => handlePostPress(post.id, true)}
|
|
|
|
|
|
onBookmark={() => handleBookmark(post)}
|
|
|
|
|
|
onShare={() => handleShare(post)}
|
|
|
|
|
|
onImagePress={(images, index) => handleImagePress(images, index)}
|
|
|
|
|
|
onDelete={() => handleDeletePost(post.id)}
|
|
|
|
|
|
isPostAuthor={isPostAuthor}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ResponsiveGrid>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染空状态
|
|
|
|
|
|
const renderEmpty = () => {
|
|
|
|
|
|
if (loading) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<EmptyState
|
|
|
|
|
|
title="暂无内容"
|
|
|
|
|
|
description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染列表内容
|
|
|
|
|
|
const renderListContent = () => {
|
|
|
|
|
|
if (useMultiColumnList) {
|
|
|
|
|
|
// 平板/桌面端使用多列网格
|
|
|
|
|
|
return renderResponsiveGrid();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 移动端和宽屏都使用单列 FlatList,宽屏下居中显示
|
|
|
|
|
|
return (
|
|
|
|
|
|
<FlatList
|
|
|
|
|
|
data={posts}
|
|
|
|
|
|
renderItem={renderPostList}
|
|
|
|
|
|
keyExtractor={item => item.id}
|
|
|
|
|
|
contentContainerStyle={[
|
|
|
|
|
|
styles.listContent,
|
|
|
|
|
|
{
|
|
|
|
|
|
paddingHorizontal: listHorizontalPadding,
|
|
|
|
|
|
paddingBottom: 80 + responsivePadding,
|
|
|
|
|
|
alignItems: 'center', // 确保子项居中
|
|
|
|
|
|
}
|
|
|
|
|
|
]}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
refreshControl={
|
|
|
|
|
|
<RefreshControl
|
|
|
|
|
|
refreshing={refreshing}
|
|
|
|
|
|
onRefresh={onRefresh}
|
|
|
|
|
|
colors={[colors.primary.main]}
|
|
|
|
|
|
tintColor={colors.primary.main}
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
onEndReached={onEndReached}
|
|
|
|
|
|
onEndReachedThreshold={0.3}
|
|
|
|
|
|
ListEmptyComponent={renderEmpty}
|
|
|
|
|
|
ListFooterComponent={loadingMore ? <Loading size="sm" /> : null}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
|
|
|
|
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} />
|
|
|
|
|
|
|
|
|
|
|
|
{/* 顶部Header */}
|
|
|
|
|
|
<View style={styles.header}>
|
|
|
|
|
|
{/* 搜索栏 */}
|
|
|
|
|
|
<View style={[styles.searchWrapper, { paddingHorizontal: responsivePadding }]}>
|
|
|
|
|
|
<SearchBar
|
|
|
|
|
|
value=""
|
|
|
|
|
|
onChangeText={() => {}}
|
|
|
|
|
|
onSubmit={handleSearchPress}
|
|
|
|
|
|
onFocus={handleSearchPress}
|
|
|
|
|
|
placeholder="搜索帖子、用户"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Tab切换 */}
|
|
|
|
|
|
<TabBar
|
|
|
|
|
|
tabs={TABS}
|
|
|
|
|
|
icons={TAB_ICONS}
|
|
|
|
|
|
activeIndex={activeIndex}
|
|
|
|
|
|
onTabChange={changeTab}
|
|
|
|
|
|
variant="modern"
|
|
|
|
|
|
rightContent={
|
|
|
|
|
|
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
|
|
|
|
|
|
<MaterialCommunityIcons
|
|
|
|
|
|
name={viewMode === 'list' ? 'view-grid-outline' : 'view-list'}
|
|
|
|
|
|
size={22}
|
|
|
|
|
|
color="#666"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 帖子列表 */}
|
|
|
|
|
|
<GestureDetector gesture={swipeGesture}>
|
|
|
|
|
|
<View style={styles.contentContainer}>
|
|
|
|
|
|
{viewMode === 'list' ? (
|
|
|
|
|
|
renderListContent()
|
|
|
|
|
|
) : (
|
|
|
|
|
|
// 网格模式:使用响应式网格
|
|
|
|
|
|
renderResponsiveGrid()
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
|
|
|
|
|
</GestureDetector>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 漂浮发帖按钮 */}
|
|
|
|
|
|
<TouchableOpacity
|
|
|
|
|
|
style={[
|
|
|
|
|
|
styles.floatingButton,
|
|
|
|
|
|
isDesktop && styles.floatingButtonDesktop,
|
|
|
|
|
|
isWideScreen && styles.floatingButtonWide,
|
|
|
|
|
|
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onPress={handleCreatePost}
|
|
|
|
|
|
activeOpacity={0.8}
|
|
|
|
|
|
>
|
|
|
|
|
|
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
|
|
|
|
|
|
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 图片查看器 */}
|
|
|
|
|
|
<ImageGallery
|
|
|
|
|
|
visible={showImageViewer}
|
|
|
|
|
|
images={postImages.map(img => ({
|
|
|
|
|
|
id: img.id || img.url || String(Math.random()),
|
|
|
|
|
|
url: img.url || img.uri || ''
|
|
|
|
|
|
}))}
|
|
|
|
|
|
initialIndex={selectedImageIndex}
|
|
|
|
|
|
onClose={() => setShowImageViewer(false)}
|
|
|
|
|
|
enableSave
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SafeAreaView>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const styles = StyleSheet.create({
|
|
|
|
|
|
container: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
backgroundColor: colors.background.default,
|
|
|
|
|
|
},
|
|
|
|
|
|
header: {
|
|
|
|
|
|
backgroundColor: colors.background.paper,
|
|
|
|
|
|
},
|
|
|
|
|
|
searchWrapper: {
|
|
|
|
|
|
paddingBottom: spacing.sm,
|
|
|
|
|
|
},
|
|
|
|
|
|
viewToggleBtn: {
|
|
|
|
|
|
width: 44,
|
|
|
|
|
|
height: 44,
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
},
|
|
|
|
|
|
listContent: {
|
|
|
|
|
|
flexGrow: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
contentContainer: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
listItem: {
|
|
|
|
|
|
// 动态设置 marginBottom
|
|
|
|
|
|
},
|
|
|
|
|
|
waterfallScroll: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
waterfallContainer: {
|
|
|
|
|
|
flexDirection: 'row',
|
|
|
|
|
|
flexGrow: 1,
|
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
|
},
|
|
|
|
|
|
waterfallColumn: {
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
},
|
|
|
|
|
|
waterfallItem: {
|
|
|
|
|
|
// 动态设置 marginBottom
|
|
|
|
|
|
},
|
|
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|