feat: 添加会话列表和搜索功能,重构聊天界面组件

This commit is contained in:
2026-03-09 08:12:30 +07:00
parent 007794688b
commit 212b3fa8de
12 changed files with 640 additions and 3 deletions

View File

@@ -5,6 +5,7 @@ export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#00D48C",
tabBarInactiveTintColor: "#94A3B8",
}}

View File

@@ -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() {
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,
},
});

View 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,
},
});

View 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",
},
});

View 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,
},
});

View 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",
},
});

View 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,
};
}

View 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]);
}

View 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",
},
];

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

View File

@@ -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",

16
pnpm-lock.yaml generated
View File

@@ -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':