Files
frontend/src/screens/profile/FollowListScreen.tsx
lafay 705b365536
Some checks failed
Frontend CI / ota (android) (push) Successful in 3m11s
Frontend CI / ota (ios) (push) Successful in 3m31s
Frontend CI / build-and-push-web (push) Successful in 4m38s
Frontend CI / build-android-apk (push) Has been cancelled
refactor(core): distinguish network errors from auth failures and refactor real-time messaging pipeline
- Add isNetworkError() helper and FetchCurrentUserResult discriminated union to prevent logout on network failures
- Refactor authService to return { kind: 'user' } | { kind: 'auth_failed' } | { kind: 'network_error' }
- Update authStore to preserve login state on network errors, only logout on auth failures
- Replace WSMessageHandler with RealtimeIngestionPipeline for improved real-time message handling
- Remove MessageDeduplication service; add store-level idempotent addOrReplaceMessage for message dedup
- Add atomic systemUnreadCount operations to prevent race conditions
- Add SearchHeader component for consistent search UI across screens
- Add 'home' TabBar variant with underline style matching HomeScreen
- Add Jest test infrastructure and exclude tests from tsconfig
- Fix ChatScreen history lock being stuck when scrolling to latest messages
- Remove unused call_participant_joined/left WS event types
2026-06-21 17:17:15 +08:00

