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;
+}