From db4ee293d2503f97a32088136938a429ae71980a Mon Sep 17 00:00:00 2001 From: Seven Date: Mon, 9 Mar 2026 08:25:51 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=A1=B5=E9=9D=A2=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E4=BC=9A=E8=AF=9DID=E5=92=8C=E6=A0=87=E9=A2=98=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E4=BC=98=E5=8C=96=E4=BC=9A=E8=AF=9D=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=92=8C=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 66 +++++++++++++++++-- app/chat/[conversationId].tsx | 48 ++++++++++++++ .../im/components/conversation-header.tsx | 2 +- features/im/components/conversation-list.tsx | 42 +++++++----- .../im/components/conversation-search-bar.tsx | 48 +++++++++++++- features/im/hooks/use-conversation-list.ts | 33 ++++++++-- features/im/hooks/use-debounced-value.ts | 17 +++++ 7 files changed, 225 insertions(+), 31 deletions(-) create mode 100644 app/chat/[conversationId].tsx create mode 100644 features/im/hooks/use-debounced-value.ts diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 0b18fbe..8e9b9da 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,11 +1,13 @@ import { StatusBar } from "expo-status-bar"; import { useMemo, useState } from "react"; -import { StyleSheet, View } from "react-native"; +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { useRouter } from "expo-router"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ConversationHeader } from "@/features/im/components/conversation-header"; import { ConversationList } from "@/features/im/components/conversation-list"; import { ConversationSearchBar } from "@/features/im/components/conversation-search-bar"; +import { useDebouncedValue } from "@/features/im/hooks/use-debounced-value"; import { useConversationList } from "@/features/im/hooks/use-conversation-list"; import { useConversationSearch } from "@/features/im/hooks/use-conversation-search"; import { mockConversations } from "@/features/im/mocks/mock-conversations"; @@ -20,16 +22,18 @@ const SYSTEM_MESSAGE_ITEM: ConversationItem = { }; export default function IndexRoute() { + const router = useRouter(); const insets = useSafeAreaInsets(); const [keyword, setKeyword] = useState(""); - const { items, isLoading, isLoggedIn } = useConversationList(); + const debouncedKeyword = useDebouncedValue(keyword, 180); + const { items, isLoading, isRefreshing, isLoggedIn, error, reload } = useConversationList(); const sourceItems = isLoggedIn ? items : mockConversations; const conversationItems = useMemo( () => [SYSTEM_MESSAGE_ITEM, ...sourceItems.filter((item) => item.kind !== "system")], [sourceItems] ); - const filteredConversations = useConversationSearch(conversationItems, keyword); + const filteredConversations = useConversationSearch(conversationItems, debouncedKeyword); const unreadTotal = useMemo(() => { const total = sourceItems.reduce((sum, item) => sum + (item.unreadCount ?? 0), 0); if (total <= 0) { @@ -48,7 +52,36 @@ export default function IndexRoute() { - + {isLoggedIn && !!error && ( + + + {error} + + + 重试 + + + )} + + { + if (item.kind === "system") { + return; + } + + router.push({ + pathname: "/chat/[conversationId]", + params: { + conversationId: item.id, + title: item.title, + }, + }); + }} + /> ); @@ -69,4 +102,29 @@ const styles = StyleSheet.create({ borderTopWidth: StyleSheet.hairlineWidth, flex: 1, }, + errorBanner: { + alignItems: "center", + backgroundColor: "#F9FFFC", + borderBottomColor: "#DCF6EB", + borderBottomWidth: StyleSheet.hairlineWidth, + flexDirection: "row", + gap: 8, + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 10, + }, + errorText: { + color: "#3D4A45", + flex: 1, + fontSize: 13, + }, + errorAction: { + paddingHorizontal: 8, + paddingVertical: 2, + }, + errorActionText: { + color: "#00B97A", + fontSize: 13, + fontWeight: "600", + }, }); \ No newline at end of file diff --git a/app/chat/[conversationId].tsx b/app/chat/[conversationId].tsx new file mode 100644 index 0000000..ed357e1 --- /dev/null +++ b/app/chat/[conversationId].tsx @@ -0,0 +1,48 @@ +import { Stack, useLocalSearchParams } from "expo-router"; +import { StyleSheet, Text, View } from "react-native"; + +export default function ChatConversationRoute() { + const params = useLocalSearchParams<{ conversationId?: string; title?: string }>(); + + return ( + + + + + 聊天页骨架 + + + 会话 ID: {params.conversationId || "-"} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + alignItems: "center", + backgroundColor: "#F7F7F8", + flex: 1, + justifyContent: "center", + paddingHorizontal: 20, + }, + card: { + backgroundColor: "#FFFFFF", + borderCurve: "continuous", + borderRadius: 20, + gap: 8, + paddingHorizontal: 18, + paddingVertical: 16, + width: "100%", + }, + title: { + color: "#1F2328", + fontSize: 18, + fontWeight: "700", + }, + subTitle: { + color: "#6C737F", + fontSize: 14, + }, +}); diff --git a/features/im/components/conversation-header.tsx b/features/im/components/conversation-header.tsx index b8c2371..68eddbc 100644 --- a/features/im/components/conversation-header.tsx +++ b/features/im/components/conversation-header.tsx @@ -19,7 +19,7 @@ export function ConversationHeader({ onPress={onPressCreate} style={({ pressed }) => [styles.addButton, pressed && styles.addButtonPressed]} > - + ); diff --git a/features/im/components/conversation-list.tsx b/features/im/components/conversation-list.tsx index 8cf9ed5..0c54503 100644 --- a/features/im/components/conversation-list.tsx +++ b/features/im/components/conversation-list.tsx @@ -1,5 +1,5 @@ import { FlashList } from "@shopify/flash-list"; -import { StyleSheet, Text, View } from "react-native"; +import { RefreshControl, StyleSheet, Text, View } from "react-native"; import { ConversationListItem } from "@/features/im/components/conversation-list-item"; import type { ConversationItem } from "@/features/im/types/conversation"; @@ -7,40 +7,45 @@ import type { ConversationItem } from "@/features/im/types/conversation"; type ConversationListProps = { data: ConversationItem[]; isLoading?: boolean; + isRefreshing?: boolean; + onRefresh?: () => void; onPressItem?: (item: ConversationItem) => void; }; export function ConversationList({ data, isLoading = false, + isRefreshing = false, + onRefresh, onPressItem, }: ConversationListProps) { - if (isLoading) { - return ( - - 会话加载中... - - ); - } - - if (data.length === 0) { - return ( - - 暂无会话 - 试试更换关键字 - - ); - } - return ( item.id} + ListEmptyComponent={ + + {isLoading ? "会话加载中..." : "暂无会话"} + {!isLoading && 试试更换关键字} + + } + onRefresh={onRefresh} + refreshControl={ + + } renderItem={({ item }) => ( )} + refreshing={isRefreshing} showsVerticalScrollIndicator={false} /> ); @@ -48,6 +53,7 @@ export function ConversationList({ const styles = StyleSheet.create({ contentContainer: { + flexGrow: 1, paddingBottom: 28, paddingTop: 16, }, diff --git a/features/im/components/conversation-search-bar.tsx b/features/im/components/conversation-search-bar.tsx index a202520..3552407 100644 --- a/features/im/components/conversation-search-bar.tsx +++ b/features/im/components/conversation-search-bar.tsx @@ -1,5 +1,6 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons"; -import { StyleSheet, TextInput, View } from "react-native"; +import { useRef, useState } from "react"; +import { Animated, Easing, StyleSheet, TextInput } from "react-native"; type ConversationSearchBarProps = { value: string; @@ -10,17 +11,52 @@ export function ConversationSearchBar({ value, onChangeText, }: ConversationSearchBarProps) { + const [isFocused, setIsFocused] = useState(false); + const focusAnim = useRef(new Animated.Value(0)).current; + + const animateFocus = (toValue: 0 | 1) => { + Animated.timing(focusAnim, { + toValue, + duration: 140, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(); + }; + return ( - + { + setIsFocused(false); + animateFocus(0); + }} onChangeText={onChangeText} + onFocus={() => { + setIsFocused(true); + animateFocus(1); + }} placeholder="搜索联系人备注/昵称/ID" placeholderTextColor="#B8B8BC" style={styles.input} value={value} /> - + ); } @@ -28,7 +64,9 @@ const styles = StyleSheet.create({ wrapper: { alignItems: "center", backgroundColor: "#F0F0F2", + borderColor: "transparent", borderCurve: "continuous", + borderWidth: 1, borderRadius: 20, flexDirection: "row", gap: 8, @@ -36,6 +74,10 @@ const styles = StyleSheet.create({ marginHorizontal: 20, paddingHorizontal: 16, }, + wrapperFocused: { + backgroundColor: "#F4FAF8", + borderColor: "#BEEEDB", + }, input: { color: "#2A2A2A", flex: 1, diff --git a/features/im/hooks/use-conversation-list.ts b/features/im/hooks/use-conversation-list.ts index 588d1c7..3108d38 100644 --- a/features/im/hooks/use-conversation-list.ts +++ b/features/im/hooks/use-conversation-list.ts @@ -120,10 +120,19 @@ function toViewModel(item: OpenIMConversationItem): ConversationItem { export function useConversationList() { const [conversations, setConversations] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false); const [error, setError] = useState(null); - const reload = useCallback(async () => { + const loadConversations = useCallback(async (mode: "initial" | "refresh" | "silent") => { + const startedAt = Date.now(); + if (mode === "initial") { + setIsLoading(true); + } + if (mode === "refresh") { + setIsRefreshing(true); + } + try { setError(null); const status = await OpenIMSDK.getLoginStatus(); @@ -141,13 +150,26 @@ export function useConversationList() { const message = err instanceof Error ? err.message : "拉取会话失败"; setError(message); } finally { + if (mode === "refresh") { + const elapsed = Date.now() - startedAt; + const minVisibleMs = 420; + if (elapsed < minVisibleMs) { + await new Promise((resolve) => setTimeout(resolve, minVisibleMs - elapsed)); + } + } + setIsLoading(false); + setIsRefreshing(false); } }, []); + const reload = useCallback(async () => { + await loadConversations("refresh"); + }, [loadConversations]); + useEffect(() => { - reload(); - }, [reload]); + loadConversations("initial"); + }, [loadConversations]); useEffect(() => { const onConversationChanged = (changed: OpenIMConversationItem[]) => { @@ -159,7 +181,7 @@ export function useConversationList() { }; const onRecvNewMessages = () => { - reload(); + loadConversations("silent"); }; OpenIMSDK.on(OpenIMEvent.OnConversationChanged, onConversationChanged); @@ -171,13 +193,14 @@ export function useConversationList() { OpenIMSDK.off(OpenIMEvent.OnNewConversation, onNewConversation); OpenIMSDK.off(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages); }; - }, [reload]); + }, [loadConversations]); const items = useMemo(() => conversations.map(toViewModel), [conversations]); return { items, isLoading, + isRefreshing, isLoggedIn, error, reload, diff --git a/features/im/hooks/use-debounced-value.ts b/features/im/hooks/use-debounced-value.ts new file mode 100644 index 0000000..c06245d --- /dev/null +++ b/features/im/hooks/use-debounced-value.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delay = 180) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [delay, value]); + + return debouncedValue; +}