feat: 添加会话列表和搜索功能,重构聊天界面组件
This commit is contained in:
@@ -5,6 +5,7 @@ export default function TabLayout() {
|
|||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
tabBarActiveTintColor: "#00D48C",
|
tabBarActiveTintColor: "#00D48C",
|
||||||
tabBarInactiveTintColor: "#94A3B8",
|
tabBarInactiveTintColor: "#94A3B8",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,72 @@
|
|||||||
import { Text } from "react-native";
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
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 { 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";
|
||||||
|
import type { ConversationItem } from "@/features/im/types/conversation";
|
||||||
|
|
||||||
|
const SYSTEM_MESSAGE_ITEM: ConversationItem = {
|
||||||
|
id: "system-message",
|
||||||
|
kind: "system",
|
||||||
|
title: "系统消息",
|
||||||
|
preview: "[系统消息]",
|
||||||
|
timeLabel: "",
|
||||||
|
};
|
||||||
|
|
||||||
export default function IndexRoute() {
|
export default function IndexRoute() {
|
||||||
return <Text>Chat Route</Text>;
|
const insets = useSafeAreaInsets();
|
||||||
}
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const { items, isLoading, isLoggedIn } = 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 unreadTotal = useMemo(() => {
|
||||||
|
const total = sourceItems.reduce((sum, item) => sum + (item.unreadCount ?? 0), 0);
|
||||||
|
if (total <= 0) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
return total > 99 ? "99+" : `${total}`;
|
||||||
|
}, [sourceItems]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.screen}>
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
|
||||||
|
<View style={[styles.headerBlock, { paddingTop: insets.top + 8 }]}>
|
||||||
|
<ConversationHeader totalLabel={unreadTotal} />
|
||||||
|
<ConversationSearchBar value={keyword} onChangeText={setKeyword} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.listBlock}>
|
||||||
|
<ConversationList data={filteredConversations} isLoading={isLoading && isLoggedIn} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
backgroundColor: "#F7F7F8",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerBlock: {
|
||||||
|
gap: 12,
|
||||||
|
paddingBottom: 14,
|
||||||
|
},
|
||||||
|
listBlock: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
borderTopColor: "#F0F1F3",
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
56
features/im/components/conversation-header.tsx
Normal file
56
features/im/components/conversation-header.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
type ConversationHeaderProps = {
|
||||||
|
totalLabel: string;
|
||||||
|
onPressCreate?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConversationHeader({
|
||||||
|
totalLabel,
|
||||||
|
onPressCreate,
|
||||||
|
}: ConversationHeaderProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.sideSpacer} />
|
||||||
|
<Text style={styles.title}>{`消息(${totalLabel})`}</Text>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
onPress={onPressCreate}
|
||||||
|
style={({ pressed }) => [styles.addButton, pressed && styles.addButtonPressed]}
|
||||||
|
>
|
||||||
|
<MaterialIcons color="#666666" name="add-circle-outline" size={36} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
height: 52,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
},
|
||||||
|
sideSpacer: {
|
||||||
|
width: 36,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: "#242424",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
},
|
||||||
|
addButton: {
|
||||||
|
alignItems: "center",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
borderRadius: 18,
|
||||||
|
height: 36,
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 36,
|
||||||
|
},
|
||||||
|
addButtonPressed: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
});
|
||||||
136
features/im/components/conversation-list-item.tsx
Normal file
136
features/im/components/conversation-list-item.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { memo } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import type { ConversationItem } from "@/features/im/types/conversation";
|
||||||
|
|
||||||
|
type ConversationListItemProps = {
|
||||||
|
item: ConversationItem;
|
||||||
|
onPress?: (item: ConversationItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ConversationListItemInner({ item, onPress }: ConversationListItemProps) {
|
||||||
|
const isSystem = item.kind === "system";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => onPress?.(item)}
|
||||||
|
style={({ pressed }) => [styles.container, pressed && styles.containerPressed]}
|
||||||
|
>
|
||||||
|
{isSystem ? (
|
||||||
|
<View style={styles.systemAvatar}>
|
||||||
|
<MaterialIcons color="#FFFFFF" name="volume-up" size={28} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
source={item.avatarUrl}
|
||||||
|
style={styles.avatar}
|
||||||
|
transition={120}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.rowTop}>
|
||||||
|
<Text numberOfLines={1} style={styles.title}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.timeLabel}>{item.timeLabel}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.rowBottom}>
|
||||||
|
<Text numberOfLines={1} style={styles.preview}>
|
||||||
|
{item.preview}
|
||||||
|
</Text>
|
||||||
|
{!!item.unreadCount && (
|
||||||
|
<View style={styles.unreadBadge}>
|
||||||
|
<Text style={styles.unreadText}>{item.unreadCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConversationListItem = memo(ConversationListItemInner);
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
minHeight: 78,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
containerPressed: {
|
||||||
|
opacity: 0.75,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
backgroundColor: "#EAEAEA",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
borderRadius: 24,
|
||||||
|
height: 48,
|
||||||
|
width: 48,
|
||||||
|
},
|
||||||
|
systemAvatar: {
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#50D280",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
borderRadius: 24,
|
||||||
|
height: 48,
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 48,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 6,
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
rowTop: {
|
||||||
|
alignItems: "baseline",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
rowBottom: {
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: "#323232",
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
timeLabel: {
|
||||||
|
color: "#9B9EA4",
|
||||||
|
fontSize: 12,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
fontWeight: "400",
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
color: "#A2A6AD",
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "400",
|
||||||
|
},
|
||||||
|
unreadBadge: {
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#00BC7D",
|
||||||
|
borderRadius: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
minWidth: 20,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
},
|
||||||
|
unreadText: {
|
||||||
|
color: "#FFFFFF",
|
||||||
|
fontSize: 12,
|
||||||
|
fontVariant: ["tabular-nums"],
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
70
features/im/components/conversation-list.tsx
Normal file
70
features/im/components/conversation-list.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { StyleSheet, Text, View } from "react-native";
|
||||||
|
|
||||||
|
import { ConversationListItem } from "@/features/im/components/conversation-list-item";
|
||||||
|
import type { ConversationItem } from "@/features/im/types/conversation";
|
||||||
|
|
||||||
|
type ConversationListProps = {
|
||||||
|
data: ConversationItem[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
onPressItem?: (item: ConversationItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConversationList({
|
||||||
|
data,
|
||||||
|
isLoading = false,
|
||||||
|
onPressItem,
|
||||||
|
}: 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 (
|
||||||
|
<FlashList
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
data={data}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<ConversationListItem item={item} onPress={onPressItem} />
|
||||||
|
)}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
contentContainer: {
|
||||||
|
paddingBottom: 28,
|
||||||
|
paddingTop: 16,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
emptyTitle: {
|
||||||
|
color: "#666B73",
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
emptyHint: {
|
||||||
|
color: "#A0A4AA",
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
45
features/im/components/conversation-search-bar.tsx
Normal file
45
features/im/components/conversation-search-bar.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { StyleSheet, TextInput, View } from "react-native";
|
||||||
|
|
||||||
|
type ConversationSearchBarProps = {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConversationSearchBar({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
}: ConversationSearchBarProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<MaterialIcons color="#A2A2A7" name="search" size={28} />
|
||||||
|
<TextInput
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
placeholder="搜索联系人备注/昵称/ID"
|
||||||
|
placeholderTextColor="#B8B8BC"
|
||||||
|
style={styles.input}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#F0F0F2",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
borderRadius: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
height: 52,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
color: "#2A2A2A",
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "400",
|
||||||
|
},
|
||||||
|
});
|
||||||
185
features/im/hooks/use-conversation-list.ts
Normal file
185
features/im/hooks/use-conversation-list.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import OpenIMSDK, {
|
||||||
|
LoginStatus,
|
||||||
|
OpenIMEvent,
|
||||||
|
type ConversationItem as OpenIMConversationItem,
|
||||||
|
type MessageItem,
|
||||||
|
} from "@openim/rn-client-sdk";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import type { ConversationItem } from "@/features/im/types/conversation";
|
||||||
|
|
||||||
|
function sortConversations(items: OpenIMConversationItem[]) {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
if (a.isPinned !== b.isPinned) {
|
||||||
|
return a.isPinned ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (b.latestMsgSendTime ?? 0) - (a.latestMsgSendTime ?? 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertConversations(
|
||||||
|
current: OpenIMConversationItem[],
|
||||||
|
changed: OpenIMConversationItem[]
|
||||||
|
) {
|
||||||
|
if (!changed.length) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map(current.map((item) => [item.conversationID, item]));
|
||||||
|
changed.forEach((item) => {
|
||||||
|
map.set(item.conversationID, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortConversations([...map.values()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp: number) {
|
||||||
|
if (!timestamp) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = Math.max(0, now - timestamp);
|
||||||
|
const minute = 60 * 1000;
|
||||||
|
const hour = 60 * minute;
|
||||||
|
const day = 24 * hour;
|
||||||
|
|
||||||
|
if (diff < minute) {
|
||||||
|
return "刚刚";
|
||||||
|
}
|
||||||
|
if (diff < hour) {
|
||||||
|
return `${Math.floor(diff / minute)}分钟前`;
|
||||||
|
}
|
||||||
|
if (diff < day) {
|
||||||
|
return `${Math.floor(diff / hour)}小时前`;
|
||||||
|
}
|
||||||
|
if (diff < day * 2) {
|
||||||
|
return "昨天";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||||
|
const dayOfMonth = `${date.getDate()}`.padStart(2, "0");
|
||||||
|
return `${month}-${dayOfMonth}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLatestMessagePreview(latestMsg: string, draftText: string) {
|
||||||
|
if (draftText) {
|
||||||
|
return `[草稿] ${draftText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!latestMsg) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(latestMsg) as MessageItem;
|
||||||
|
if (message.textElem?.content) {
|
||||||
|
return message.textElem.content;
|
||||||
|
}
|
||||||
|
if (message.quoteElem?.text) {
|
||||||
|
return `[引用] ${message.quoteElem.text}`;
|
||||||
|
}
|
||||||
|
if (message.pictureElem) {
|
||||||
|
return "[图片]";
|
||||||
|
}
|
||||||
|
if (message.videoElem) {
|
||||||
|
return "[视频]";
|
||||||
|
}
|
||||||
|
if (message.fileElem) {
|
||||||
|
return "[文件]";
|
||||||
|
}
|
||||||
|
if (message.soundElem) {
|
||||||
|
return "[语音]";
|
||||||
|
}
|
||||||
|
if (message.notificationElem) {
|
||||||
|
return "[系统通知]";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return latestMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "[暂不支持的消息]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toViewModel(item: OpenIMConversationItem): ConversationItem {
|
||||||
|
const title = item.showName || item.userID || item.groupID || "未命名会话";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.conversationID,
|
||||||
|
kind: "direct",
|
||||||
|
title,
|
||||||
|
preview: parseLatestMessagePreview(item.latestMsg, item.draftText),
|
||||||
|
timeLabel: formatRelativeTime(item.latestMsgSendTime),
|
||||||
|
avatarUrl: item.faceURL || undefined,
|
||||||
|
unreadCount: item.unreadCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversationList() {
|
||||||
|
const [conversations, setConversations] = useState<OpenIMConversationItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const status = await OpenIMSDK.getLoginStatus();
|
||||||
|
const logged = status === LoginStatus.Logged;
|
||||||
|
setIsLoggedIn(logged);
|
||||||
|
|
||||||
|
if (!logged) {
|
||||||
|
setConversations([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = await OpenIMSDK.getAllConversationList();
|
||||||
|
setConversations(sortConversations(list));
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "拉取会话失败";
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onConversationChanged = (changed: OpenIMConversationItem[]) => {
|
||||||
|
setConversations((prev) => upsertConversations(prev, changed));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNewConversation = (created: OpenIMConversationItem[]) => {
|
||||||
|
setConversations((prev) => upsertConversations(prev, created));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRecvNewMessages = () => {
|
||||||
|
reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
OpenIMSDK.on(OpenIMEvent.OnConversationChanged, onConversationChanged);
|
||||||
|
OpenIMSDK.on(OpenIMEvent.OnNewConversation, onNewConversation);
|
||||||
|
OpenIMSDK.on(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
OpenIMSDK.off(OpenIMEvent.OnConversationChanged, onConversationChanged);
|
||||||
|
OpenIMSDK.off(OpenIMEvent.OnNewConversation, onNewConversation);
|
||||||
|
OpenIMSDK.off(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
|
||||||
|
};
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const items = useMemo(() => conversations.map(toViewModel), [conversations]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
isLoggedIn,
|
||||||
|
error,
|
||||||
|
reload,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
features/im/hooks/use-conversation-search.ts
Normal file
19
features/im/hooks/use-conversation-search.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import type { ConversationItem } from "@/features/im/types/conversation";
|
||||||
|
|
||||||
|
export function useConversationSearch(items: ConversationItem[], keyword: string) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const query = keyword.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
return (
|
||||||
|
item.title.toLowerCase().includes(query) ||
|
||||||
|
item.preview.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [items, keyword]);
|
||||||
|
}
|
||||||
30
features/im/mocks/mock-conversations.ts
Normal file
30
features/im/mocks/mock-conversations.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ConversationItem } from "@/features/im/types/conversation";
|
||||||
|
|
||||||
|
export const mockConversations: ConversationItem[] = [
|
||||||
|
{
|
||||||
|
id: "system",
|
||||||
|
kind: "system",
|
||||||
|
title: "系统消息",
|
||||||
|
preview: "[系统消息]",
|
||||||
|
timeLabel: "昨天 20:12",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "admin",
|
||||||
|
kind: "direct",
|
||||||
|
title: "Admin",
|
||||||
|
preview: "这是一条聊天消息,很长的聊天消息",
|
||||||
|
timeLabel: "21分钟前",
|
||||||
|
avatarUrl:
|
||||||
|
"https://images.unsplash.com/photo-1613070120286-98b11cdb9f8f?auto=format&fit=crop&w=120&q=80",
|
||||||
|
unreadCount: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zhangsan",
|
||||||
|
kind: "direct",
|
||||||
|
title: "张三",
|
||||||
|
preview: "你好呀~~~",
|
||||||
|
timeLabel: "21分钟前",
|
||||||
|
avatarUrl:
|
||||||
|
"https://images.unsplash.com/photo-1524504388940-b1c1722653e1?auto=format&fit=crop&w=120&q=80",
|
||||||
|
},
|
||||||
|
];
|
||||||
11
features/im/types/conversation.ts
Normal file
11
features/im/types/conversation.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type ConversationKind = "system" | "direct";
|
||||||
|
|
||||||
|
export type ConversationItem = {
|
||||||
|
id: string;
|
||||||
|
kind: ConversationKind;
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
timeLabel: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
unreadCount?: number;
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@shopify/flash-list": "^2.3.0",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
'@react-navigation/native':
|
'@react-navigation/native':
|
||||||
specifier: ^7.1.8
|
specifier: ^7.1.8
|
||||||
version: 7.1.33(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
version: 7.1.33(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
|
'@shopify/flash-list':
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.3.0(@babel/runtime@7.28.6)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -1243,6 +1246,13 @@ packages:
|
|||||||
'@rtsao/scc@1.1.0':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
|
'@shopify/flash-list@2.3.0':
|
||||||
|
resolution: {integrity: sha512-DR7VuN8KJHTYj9zv1/IhpqrMBMQyeeW/DCWCbVQAAkWhHrc6ylIbXOY+qK93CuHABV+dNHXK/3V6p4wCSW/+wA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@babel/runtime': '*'
|
||||||
|
react: '*'
|
||||||
|
react-native: '*'
|
||||||
|
|
||||||
'@sinclair/typebox@0.27.10':
|
'@sinclair/typebox@0.27.10':
|
||||||
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
||||||
|
|
||||||
@@ -5917,6 +5927,12 @@ snapshots:
|
|||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
|
'@shopify/flash-list@2.3.0(@babel/runtime@7.28.6)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
react: 19.1.0
|
||||||
|
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||||
|
|
||||||
'@sinclair/typebox@0.27.10': {}
|
'@sinclair/typebox@0.27.10': {}
|
||||||
|
|
||||||
'@sinonjs/commons@3.0.1':
|
'@sinonjs/commons@3.0.1':
|
||||||
|
|||||||
Reference in New Issue
Block a user