feat: 添加聊天会话页面,支持会话ID和标题显示,优化会话列表和搜索功能

This commit is contained in:
2026-03-09 08:25:51 +07:00
parent 212b3fa8de
commit db4ee293d2
7 changed files with 225 additions and 31 deletions

View File

@@ -1,11 +1,13 @@
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { useMemo, useState } from "react"; 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { ConversationHeader } from "@/features/im/components/conversation-header"; import { ConversationHeader } from "@/features/im/components/conversation-header";
import { ConversationList } from "@/features/im/components/conversation-list"; import { ConversationList } from "@/features/im/components/conversation-list";
import { ConversationSearchBar } from "@/features/im/components/conversation-search-bar"; 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 { useConversationList } from "@/features/im/hooks/use-conversation-list";
import { useConversationSearch } from "@/features/im/hooks/use-conversation-search"; import { useConversationSearch } from "@/features/im/hooks/use-conversation-search";
import { mockConversations } from "@/features/im/mocks/mock-conversations"; import { mockConversations } from "@/features/im/mocks/mock-conversations";
@@ -20,16 +22,18 @@ const SYSTEM_MESSAGE_ITEM: ConversationItem = {
}; };
export default function IndexRoute() { export default function IndexRoute() {
const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [keyword, setKeyword] = useState(""); 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 sourceItems = isLoggedIn ? items : mockConversations;
const conversationItems = useMemo( const conversationItems = useMemo(
() => [SYSTEM_MESSAGE_ITEM, ...sourceItems.filter((item) => item.kind !== "system")], () => [SYSTEM_MESSAGE_ITEM, ...sourceItems.filter((item) => item.kind !== "system")],
[sourceItems] [sourceItems]
); );
const filteredConversations = useConversationSearch(conversationItems, keyword); const filteredConversations = useConversationSearch(conversationItems, debouncedKeyword);
const unreadTotal = useMemo(() => { const unreadTotal = useMemo(() => {
const total = sourceItems.reduce((sum, item) => sum + (item.unreadCount ?? 0), 0); const total = sourceItems.reduce((sum, item) => sum + (item.unreadCount ?? 0), 0);
if (total <= 0) { if (total <= 0) {
@@ -48,7 +52,36 @@ export default function IndexRoute() {
</View> </View>
<View style={styles.listBlock}> <View style={styles.listBlock}>
<ConversationList data={filteredConversations} isLoading={isLoading && isLoggedIn} /> {isLoggedIn && !!error && (
<View style={styles.errorBanner}>
<Text numberOfLines={1} style={styles.errorText}>
{error}
</Text>
<Pressable onPress={reload} style={styles.errorAction}>
<Text style={styles.errorActionText}></Text>
</Pressable>
</View>
)}
<ConversationList
data={filteredConversations}
isLoading={isLoading && isLoggedIn}
isRefreshing={isRefreshing}
onRefresh={reload}
onPressItem={(item) => {
if (item.kind === "system") {
return;
}
router.push({
pathname: "/chat/[conversationId]",
params: {
conversationId: item.id,
title: item.title,
},
});
}}
/>
</View> </View>
</View> </View>
); );
@@ -69,4 +102,29 @@ const styles = StyleSheet.create({
borderTopWidth: StyleSheet.hairlineWidth, borderTopWidth: StyleSheet.hairlineWidth,
flex: 1, 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",
},
}); });

View File

@@ -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 (
<View style={styles.screen}>
<Stack.Screen options={{ title: params.title || "会话" }} />
<View style={styles.card}>
<Text selectable style={styles.title}>
</Text>
<Text selectable style={styles.subTitle}>
ID: {params.conversationId || "-"}
</Text>
</View>
</View>
);
}
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,
},
});

View File

@@ -19,7 +19,7 @@ export function ConversationHeader({
onPress={onPressCreate} onPress={onPressCreate}
style={({ pressed }) => [styles.addButton, pressed && styles.addButtonPressed]} style={({ pressed }) => [styles.addButton, pressed && styles.addButtonPressed]}
> >
<MaterialIcons color="#666666" name="add-circle-outline" size={36} /> <MaterialIcons color="#666666" name="add-circle-outline" size={30} />
</Pressable> </Pressable>
</View> </View>
); );

View File