556 lines
16 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.
/**
* FollowListScreen 关注/粉丝列表页面(响应式适配)
* 显示用户的关注列表或粉丝列表
* 支持互关状态显示和关注/回关操作
* 在宽屏下使用网格布局
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
FlatList,
StyleSheet,
RefreshControl,
TouchableOpacity,
ListRenderItem,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { spacing, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { User } from '../../types';
import { useAuthStore, useUserStore } from '../../stores';
import { authService } from '../../services';
import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common';
import { SearchBar } from '../../components/business';
import * as hrefs from '../../navigation/hrefs';
import { useResponsive, useColumnCount } from '../../hooks';
const FollowListScreen: React.FC = () => {
const colors = useAppColors();
const styles = useMemo(() => createFollowListStyles(colors), [colors]);
const router = useRouter();
const { userId = '', type: typeParam } = useLocalSearchParams<{ userId?: string; type?: string }>();
const type = typeParam === 'followers' ? 'followers' : 'following';
const currentUser = useAuthStore((s) => s.currentUser);
const { followUser, unfollowUser } = useUserStore.getState();
// 响应式布局
const { isWideScreen, isDesktop, width } = useResponsive();
const columnCount = useColumnCount({
xs: 1,
sm: 1,
md: 2,
lg: 2,
xl: 3,
'2xl': 3,
'3xl': 4,
'4xl': 4,
});
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isCurrentUser = currentUser?.id === userId;
const title = type === 'following' ? '关注' : '粉丝';
const isSearching = debouncedKeyword.trim().length > 0;
// 加载用户列表
const loadUsers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
if (!hasMore && !refresh) return;
try {
const pageSize = 20;
let userList: User[] = [];
const trimmedKeyword = debouncedKeyword.trim();
if (type === 'following') {
userList = await authService.getFollowingList(userId, pageNum, pageSize, trimmedKeyword);
} else {
userList = await authService.getFollowersList(userId, pageNum, pageSize, trimmedKeyword);
}
if (refresh) {
setUsers(userList);
setPage(1);
} else {
setUsers(prev => [...prev, ...userList]);
}
setHasMore(userList.length === pageSize);
} catch (error) {
console.error('加载用户列表失败:', error);
}
setLoading(false);
setRefreshing(false);
}, [userId, type, hasMore, debouncedKeyword]);
// 初始加载(包括 type/userId 切换以及防抖后关键词变化)
useEffect(() => {
setLoading(true);
setHasMore(true);
loadUsers(1, true);
// 仅在 userId/type/debouncedKeyword 变化时重新加载
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, type, debouncedKeyword]);
// 关键词输入防抖300ms
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
setDebouncedKeyword(keyword);
}, 300);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = null;
}
};
}, [keyword]);
// 下拉刷新
const onRefresh = useCallback(() => {
setRefreshing(true);
setHasMore(true);
loadUsers(1, true);
}, [loadUsers]);
// 加载更多
const loadMore = useCallback(() => {
if (!loading && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
loadUsers(nextPage);
}
}, [loading, hasMore, page, loadUsers]);
// 关注/取消关注
const handleFollowToggle = async (user: User) => {
const isFollowing = user.is_following ?? false;
// 乐观更新
setUsers(prev => prev.map(u => {
if (u.id === user.id) {
return {
...u,
is_following: !isFollowing,
followers_count: isFollowing ? u.followers_count - 1 : u.followers_count + 1,
};
}
return u;
}));
if (isFollowing) {
await unfollowUser(user.id);
} else {
await followUser(user.id);
}
};
// 跳转到用户主页
const handleUserPress = (targetUserId: string) => {
if (targetUserId !== currentUser?.id) {
router.push(hrefs.hrefUserProfile(targetUserId));
}
};
// 获取按钮状态
const getButtonConfig = (user: User): { title: string; variant: 'primary' | 'outline' } => {
const isFollowing = user.is_following ?? false;
const isFollowingMe = user.is_following_me ?? false;
if (isFollowing && isFollowingMe) {
// 已互关
return { title: '互相关注', variant: 'outline' };
} else if (isFollowing) {
// 已关注但对方未回关
return { title: '已关注', variant: 'outline' };
} else if (isFollowingMe) {
// 对方关注了我,但我没关注对方
return { title: '回关', variant: 'primary' };
} else {
// 互不关注
return { title: '关注', variant: 'primary' };
}
};
// 渲染用户项 - 列表模式(移动端)
const renderUserListItem: ListRenderItem<User> = ({ item }) => {
const buttonConfig = getButtonConfig(item);
const isSelf = item.id === currentUser?.id;
return (
<TouchableOpacity
style={styles.userItem}
onPress={() => handleUserPress(item.id)}
activeOpacity={0.7}
>
<Avatar
source={item.avatar}
size={52}
name={item.nickname}
/>
<View style={styles.userInfo}>
<Text variant="body" style={styles.nickname} numberOfLines={1}>
{item.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
@{item.username}
</Text>
{item.bio?.trim() ? (
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.bio}>
{item.bio}
</Text>
) : null}
</View>
{isCurrentUser && !isSelf && (
<Button
title={buttonConfig.title}
variant={buttonConfig.variant}
size="sm"
onPress={() => handleFollowToggle(item)}
style={styles.followButton}
/>
)}
</TouchableOpacity>
);
};
// 渲染用户卡片 - 网格模式(宽屏)
const renderUserGridItem: ListRenderItem<User> = ({ item }) => {
const buttonConfig = getButtonConfig(item);
const isSelf = item.id === currentUser?.id;
return (
<TouchableOpacity
style={styles.userCard}
onPress={() => handleUserPress(item.id)}
activeOpacity={0.7}
>
<View style={styles.userCardHeader}>
<Avatar
source={item.avatar}
size={56}
name={item.nickname}
/>
</View>
<View style={styles.userCardContent}>
<Text variant="body" style={styles.userCardNickname} numberOfLines={1}>
{item.nickname}
</Text>
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
@{item.username}
</Text>
{item.bio?.trim() ? (
<Text variant="caption" color={colors.text.hint} numberOfLines={2} style={styles.userCardBio}>
{item.bio}
</Text>
) : null}
</View>
{isCurrentUser && !isSelf && (
<View style={styles.userCardFooter}>
<Button
title={buttonConfig.title}
variant={buttonConfig.variant}
size="sm"
onPress={() => handleFollowToggle(item)}
style={styles.userCardFollowButton}
/>
</View>
)}
</TouchableOpacity>
);
};
// 渲染空状态
const renderEmpty = () => {
if (loading) return <Loading />;
if (isSearching) {
return (
<EmptyState
title="没有找到匹配的用户"
description="换个关键词试试"
icon="account-search-outline"
variant="modern"
/>
);
}
const emptyText = type === 'following'
? '还没有关注任何人'
: '还没有粉丝';
const emptyDesc = type === 'following'
? '去发现更多有趣的用户吧'
: '发布更多优质内容来吸引粉丝吧';
return (
<EmptyState
title={emptyText}
description={emptyDesc}
icon={type === 'following' ? 'account-plus-outline' : 'account-group-outline'}
variant="modern"
/>
);
};
const renderListHeader = () => (
<View style={styles.headerCard}>
<View style={styles.headerAccent} />
<View style={styles.headerMainRow}>
<Text variant="h2" style={styles.headerTitle}>{title}</Text>
{!isSearching ? (
<View style={styles.headerCountPill}>
<Text variant="caption" color={colors.text.secondary}>
{users.length}
</Text>
</View>
) : (
<View style={styles.headerCountPill}>
<Text variant="caption" color={colors.text.secondary}>
</Text>
</View>
)}
</View>
<Text variant="caption" color={colors.text.secondary} style={styles.headerSubtitle}>
{type === 'following'
? (isCurrentUser ? '你已关注的用户' : 'TA关注的用户')
: (isCurrentUser ? '关注你的用户' : 'TA的粉丝')}
</Text>
<View style={styles.headerSearchWrap}>
<SearchBar
value={keyword}
onChangeText={setKeyword}
onSubmit={() => setDebouncedKeyword(keyword)}
placeholder={type === 'following' ? '搜索关注' : '搜索粉丝'}
compact
/>
</View>
</View>
);
const renderFooter = () => {
if (!hasMore || loading || users.length === 0) return null;
return (
<View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary.main} />
<Text variant="caption" color={colors.text.secondary} style={styles.footerLoadingText}>
</Text>
</View>
);
};
// 宽屏使用网格布局
if (isWideScreen && columnCount > 1) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<ResponsiveContainer maxWidth={1200}>
<FlatList
data={users}
renderItem={renderUserGridItem}
keyExtractor={item => item.id}
key={`grid-${columnCount}`}
numColumns={columnCount}
contentContainerStyle={[
styles.gridContent,
users.length === 0 && styles.emptyGridContent,
]}
columnWrapperStyle={styles.gridRow}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListHeaderComponent={renderListHeader}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
showsVerticalScrollIndicator={false}
/>
</ResponsiveContainer>
</SafeAreaView>
);
}
// 移动端使用列表布局
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<FlatList
data={users}
renderItem={renderUserListItem}
keyExtractor={item => item.id}
contentContainerStyle={styles.listContent}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
colors={[colors.primary.main]}
tintColor={colors.primary.main}
/>
}
onEndReached={loadMore}
onEndReachedThreshold={0.3}
ListHeaderComponent={renderListHeader}
ListEmptyComponent={renderEmpty}
ListFooterComponent={renderFooter}
showsVerticalScrollIndicator={false}
/>
</SafeAreaView>
);
};
function createFollowListStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
// 列表布局样式
listContent: {
flexGrow: 1,
paddingHorizontal: spacing.md,
paddingTop: spacing.md,
paddingBottom: spacing.lg,
},
headerCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: colors.divider + '4A',
...shadows.sm,
},
headerAccent: {
width: 28,
height: 3,
borderRadius: 999,
backgroundColor: colors.primary.main + 'A6',
marginBottom: spacing.sm,
},
headerMainRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: {
fontWeight: '700',
},
headerCountPill: {
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: borderRadius.full,
backgroundColor: colors.background.default,
},
headerSubtitle: {
marginTop: spacing.xs,
},
headerSearchWrap: {
marginTop: spacing.md,
},
userItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.md,
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: colors.divider + '45',
...shadows.sm,
},
userInfo: {
flex: 1,
marginLeft: spacing.md,
marginRight: spacing.sm,
},
nickname: {
fontWeight: '600',
marginBottom: 2,
},
bio: {
marginTop: 2,
},
followButton: {
minWidth: 82,
},
// 网格布局样式
gridContent: {
flexGrow: 1,
padding: spacing.lg,
},
emptyGridContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
gridRow: {
justifyContent: 'flex-start',
gap: spacing.md,
marginBottom: spacing.md,
},
userCard: {
flex: 1,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
padding: spacing.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.divider + '45',
...shadows.sm,
},
userCardHeader: {
marginBottom: spacing.sm,
},
userCardContent: {
alignItems: 'center',
width: '100%',
},
userCardNickname: {
fontWeight: '600',
marginBottom: 2,
textAlign: 'center',
},
userCardBio: {
marginTop: spacing.xs,
textAlign: 'center',
},
userCardFooter: {
marginTop: spacing.md,
width: '100%',
},
userCardFollowButton: {
width: '100%',
},
footerLoading: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
gap: spacing.xs,
},
footerLoadingText: {
marginLeft: spacing.xs,
},
});
}
export default FollowListScreen;