feat: 添加聊天会话页面,支持会话ID和标题显示,优化会话列表和搜索功能
This commit is contained in:
@@ -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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
48
app/chat/[conversationId].tsx
Normal file
48
app/chat/[conversationId].tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
17
features/im/hooks/use-debounced-value.ts
Normal file
17
features/im/hooks/use-debounced-value.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user