@@ -1,5 +1,5 @@
import { FlashList } from "@shopify/flash-list"; 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 { ConversationListItem } from "@/features/im/components/conversation-list-item";
import type { ConversationItem } from "@/features/im/types/conversation"; import type { ConversationItem } from "@/features/im/types/conversation";
@@ -7,40 +7,45 @@ import type { ConversationItem } from "@/features/im/types/conversation";
type ConversationListProps = { type ConversationListProps = {
data: ConversationItem[]; data: ConversationItem[];
isLoading?: boolean; isLoading?: boolean;
isRefreshing?: boolean;
onRefresh?: () => void;
onPressItem?: (item: ConversationItem) => void; onPressItem?: (item: ConversationItem) => void;
}; };
export function ConversationList({ export function ConversationList({
data, data,
isLoading = false, isLoading = false,
isRefreshing = false,
onRefresh,
onPressItem, onPressItem,
}: ConversationListProps) { }: ConversationListProps) {
if (isLoading) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>...</Text>
</View>
);
}
if (data.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.emptyHint}></Text>
</View>
);
}
return ( return (
<FlashList <FlashList
alwaysBounceVertical
bounces
contentInsetAdjustmentBehavior="automatic" contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
data={data} data={data}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Text style={styles.emptyTitle}>{isLoading ? "会话加载中..." : "暂无会话"}</Text>
{!isLoading && <Text style={styles.emptyHint}></Text>}
</View>
}
onRefresh={onRefresh}
refreshControl={
<RefreshControl
colors={["#00D48C"]}
onRefresh={onRefresh}
refreshing={isRefreshing}
tintColor="#00D48C"
/>
}
renderItem={({ item }) => ( renderItem={({ item }) => (
<ConversationListItem item={item} onPress={onPressItem} /> <ConversationListItem item={item} onPress={onPressItem} />
)} )}
refreshing={isRefreshing}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
/> />
); );
@@ -48,6 +53,7 @@ export function ConversationList({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
contentContainer: { contentContainer: {
flexGrow: 1,
paddingBottom: 28, paddingBottom: 28,
paddingTop: 16, paddingTop: 16,
}, },

View File

@@ -1,5 +1,6 @@
import MaterialIcons from "@expo/vector-icons/MaterialIcons"; 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 = { type ConversationSearchBarProps = {
value: string; value: string;
@@ -10,17 +11,52 @@ export function ConversationSearchBar({
value, value,
onChangeText, onChangeText,
}: ConversationSearchBarProps) { }: 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 ( return (
<View style={styles.wrapper}> <Animated.View
style={[
styles.wrapper,
isFocused && styles.wrapperFocused,
{
transform: [
{
scale: focusAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.01],
}),
},
],
},
]}
>
<MaterialIcons color="#A2A2A7" name="search" size={28} /> <MaterialIcons color="#A2A2A7" name="search" size={28} />
<TextInput <TextInput
onBlur={() => {
setIsFocused(false);
animateFocus(0);
}}
onChangeText={onChangeText} onChangeText={onChangeText}
onFocus={() => {
setIsFocused(true);
animateFocus(1);
}}
placeholder="搜索联系人备注/昵称/ID" placeholder="搜索联系人备注/昵称/ID"
placeholderTextColor="#B8B8BC" placeholderTextColor="#B8B8BC"
style={styles.input} style={styles.input}
value={value} value={value}
/> />
</View> </Animated.View>
); );
} }
@@ -28,7 +64,9 @@ const styles = StyleSheet.create({
wrapper: { wrapper: {
alignItems: "center", alignItems: "center",
backgroundColor: "#F0F0F2", backgroundColor: "#F0F0F2",
borderColor: "transparent",
borderCurve: "continuous", borderCurve: "continuous",
borderWidth: 1,
borderRadius: 20, borderRadius: 20,
flexDirection: "row", flexDirection: "row",
gap: 8, gap: 8,
@@ -36,6 +74,10 @@ const styles = StyleSheet.create({
marginHorizontal: 20, marginHorizontal: 20,
paddingHorizontal: 16, paddingHorizontal: 16,
}, },
wrapperFocused: {
backgroundColor: "#F4FAF8",
borderColor: "#BEEEDB",
},
input: { input: {
color: "#2A2A2A", color: "#2A2A2A",
flex: 1, flex: 1,

View File

@@ -120,10 +120,19 @@ function toViewModel(item: OpenIMConversationItem): ConversationItem {
export function useConversationList() { export function useConversationList() {
const [conversations, setConversations] = useState<OpenIMConversationItem[]>([]); const [conversations, setConversations] = useState<OpenIMConversationItem[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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 { try {
setError(null); setError(null);
const status = await OpenIMSDK.getLoginStatus(); const status = await OpenIMSDK.getLoginStatus();
@@ -141,13 +150,26 @@ export function useConversationList() {
const message = err instanceof Error ? err.message : "拉取会话失败"; const message = err instanceof Error ? err.message : "拉取会话失败";
setError(message); setError(message);
} finally { } 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); setIsLoading(false);
setIsRefreshing(false);
} }
}, []); }, []);
const reload = useCallback(async () => {
await loadConversations("refresh");
}, [loadConversations]);
useEffect(() => { useEffect(() => {
reload(); loadConversations("initial");
}, [reload]); }, [loadConversations]);
useEffect(() => { useEffect(() => {
const onConversationChanged = (changed: OpenIMConversationItem[]) => { const onConversationChanged = (changed: OpenIMConversationItem[]) => {
@@ -159,7 +181,7 @@ export function useConversationList() {
}; };
const onRecvNewMessages = () => { const onRecvNewMessages = () => {
reload(); loadConversations("silent");
}; };
OpenIMSDK.on(OpenIMEvent.OnConversationChanged, onConversationChanged); OpenIMSDK.on(OpenIMEvent.OnConversationChanged, onConversationChanged);
@@ -171,13 +193,14 @@ export function useConversationList() {
OpenIMSDK.off(OpenIMEvent.OnNewConversation, onNewConversation); OpenIMSDK.off(OpenIMEvent.OnNewConversation, onNewConversation);
OpenIMSDK.off(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages); OpenIMSDK.off(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
}; };
}, [reload]); }, [loadConversations]);
const items = useMemo(() => conversations.map(toViewModel), [conversations]); const items = useMemo(() => conversations.map(toViewModel), [conversations]);
return { return {
items, items,
isLoading, isLoading,
isRefreshing,
isLoggedIn, isLoggedIn,
error, error,
reload, reload,

View File

@@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 180) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [delay, value]);
return debouncedValue;
}