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

@@ -24,7 +24,7 @@ import { useUserStore } from '../../stores';
import { postService, authService } from '../../services';
import { tradeService } from '../../services/trade/tradeService';
import { postSyncService } from '@/services/post';
import { PostCard, SearchBar } from '../../components/business';
import { PostCard, SearchHeader, TabBar } from '../../components/business';
import { TradeCard } from '../../components/business/TradeCard/TradeCard';
import type { PostCardAction } from '../../components/business/PostCard';
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
@@ -601,66 +601,32 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, homeTab = 's
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* 搜索输入框 */}
<View
style={[
styles.searchHeader,
{
paddingTop: spacing.sm,
paddingHorizontal: responsivePadding,
paddingBottom: spacing.xs,
},
]}
>
<View style={[styles.searchShell, { maxWidth: searchBarMaxWidth }]}>
<SearchBar
value={searchText}
onChangeText={setSearchText}
onSubmit={handleSearch}
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
autoFocus
compact
/>
</View>
<TouchableOpacity
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
onPress={() => (onBack ? onBack() : router.back())}
activeOpacity={0.85}
>
<Text
variant="body"
color={colors.primary.main}
style={styles.cancelText}
>
</Text>
</TouchableOpacity>
</View>
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
<SearchHeader
value={searchText}
onChangeText={setSearchText}
onSubmit={handleSearch}
onCancel={onBack ? onBack : () => router.back()}
placeholder={isMarket ? "搜索商品、用户" : "搜索帖子、用户"}
compact
searchBarMaxWidth={searchBarMaxWidth}
horizontalPadding={responsivePadding}
/>
{/* Tab切换 - 与主页风格一致的下划线样式 */}
<View style={styles.tabWrapper}>
<View style={[styles.homeTabSwitcher, { paddingHorizontal: responsivePadding }]}>
{TABS.map((tab, index) => {
const isActive = activeIndex === index;
return (
<TouchableOpacity
key={tab}
activeOpacity={0.7}
style={[styles.homeTabItem, isActive && styles.homeTabItemActive]}
onPress={() => {
setActiveIndex(index);
if (currentKeyword && hasSearched) {
performSearch(currentKeyword);
}
}}
>
<Text style={isActive ? styles.homeTabTextActive : styles.homeTabText}>
{tab}
</Text>
</TouchableOpacity>
);
})}
</View>
<TabBar
tabs={TABS}
activeIndex={activeIndex}
onTabChange={(index) => {
setActiveIndex(index);
if (currentKeyword && hasSearched) {
performSearch(currentKeyword);
}
}}
variant="home"
style={{ paddingHorizontal: responsivePadding }}
/>
</View>
{/* 内容区域 */}
@@ -675,52 +641,10 @@ function createSearchScreenStyles(colors: AppColors) {
flex: 1,
backgroundColor: colors.background.default,
},
searchHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
},
searchShell: {
flex: 1,
},
cancelButton: {
borderRadius: borderRadius.full,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
cancelText: {
fontSize: fontSizes.md,
fontWeight: '600',
},
tabWrapper: {
backgroundColor: colors.background.default,
paddingVertical: spacing.xs,
},
homeTabSwitcher: {
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
homeTabItem: {
paddingVertical: spacing.sm,
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
homeTabItemActive: {
borderBottomColor: colors.text.primary,
},
homeTabText: {
fontSize: 18,
fontWeight: '400',
color: colors.text.hint,
},
homeTabTextActive: {
fontSize: 18,
fontWeight: '700',
color: colors.text.primary,
},
suggestionsContainer: {
flex: 1,
},

View File

@@ -214,6 +214,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
handleReachLatestEdge,
jumpToLatestMessages,
setBrowsingHistory,
clearHistoryLock,
} = useChatScreen(props);
const stableOnBack = useCallback(() => router.back(), [router]);
@@ -412,15 +413,12 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
setBrowsingHistory(true);
}
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current;
if (
isUserDraggingRef.current &&
!loadingMore &&
isScrollingTowardLatest &&
contentOffset.y <= 40
) {
handleReachLatestEdge();
// 接近最新消息端时退出历史阅读模式
// 修复:不再要求 isUserDraggingRef — 动量滚动结束后、layout settle 后、
// 或用户轻触回到底部时都能正确解锁,防止浏览历史锁永久为 true
// 导致新消息不自动跟随(仅自己发的才显示的问题)
if (!loadingMore && contentOffset.y <= 100) {
clearHistoryLock();
}
// inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小
@@ -460,14 +458,18 @@ export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
loading,
messages.length,
loadMoreHistory,
handleReachLatestEdge,
showEdgeLoadingIndicator,
setBrowsingHistory,
]);
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
handleMessageListContentSizeChange(contentWidth, contentHeight);
}, [handleMessageListContentSizeChange]);
// 安全检查loadMoreHistory 完成后,如果用户仍在底部附近,清除浏览历史锁
// 防止 layout settle 后没有触发滚动事件导致锁无法释放
if (!loadingMore && scrollPositionRef.current.scrollY <= 100) {
clearHistoryLock();
}
}, [handleMessageListContentSizeChange, loadingMore, clearHistoryLock]);
return (
<SafeAreaView style={containerStyle} edges={props.isEmbedded ? [] : ['top']}>

View File

@@ -61,7 +61,7 @@ import { ConversationListRow } from './components/ConversationListRow';
// 导入 NotificationsScreen 用于在内部显示
import { NotificationsScreen } from './NotificationsScreen';
// 导入扫码组件
import { SearchBar, TabBar } from '../../components/business';
import { SearchBar, SearchHeader, TabBar } from '../../components/business';
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
// 系统通知会话特殊ID
@@ -668,33 +668,27 @@ export const MessageListScreen: React.FC = () => {
// 渲染搜索模式UI扁平化现代化设计
const renderSearchMode = () => (
<View style={styles.searchModeContainer}>
{/* 搜索头部 */}
<View style={[styles.searchHeader, { paddingTop: spacing.sm }]}>
<View style={styles.searchShell}>
<SearchBar
value={searchText}
onChangeText={setSearchText}
onSubmit={() => performSearch(searchText)}
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
autoFocus
/>
</View>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleCloseSearch}
activeOpacity={0.7}
>
<Text style={styles.cancelText}></Text>
</TouchableOpacity>
</View>
{/* 搜索顶栏(统一组件,内置硬件返回键拦截) */}
<SearchHeader
value={searchText}
onChangeText={setSearchText}
onSubmit={() => performSearch(searchText)}
onCancel={handleCloseSearch}
placeholder={activeSearchTab === 'chat' ? '搜索聊天记录' : '搜索用户'}
compact
/>
{/* Tab切换 - 现代化风格 */}
{/* Tab切换 - 与主页一致的下划线风格 */}
<View style={styles.searchTabContainer}>
<TabBar
tabs={['聊天记录', '用户']}
activeIndex={activeSearchTab === 'chat' ? 0 : 1}
onTabChange={(index) => setActiveSearchTab(index === 0 ? 'chat' : 'user')}
variant="modern"
variant="home"
style={{
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
}}
/>
</View>
@@ -1048,32 +1042,10 @@ function createMessageListStyles(colors: AppColors) {
},
searchModeContainer: {
flex: 1,
backgroundColor: colors.background.paper,
},
searchHeader: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}70`,
paddingHorizontal: spacing.md,
paddingBottom: spacing.sm,
},
searchShell: {
flex: 1,
},
cancelButton: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
marginLeft: spacing.sm,
},
cancelText: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.primary.main,
backgroundColor: colors.background.default,
},
searchTabContainer: {
backgroundColor: colors.background.paper,
backgroundColor: colors.background.default,
borderBottomWidth: 1,
borderBottomColor: `${colors.divider}50`,
},

View File

@@ -17,7 +17,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import {
spacing,
fontSizes,
borderRadius,
useAppColors,
type AppColors,
} from '../../theme';
@@ -26,8 +25,8 @@ import { extractTextFromSegments, UserDTO } from '../../types/dto';
import { messageRepository } from '../../database';
import { userManager } from '../../stores/user';
import { useAuthStore } from '../../stores';
import { Avatar, Text, EmptyState, AppBackButton } from '../../components/common';
import { SearchBar } from '../../components/business';
import { Avatar, Text, EmptyState } from '../../components/common';
import { SearchHeader } from '../../components/business';
import HighlightText from '../../components/common/HighlightText';
import { formatTime } from '../../utils/formatTime';
import * as hrefs from '../../navigation/hrefs';
@@ -233,24 +232,16 @@ export const MessageSearchScreen: React.FC = () => {
}, [loading, results.length, styles, colors]);
return (
<SafeAreaView style={styles.container} edges={['bottom']}>
<View style={styles.header}>
<AppBackButton onPress={() => router.back()} />
<Text style={styles.headerTitle} numberOfLines={1}>
{conversationName}
</Text>
<View style={styles.headerSpacer} />
</View>
<View style={styles.searchWrap}>
<SearchBar
value={keyword}
onChangeText={handleTextChanged}
onSubmit={handleSubmit}
placeholder="搜索聊天记录"
autoFocus
/>
</View>
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* 搜索顶栏(与 HomeScreen 搜索页一致的统一组件) */}
<SearchHeader
value={keyword}
onChangeText={handleTextChanged}
onSubmit={handleSubmit}
onCancel={() => router.back()}
placeholder={`搜索 ${conversationName} 的聊天记录`}
compact
/>
<FlashList
data={results}
@@ -272,29 +263,6 @@ function createMessageSearchStyles(colors: AppColors) {
flex: 1,
backgroundColor: colors.background.default,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
},
headerTitle: {
flex: 1,
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
textAlign: 'center',
marginHorizontal: spacing.sm,
},
headerSpacer: {
width: 40,
},
searchWrap: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.paper,
},
resultItem: {
flexDirection: 'row',
alignItems: 'center',

View File

@@ -649,6 +649,17 @@ export const useChatScreen = (props?: ChatScreenProps) => {
isBrowsingHistoryRef.current = browsing;
}, []);
/**
* 无条件清除浏览历史锁与自动跟随抑制动量滚动结束、layout settle、
* 轻触回到底部等场景使用)。区别于 handleReachLatestEdge后者受加载锁保护。
*/
const clearHistoryLock = useCallback(() => {
if (isBrowsingHistoryRef.current || suppressAutoFollowRef.current) {
isBrowsingHistoryRef.current = false;
suppressAutoFollowRef.current = false;
}
}, []);
// 进入聊天详情后立即清未读(不依赖滚动位置)
useEffect(() => {
if (!conversationId) return;
@@ -1653,5 +1664,6 @@ export const useChatScreen = (props?: ChatScreenProps) => {
handleReachLatestEdge,
jumpToLatestMessages,
setBrowsingHistory,
clearHistoryLock,
};
};

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',