From 212b3fa8de824238c4da802cffeea74b26f8bfea Mon Sep 17 00:00:00 2001 From: Seven Date: Mon, 9 Mar 2026 08:12:30 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=92=8C=E6=90=9C=E7=B4=A2=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E6=9E=84=E8=81=8A=E5=A4=A9=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/_layout.tsx | 1 + app/(tabs)/index.tsx | 73 ++++++- .../im/components/conversation-header.tsx | 56 ++++++ .../im/components/conversation-list-item.tsx | 136 +++++++++++++ features/im/components/conversation-list.tsx | 70 +++++++ .../im/components/conversation-search-bar.tsx | 45 +++++ features/im/hooks/use-conversation-list.ts | 185 ++++++++++++++++++ features/im/hooks/use-conversation-search.ts | 19 ++ features/im/mocks/mock-conversations.ts | 30 +++ features/im/types/conversation.ts | 11 ++ package.json | 1 + pnpm-lock.yaml | 16 ++ 12 files changed, 640 insertions(+), 3 deletions(-) create mode 100644 features/im/components/conversation-header.tsx create mode 100644 features/im/components/conversation-list-item.tsx create mode 100644 features/im/components/conversation-list.tsx create mode 100644 features/im/components/conversation-search-bar.tsx create mode 100644 features/im/hooks/use-conversation-list.ts create mode 100644 features/im/hooks/use-conversation-search.ts create mode 100644 features/im/mocks/mock-conversations.ts create mode 100644 features/im/types/conversation.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 43124ca..ea621ad 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -5,6 +5,7 @@ export default function TabLayout() { return ( Chat Route; -} \ No newline at end of file + 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 ( + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + backgroundColor: "#F7F7F8", + flex: 1, + }, + headerBlock: { + gap: 12, + paddingBottom: 14, + }, + listBlock: { + backgroundColor: "#FFFFFF", + borderTopColor: "#F0F1F3", + borderTopWidth: StyleSheet.hairlineWidth, + flex: 1, + }, +}); \ No newline at end of file diff --git a/features/im/components/conversation-header.tsx b/features/im/components/conversation-header.tsx new file mode 100644 index 0000000..b8c2371 --- /dev/null +++ b/features/im/components/conversation-header.tsx @@ -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 ( + + + {`消息(${totalLabel})`} + [styles.addButton, pressed && styles.addButtonPressed]} + > + + + + ); +} + +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, + }, +}); diff --git a/features/im/components/conversation-list-item.tsx b/features/im/components/conversation-list-item.tsx new file mode 100644 index 0000000..2d5cccf --- /dev/null +++ b/features/im/components/conversation-list-item.tsx @@ -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 ( + onPress?.(item)} + style={({ pressed }) => [styles.container, pressed && styles.containerPressed]} + > + {isSystem ? ( + + + + ) : ( + + )} + + + + + {item.title} + + {item.timeLabel} + + + + + {item.preview} + + {!!item.unreadCount && ( + + {item.unreadCount} + + )} + + + + ); +} + +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", + }, +}); diff --git a/features/im/components/conversation-list.tsx b/features/im/components/conversation-list.tsx new file mode 100644 index 0000000..8cf9ed5 --- /dev/null +++ b/features/im/components/conversation-list.tsx @@ -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 ( + + 会话加载中... + + ); + } + + if (data.length === 0) { + return ( + + 暂无会话 + 试试更换关键字 + + ); + } + + return ( + item.id} + renderItem={({ item }) => ( + + )} + 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, + }, +}); diff --git a/features/im/components/conversation-search-bar.tsx b/features/im/components/conversation-search-bar.tsx new file mode 100644 index 0000000..a202520 --- /dev/null +++ b/features/im/components/conversation-search-bar.tsx @@ -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 ( + + + + + ); +} + +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", + }, +}); diff --git a/features/im/hooks/use-conversation-list.ts b/features/im/hooks/use-conversation-list.ts new file mode 100644 index 0000000..588d1c7 --- /dev/null +++ b/features/im/hooks/use-conversation-list.ts @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [error, setError] = useState(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, + }; +} diff --git a/features/im/hooks/use-conversation-search.ts b/features/im/hooks/use-conversation-search.ts new file mode 100644 index 0000000..c25bae9 --- /dev/null +++ b/features/im/hooks/use-conversation-search.ts @@ -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]); +} diff --git a/features/im/mocks/mock-conversations.ts b/features/im/mocks/mock-conversations.ts new file mode 100644 index 0000000..acb600b --- /dev/null +++ b/features/im/mocks/mock-conversations.ts @@ -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", + }, +]; diff --git a/features/im/types/conversation.ts b/features/im/types/conversation.ts new file mode 100644 index 0000000..62d7be7 --- /dev/null +++ b/features/im/types/conversation.ts @@ -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; +}; diff --git a/package.json b/package.json index 84864f9..828863d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@shopify/flash-list": "^2.3.0", "@tailwindcss/postcss": "^4.2.1", "clsx": "^2.1.1", "expo": "~54.0.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 243ef07..6ee6f5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@react-navigation/native': 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) + '@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': specifier: ^4.2.1 version: 4.2.1 @@ -1243,6 +1246,13 @@ packages: '@rtsao/scc@1.1.0': 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': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -5917,6 +5927,12 @@ snapshots: '@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': {} '@sinonjs/commons@3.0.1':