refactor(core): distinguish network errors from auth failures and refactor real-time messaging pipeline
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

- 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
This commit is contained in:
lafay
2026-06-21 17:17:15 +08:00
parent 94a202506b
commit 705b365536
47 changed files with 4357 additions and 1574 deletions

View File

@@ -5,7 +5,7 @@
* 在宽屏下使用网格布局
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
View,
FlatList,
@@ -22,6 +22,7 @@ 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';
@@ -53,9 +54,13 @@ const FollowListScreen: React.FC = () => {
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) => {
@@ -64,11 +69,12 @@ const FollowListScreen: React.FC = () => {
try {
const pageSize = 20;
let userList: User[] = [];
const trimmedKeyword = debouncedKeyword.trim();
if (type === 'following') {
userList = await authService.getFollowingList(userId, pageNum, pageSize);
userList = await authService.getFollowingList(userId, pageNum, pageSize, trimmedKeyword);
} else {
userList = await authService.getFollowersList(userId, pageNum, pageSize);
userList = await authService.getFollowersList(userId, pageNum, pageSize, trimmedKeyword);
}
if (refresh) {
@@ -84,12 +90,33 @@ const FollowListScreen: React.FC = () => {
}
setLoading(false);
setRefreshing(false);
}, [userId, type, hasMore]);
}, [userId, type, hasMore, debouncedKeyword]);
// 初始加载
// 初始加载(包括 type/userId 切换以及防抖后关键词变化)
useEffect(() => {
setLoading(true);
setHasMore(true);
loadUsers(1, true);
}, [userId, type]);
// 仅在 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(() => {
@@ -248,7 +275,18 @@ const FollowListScreen: React.FC = () => {
// 渲染空状态
const renderEmpty = () => {
if (loading) return <Loading />;
if (isSearching) {
return (
<EmptyState
title="没有找到匹配的用户"
description="换个关键词试试"
icon="account-search-outline"
variant="modern"
/>
);
}
const emptyText = type === 'following'
? '还没有关注任何人'
: '还没有粉丝';
@@ -271,17 +309,34 @@ const FollowListScreen: React.FC = () => {
<View style={styles.headerAccent} />
<View style={styles.headerMainRow}>
<Text variant="h2" style={styles.headerTitle}>{title}</Text>
<View style={styles.headerCountPill}>
<Text variant="caption" color={colors.text.secondary}>
{users.length}
</Text>
</View>
{!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>
);
@@ -407,6 +462,9 @@ function createFollowListStyles(colors: AppColors) {
headerSubtitle: {
marginTop: spacing.xs,
},
headerSearchWrap: {
marginTop: spacing.md,
},
userItem: {
flexDirection: 'row',
alignItems: 'center',