Compare commits
7 Commits
22162981e2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| db4ee293d2 | |||
| 212b3fa8de | |||
| 007794688b | |||
| e41f1cfe1f | |||
| b45959a0f4 | |||
| afb0914db8 | |||
| 283b29f9a2 |
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -15,6 +15,9 @@
|
|||||||
- 这是一个 IM 项目,集成了 OpenIM 原生 SDK。
|
- 这是一个 IM 项目,集成了 OpenIM 原生 SDK。
|
||||||
- UI 风格:极简、大气、具有呼吸感。
|
- UI 风格:极简、大气、具有呼吸感。
|
||||||
|
|
||||||
|
## 代码提交规范
|
||||||
|
Git Commit 规范通常遵循约定式提交规范 (Conventional Commits),格式为 <type>(<scope>): <subject>。Header行不超过72字符。核心类型包括:feat (新功能), fix (修补bug), docs (文档), style (格式), refactor (重构), test (测试), chore (构建/工具)。
|
||||||
|
|
||||||
1. 项目概况 (Project Profile)
|
1. 项目概况 (Project Profile)
|
||||||
Role: 资深 React Native & 交互设计专家。
|
Role: 资深 React Native & 交互设计专家。
|
||||||
Project: 正在开发名为 "lamp" 的高性能 IM 系统,使用 OpenIM SDK 底层。
|
Project: 正在开发名为 "lamp" 的高性能 IM 系统,使用 OpenIM SDK 底层。
|
||||||
|
|||||||
@@ -1,35 +1,19 @@
|
|||||||
import { Tabs } from 'expo-router';
|
import { Tabs } from 'expo-router';
|
||||||
import React from 'react';
|
import Ionicons from "@expo/vector-icons/MaterialIcons"
|
||||||
|
|
||||||
import { HapticTab } from '@/components/haptic-tab';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
|
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarButton: HapticTab,
|
tabBarActiveTintColor: "#00D48C",
|
||||||
}}>
|
tabBarInactiveTintColor: "#94A3B8",
|
||||||
<Tabs.Screen
|
|
||||||
name="index"
|
|
||||||
options={{
|
|
||||||
title: 'Home',
|
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen name="index" options={{ title: '消息', tabBarIcon: ({ color }) => <Ionicons size={28} name="message" color={color} /> }} />
|
||||||
name="explore"
|
<Tabs.Screen name="contacts" options={{ title: '通讯录', tabBarIcon: ({ color }) => <Ionicons size={28} name="assignment-ind" color={color} /> }} />
|
||||||
options={{
|
<Tabs.Screen name="discover" options={{ title: '发现', tabBarIcon: ({ color }) => <Ionicons size={28} name="compass-calibration" color={color} /> }} />
|
||||||
title: 'Explore',
|
<Tabs.Screen name="profile" options={{ title: '我', tabBarIcon: ({ color }) => <Ionicons size={28} name="person" color={color} /> }} />
|
||||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
3
app/(tabs)/contacts.tsx
Normal file
3
app/(tabs)/contacts.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function ContactsRoute() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
3
app/(tabs)/discover.tsx
Normal file
3
app/(tabs)/discover.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function ContactsRoute() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { Image } from 'expo-image';
|
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { Collapsible } from '@/components/ui/collapsible';
|
|
||||||
import { ExternalLink } from '@/components/external-link';
|
|
||||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Fonts } from '@/constants/theme';
|
|
||||||
|
|
||||||
export default function TabTwoScreen() {
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
|
|
||||||
headerImage={
|
|
||||||
<IconSymbol
|
|
||||||
size={310}
|
|
||||||
color="#808080"
|
|
||||||
name="chevron.left.forwardslash.chevron.right"
|
|
||||||
style={styles.headerImage}
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText
|
|
||||||
type="title"
|
|
||||||
style={{
|
|
||||||
fontFamily: Fonts.rounded,
|
|
||||||
}}>
|
|
||||||
Explore
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedText>This app includes example code to help you get started.</ThemedText>
|
|
||||||
<Collapsible title="File-based routing">
|
|
||||||
<ThemedText>
|
|
||||||
This app has two screens:{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
|
|
||||||
</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
|
|
||||||
sets up the tab navigator.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Android, iOS, and web support">
|
|
||||||
<ThemedText>
|
|
||||||
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
|
|
||||||
</ThemedText>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Images">
|
|
||||||
<ThemedText>
|
|
||||||
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
|
|
||||||
different screen densities
|
|
||||||
</ThemedText>
|
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/react-logo.png')}
|
|
||||||
style={{ width: 100, height: 100, alignSelf: 'center' }}
|
|
||||||
/>
|
|
||||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Light and dark mode components">
|
|
||||||
<ThemedText>
|
|
||||||
This template has light and dark mode support. The{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
|
|
||||||
what the user's current color scheme is, and so you can adjust UI colors accordingly.
|
|
||||||
</ThemedText>
|
|
||||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
|
||||||
<ThemedText type="link">Learn more</ThemedText>
|
|
||||||
</ExternalLink>
|
|
||||||
</Collapsible>
|
|
||||||
<Collapsible title="Animations">
|
|
||||||
<ThemedText>
|
|
||||||
This template includes an example of an animated component. The{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
|
|
||||||
the powerful{' '}
|
|
||||||
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
|
|
||||||
react-native-reanimated
|
|
||||||
</ThemedText>{' '}
|
|
||||||
library to create a waving hand animation.
|
|
||||||
</ThemedText>
|
|
||||||
{Platform.select({
|
|
||||||
ios: (
|
|
||||||
<ThemedText>
|
|
||||||
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
|
|
||||||
component provides a parallax effect for the header image.
|
|
||||||
</ThemedText>
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
</Collapsible>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
headerImage: {
|
|
||||||
color: '#808080',
|
|
||||||
bottom: -90,
|
|
||||||
left: -35,
|
|
||||||
position: 'absolute',
|
|
||||||
},
|
|
||||||
titleContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,98 +1,130 @@
|
|||||||
import { Image } from 'expo-image';
|
import { StatusBar } from "expo-status-bar";
|
||||||
import { Platform, StyleSheet } from 'react-native';
|
import { useMemo, useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
import { HelloWave } from '@/components/hello-wave';
|
import { ConversationHeader } from "@/features/im/components/conversation-header";
|
||||||
import ParallaxScrollView from '@/components/parallax-scroll-view';
|
import { ConversationList } from "@/features/im/components/conversation-list";
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { ConversationSearchBar } from "@/features/im/components/conversation-search-bar";
|
||||||
import { ThemedView } from '@/components/themed-view';
|
import { useDebouncedValue } from "@/features/im/hooks/use-debounced-value";
|
||||||
import { Link } from 'expo-router';
|
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() {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const debouncedKeyword = useDebouncedValue(keyword, 180);
|
||||||
|
const { items, isLoading, isRefreshing, isLoggedIn, error, reload } = useConversationList();
|
||||||
|
|
||||||
|
const sourceItems = isLoggedIn ? items : mockConversations;
|
||||||
|
const conversationItems = useMemo(
|
||||||
|
() => [SYSTEM_MESSAGE_ITEM, ...sourceItems.filter((item) => item.kind !== "system")],
|
||||||
|
[sourceItems]
|
||||||
|
);
|
||||||
|
const filteredConversations = useConversationSearch(conversationItems, debouncedKeyword);
|
||||||
|
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]);
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
return (
|
return (
|
||||||
<ParallaxScrollView
|
<View style={styles.screen}>
|
||||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
<StatusBar style="dark" />
|
||||||
headerImage={
|
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/partial-react-logo.png')}
|
|
||||||
style={styles.reactLogo}
|
|
||||||
/>
|
|
||||||
}>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText type="title">Welcome!</ThemedText>
|
|
||||||
<HelloWave />
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
|
|
||||||
<ThemedText>
|
|
||||||
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
|
|
||||||
Press{' '}
|
|
||||||
<ThemedText type="defaultSemiBold">
|
|
||||||
{Platform.select({
|
|
||||||
ios: 'cmd + d',
|
|
||||||
android: 'cmd + m',
|
|
||||||
web: 'F12',
|
|
||||||
})}
|
|
||||||
</ThemedText>{' '}
|
|
||||||
to open developer tools.
|
|
||||||
</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<Link href="/modal">
|
|
||||||
<Link.Trigger>
|
|
||||||
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
|
|
||||||
</Link.Trigger>
|
|
||||||
<Link.Preview />
|
|
||||||
<Link.Menu>
|
|
||||||
<Link.MenuAction title="Action" icon="cube" onPress={() => alert('Action pressed')} />
|
|
||||||
<Link.MenuAction
|
|
||||||
title="Share"
|
|
||||||
icon="square.and.arrow.up"
|
|
||||||
onPress={() => alert('Share pressed')}
|
|
||||||
/>
|
|
||||||
<Link.Menu title="More" icon="ellipsis">
|
|
||||||
<Link.MenuAction
|
|
||||||
title="Delete"
|
|
||||||
icon="trash"
|
|
||||||
destructive
|
|
||||||
onPress={() => alert('Delete pressed')}
|
|
||||||
/>
|
|
||||||
</Link.Menu>
|
|
||||||
</Link.Menu>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<ThemedText>
|
<View style={[styles.headerBlock, { paddingTop: insets.top + 8 }]}>
|
||||||
{`Tap the Explore tab to learn more about what's included in this starter app.`}
|
<ConversationHeader totalLabel={unreadTotal} />
|
||||||
</ThemedText>
|
<ConversationSearchBar value={keyword} onChangeText={setKeyword} />
|
||||||
</ThemedView>
|
</View>
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
|
<View style={styles.listBlock}>
|
||||||
<ThemedText>
|
{isLoggedIn && !!error && (
|
||||||
{`When you're ready, run `}
|
<View style={styles.errorBanner}>
|
||||||
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
|
<Text numberOfLines={1} style={styles.errorText}>
|
||||||
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
|
{error}
|
||||||
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
|
</Text>
|
||||||
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
|
<Pressable onPress={reload} style={styles.errorAction}>
|
||||||
</ThemedText>
|
<Text style={styles.errorActionText}>重试</Text>
|
||||||
</ThemedView>
|
</Pressable>
|
||||||
</ParallaxScrollView>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
titleContainer: {
|
screen: {
|
||||||
flexDirection: 'row',
|
backgroundColor: "#F7F7F8",
|
||||||
alignItems: 'center',
|
flex: 1,
|
||||||
gap: 8,
|
|
||||||
},
|
},
|
||||||
stepContainer: {
|
headerBlock: {
|
||||||
gap: 8,
|
gap: 12,
|
||||||
marginBottom: 8,
|
paddingBottom: 14,
|
||||||
},
|
},
|
||||||
reactLogo: {
|
listBlock: {
|
||||||
height: 178,
|
backgroundColor: "#FFFFFF",
|
||||||
width: 290,
|
borderTopColor: "#F0F1F3",
|
||||||
bottom: 0,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
left: 0,
|
flex: 1,
|
||||||
position: 'absolute',
|
},
|
||||||
|
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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
3
app/(tabs)/profile.tsx
Normal file
3
app/(tabs)/profile.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function ProfileRoute() {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
@@ -5,14 +5,24 @@ import {
|
|||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
import "react-native-reanimated";
|
import { useColorScheme } from "react-native";
|
||||||
import "../global.css";
|
|
||||||
|
|
||||||
import { useColorScheme } from "@/hooks/use-color-scheme";
|
|
||||||
import { AppBootstrapProvider } from "@/providers/app-bootstrap-provider";
|
import { AppBootstrapProvider } from "@/providers/app-bootstrap-provider";
|
||||||
|
|
||||||
export const unstable_settings = {
|
const lightTheme = {
|
||||||
anchor: "(tabs)",
|
...DefaultTheme,
|
||||||
|
colors: {
|
||||||
|
...DefaultTheme.colors,
|
||||||
|
primary: "#00D48C",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const darkTheme = {
|
||||||
|
...DarkTheme,
|
||||||
|
colors: {
|
||||||
|
...DarkTheme.colors,
|
||||||
|
primary: "#00D48C",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
@@ -20,13 +30,9 @@ export default function RootLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBootstrapProvider>
|
<AppBootstrapProvider>
|
||||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
<ThemeProvider value={colorScheme === "dark" ? darkTheme : lightTheme}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
|
||||||
name="modal"
|
|
||||||
options={{ presentation: "modal", title: "Modal" }}
|
|
||||||
/>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Link } from 'expo-router';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
|
|
||||||
export default function ModalScreen() {
|
|
||||||
return (
|
|
||||||
<ThemedView style={styles.container}>
|
|
||||||
<ThemedText type="title">This is a modal</ThemedText>
|
|
||||||
<Link href="/" dismissTo style={styles.link}>
|
|
||||||
<ThemedText type="link">Go to home screen</ThemedText>
|
|
||||||
</Link>
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 20,
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
marginTop: 15,
|
|
||||||
paddingVertical: 15,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Href, Link } from 'expo-router';
|
|
||||||
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
|
||||||
import { type ComponentProps } from 'react';
|
|
||||||
|
|
||||||
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
|
||||||
|
|
||||||
export function ExternalLink({ href, ...rest }: Props) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
target="_blank"
|
|
||||||
{...rest}
|
|
||||||
href={href}
|
|
||||||
onPress={async (event) => {
|
|
||||||
if (process.env.EXPO_OS !== 'web') {
|
|
||||||
// Prevent the default behavior of linking to the default browser on native.
|
|
||||||
event.preventDefault();
|
|
||||||
// Open the link in an in-app browser.
|
|
||||||
await openBrowserAsync(href, {
|
|
||||||
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
|
||||||
import { PlatformPressable } from '@react-navigation/elements';
|
|
||||||
import * as Haptics from 'expo-haptics';
|
|
||||||
|
|
||||||
export function HapticTab(props: BottomTabBarButtonProps) {
|
|
||||||
return (
|
|
||||||
<PlatformPressable
|
|
||||||
{...props}
|
|
||||||
onPressIn={(ev) => {
|
|
||||||
if (process.env.EXPO_OS === 'ios') {
|
|
||||||
// Add a soft haptic feedback when pressing down on the tabs.
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
}
|
|
||||||
props.onPressIn?.(ev);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import Animated from 'react-native-reanimated';
|
|
||||||
|
|
||||||
export function HelloWave() {
|
|
||||||
return (
|
|
||||||
<Animated.Text
|
|
||||||
style={{
|
|
||||||
fontSize: 28,
|
|
||||||
lineHeight: 32,
|
|
||||||
marginTop: -6,
|
|
||||||
animationName: {
|
|
||||||
'50%': { transform: [{ rotate: '25deg' }] },
|
|
||||||
},
|
|
||||||
animationIterationCount: 4,
|
|
||||||
animationDuration: '300ms',
|
|
||||||
}}>
|
|
||||||
👋
|
|
||||||
</Animated.Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
import Animated, {
|
|
||||||
interpolate,
|
|
||||||
useAnimatedRef,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useScrollOffset,
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
const HEADER_HEIGHT = 250;
|
|
||||||
|
|
||||||
type Props = PropsWithChildren<{
|
|
||||||
headerImage: ReactElement;
|
|
||||||
headerBackgroundColor: { dark: string; light: string };
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export default function ParallaxScrollView({
|
|
||||||
children,
|
|
||||||
headerImage,
|
|
||||||
headerBackgroundColor,
|
|
||||||
}: Props) {
|
|
||||||
const backgroundColor = useThemeColor({}, 'background');
|
|
||||||
const colorScheme = useColorScheme() ?? 'light';
|
|
||||||
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
||||||
const scrollOffset = useScrollOffset(scrollRef);
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
scrollOffset.value,
|
|
||||||
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
|
||||||
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.ScrollView
|
|
||||||
ref={scrollRef}
|
|
||||||
style={{ backgroundColor, flex: 1 }}
|
|
||||||
scrollEventThrottle={16}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.header,
|
|
||||||
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
|
||||||
headerAnimatedStyle,
|
|
||||||
]}>
|
|
||||||
{headerImage}
|
|
||||||
</Animated.View>
|
|
||||||
<ThemedView style={styles.content}>{children}</ThemedView>
|
|
||||||
</Animated.ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
height: HEADER_HEIGHT,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 32,
|
|
||||||
gap: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
export type ThemedTextProps = TextProps & {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedText({
|
|
||||||
style,
|
|
||||||
lightColor,
|
|
||||||
darkColor,
|
|
||||||
type = 'default',
|
|
||||||
...rest
|
|
||||||
}: ThemedTextProps) {
|
|
||||||
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
{ color },
|
|
||||||
type === 'default' ? styles.default : undefined,
|
|
||||||
type === 'title' ? styles.title : undefined,
|
|
||||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
|
||||||
type === 'subtitle' ? styles.subtitle : undefined,
|
|
||||||
type === 'link' ? styles.link : undefined,
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
default: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
defaultSemiBold: {
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
lineHeight: 32,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
lineHeight: 30,
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#0a7ea4',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { View, type ViewProps } from 'react-native';
|
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
|
||||||
|
|
||||||
export type ThemedViewProps = ViewProps & {
|
|
||||||
lightColor?: string;
|
|
||||||
darkColor?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
|
||||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
|
||||||
|
|
||||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { PropsWithChildren, useState } from 'react';
|
|
||||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
|
||||||
import { ThemedView } from '@/components/themed-view';
|
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemedView>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.heading}
|
|
||||||
onPress={() => setIsOpen((value) => !value)}
|
|
||||||
activeOpacity={0.8}>
|
|
||||||
<IconSymbol
|
|
||||||
name="chevron.right"
|
|
||||||
size={18}
|
|
||||||
weight="medium"
|
|
||||||
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
|
|
||||||
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
|
||||||
</ThemedView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
heading: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 24,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
|
||||||
import { StyleProp, ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
weight = 'regular',
|
|
||||||
}: {
|
|
||||||
name: SymbolViewProps['name'];
|
|
||||||
size?: number;
|
|
||||||
color: string;
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
weight?: SymbolWeight;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SymbolView
|
|
||||||
weight={weight}
|
|
||||||
tintColor={color}
|
|
||||||
resizeMode="scaleAspectFit"
|
|
||||||
name={name}
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// Fallback for using MaterialIcons on Android and web.
|
|
||||||
|
|
||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
||||||
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
|
|
||||||
import { ComponentProps } from 'react';
|
|
||||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
|
||||||
|
|
||||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
|
||||||
type IconSymbolName = keyof typeof MAPPING;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add your SF Symbols to Material Icons mappings here.
|
|
||||||
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
|
||||||
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
|
||||||
*/
|
|
||||||
const MAPPING = {
|
|
||||||
'house.fill': 'home',
|
|
||||||
'paperplane.fill': 'send',
|
|
||||||
'chevron.left.forwardslash.chevron.right': 'code',
|
|
||||||
'chevron.right': 'chevron-right',
|
|
||||||
} as IconMapping;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
|
||||||
* This ensures a consistent look across platforms, and optimal resource usage.
|
|
||||||
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
|
||||||
*/
|
|
||||||
export function IconSymbol({
|
|
||||||
name,
|
|
||||||
size = 24,
|
|
||||||
color,
|
|
||||||
style,
|
|
||||||
}: {
|
|
||||||
name: IconSymbolName;
|
|
||||||
size?: number;
|
|
||||||
color: string | OpaqueColorValue;
|
|
||||||
style?: StyleProp<TextStyle>;
|
|
||||||
weight?: SymbolWeight;
|
|
||||||
}) {
|
|
||||||
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
|
||||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
const tintColorLight = '#0a7ea4';
|
|
||||||
const tintColorDark = '#fff';
|
|
||||||
|
|
||||||
export const Colors = {
|
|
||||||
light: {
|
|
||||||
text: '#11181C',
|
|
||||||
background: '#fff',
|
|
||||||
tint: tintColorLight,
|
|
||||||
icon: '#687076',
|
|
||||||
tabIconDefault: '#687076',
|
|
||||||
tabIconSelected: tintColorLight,
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
text: '#ECEDEE',
|
|
||||||
background: '#151718',
|
|
||||||
tint: tintColorDark,
|
|
||||||
icon: '#9BA1A6',
|
|
||||||
tabIconDefault: '#9BA1A6',
|
|
||||||
tabIconSelected: tintColorDark,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Fonts = Platform.select({
|
|
||||||
ios: {
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
|
||||||
sans: 'system-ui',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
|
||||||
serif: 'ui-serif',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
|
||||||
rounded: 'ui-rounded',
|
|
||||||
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
|
||||||
mono: 'ui-monospace',
|
|
||||||
},
|
|
||||||
default: {
|
|
||||||
sans: 'normal',
|
|
||||||
serif: 'serif',
|
|
||||||
rounded: 'normal',
|
|
||||||
mono: 'monospace',
|
|
||||||
},
|
|
||||||
web: {
|
|
||||||
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
||||||
serif: "Georgia, 'Times New Roman', serif",
|
|
||||||
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
|
|
||||||
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -14,9 +14,13 @@ This project uses Expo Router and follows a route-first + feature-module structu
|
|||||||
## Current Module Split
|
## Current Module Split
|
||||||
|
|
||||||
- `app/_layout.tsx`
|
- `app/_layout.tsx`
|
||||||
- Root navigation composition only.
|
- Root navigation composition.
|
||||||
- Theme provider and route stack config.
|
- Theme provider and route stack config.
|
||||||
- No SDK side effects.
|
- App bootstrap provider wiring.
|
||||||
|
|
||||||
|
- `app/index.tsx` and `app/modal.tsx`
|
||||||
|
- Route shells only.
|
||||||
|
- Delegate screen implementation to `features/*/screens`.
|
||||||
|
|
||||||
- `providers/app-bootstrap-provider.tsx`
|
- `providers/app-bootstrap-provider.tsx`
|
||||||
- Central app bootstrap orchestration.
|
- Central app bootstrap orchestration.
|
||||||
@@ -30,6 +34,12 @@ This project uses Expo Router and follows a route-first + feature-module structu
|
|||||||
- `features/im/hooks/use-openim-bootstrap.ts`
|
- `features/im/hooks/use-openim-bootstrap.ts`
|
||||||
- React lifecycle bridge for IM bootstrap.
|
- React lifecycle bridge for IM bootstrap.
|
||||||
|
|
||||||
|
- `features/home/screens/home-screen.tsx`
|
||||||
|
- Home page UI implementation.
|
||||||
|
|
||||||
|
- `features/system/screens/modal-screen.tsx`
|
||||||
|
- Modal page UI implementation.
|
||||||
|
|
||||||
## Why This Structure
|
## Why This Structure
|
||||||
|
|
||||||
- Keeps routing files focused on navigation.
|
- Keeps routing files focused on navigation.
|
||||||
@@ -41,4 +51,4 @@ This project uses Expo Router and follows a route-first + feature-module structu
|
|||||||
|
|
||||||
1. Move IM API wrappers into `features/im/services/`.
|
1. Move IM API wrappers into `features/im/services/`.
|
||||||
2. Add `features/chat/hooks/use-chat-list.ts` and `features/chat/hooks/use-messages.ts`.
|
2. Add `features/chat/hooks/use-chat-list.ts` and `features/chat/hooks/use-messages.ts`.
|
||||||
3. Keep `app/(tabs)` as route shells and move business UI into `features/*/screens`.
|
3. Add route groups when chat modules are introduced, for example `app/(chat)` and `app/(auth)`.
|
||||||
|
|||||||
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={30} />
|
||||||
|
</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",
|
||||||
|
},
|
||||||
|
});
|
||||||
76
features/im/components/conversation-list.tsx
Normal file
76
features/im/components/conversation-list.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { RefreshControl, 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;
|
||||||
|
isRefreshing?: boolean;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onPressItem?: (item: ConversationItem) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConversationList({
|
||||||
|
data,
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
onRefresh,
|
||||||
|
onPressItem,
|
||||||
|
}: ConversationListProps) {
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
alwaysBounceVertical
|
||||||
|
bounces
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
data={data}
|
||||||
|
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 }) => (
|
||||||
|
<ConversationListItem item={item} onPress={onPressItem} />
|
||||||
|
)}
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
contentContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
87
features/im/components/conversation-search-bar.tsx
Normal file
87
features/im/components/conversation-search-bar.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Animated, Easing, StyleSheet, TextInput } from "react-native";
|
||||||
|
|
||||||
|
type ConversationSearchBarProps = {
|
||||||
|
value: string;
|
||||||
|
onChangeText: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConversationSearchBar({
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
}: 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 (
|
||||||
|
<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} />
|
||||||
|
<TextInput
|
||||||
|
onBlur={() => {
|
||||||
|
setIsFocused(false);
|
||||||
|
animateFocus(0);
|
||||||
|
}}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
onFocus={() => {
|
||||||
|
setIsFocused(true);
|
||||||
|
animateFocus(1);
|
||||||
|
}}
|
||||||
|
placeholder="搜索联系人备注/昵称/ID"
|
||||||
|
placeholderTextColor="#B8B8BC"
|
||||||
|
style={styles.input}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#F0F0F2",
|
||||||
|
borderColor: "transparent",
|
||||||
|
borderCurve: "continuous",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 20,
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
height: 52,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
wrapperFocused: {
|
||||||
|
backgroundColor: "#F4FAF8",
|
||||||
|
borderColor: "#BEEEDB",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
color: "#2A2A2A",
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "400",
|
||||||
|
},
|
||||||
|
});
|
||||||
208
features/im/hooks/use-conversation-list.ts
Normal file
208
features/im/hooks/use-conversation-list.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
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 [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadConversations = useCallback(async (mode: "initial" | "refresh" | "silent") => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
if (mode === "initial") {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
if (mode === "refresh") {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if (mode === "refresh") {
|
||||||
|
const elapsed = Date.now() - startedAt;
|
||||||
|
const minVisibleMs = 420;
|
||||||
|
if (elapsed < minVisibleMs) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, minVisibleMs - elapsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
await loadConversations("refresh");
|
||||||
|
}, [loadConversations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConversations("initial");
|
||||||
|
}, [loadConversations]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onConversationChanged = (changed: OpenIMConversationItem[]) => {
|
||||||
|
setConversations((prev) => upsertConversations(prev, changed));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onNewConversation = (created: OpenIMConversationItem[]) => {
|
||||||
|
setConversations((prev) => upsertConversations(prev, created));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRecvNewMessages = () => {
|
||||||
|
loadConversations("silent");
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}, [loadConversations]);
|
||||||
|
|
||||||
|
const items = useMemo(() => conversations.map(toViewModel), [conversations]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
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]);
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
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;
|
||||||
|
};
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
|
|
||||||
@layer theme {
|
@layer theme {
|
||||||
@theme {
|
@theme {
|
||||||
--color-brand-50: #f5f7ff;
|
--color-brand-50: #e9fbf4;
|
||||||
--color-brand-100: #e9edff;
|
--color-brand-100: #c7f5e3;
|
||||||
--color-brand-500: #4f6bd9;
|
--color-brand-500: #00d48c;
|
||||||
--color-brand-700: #3147a6;
|
--color-brand-700: #009d68;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { useColorScheme } from 'react-native';
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
|
||||||
*/
|
|
||||||
export function useColorScheme() {
|
|
||||||
const [hasHydrated, setHasHydrated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasHydrated(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const colorScheme = useRNColorScheme();
|
|
||||||
|
|
||||||
if (hasHydrated) {
|
|
||||||
return colorScheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Learn more about light and dark modes:
|
|
||||||
* https://docs.expo.dev/guides/color-schemes/
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Colors } from '@/constants/theme';
|
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
|
||||||
|
|
||||||
export function useThemeColor(
|
|
||||||
props: { light?: string; dark?: string },
|
|
||||||
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
|
||||||
) {
|
|
||||||
const theme = useColorScheme() ?? 'light';
|
|
||||||
const colorFromProps = props[theme];
|
|
||||||
|
|
||||||
if (colorFromProps) {
|
|
||||||
return colorFromProps;
|
|
||||||
} else {
|
|
||||||
return Colors[theme][colorName];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
3
nativewind-env.d.ts
vendored
Normal file
3
nativewind-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/// <reference types="react-native-css/types" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.
|
||||||
@@ -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':
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This script is used to reset the project to a blank state.
|
|
||||||
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
|
|
||||||
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const readline = require("readline");
|
|
||||||
|
|
||||||
const root = process.cwd();
|
|
||||||
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
|
|
||||||
const exampleDir = "app-example";
|
|
||||||
const newAppDir = "app";
|
|
||||||
const exampleDirPath = path.join(root, exampleDir);
|
|
||||||
|
|
||||||
const indexContent = `import { Text, View } from "react-native";
|
|
||||||
|
|
||||||
export default function Index() {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text>Edit app/index.tsx to edit this screen.</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const layoutContent = `import { Stack } from "expo-router";
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
|
||||||
return <Stack />;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: process.stdin,
|
|
||||||
output: process.stdout,
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveDirectories = async (userInput) => {
|
|
||||||
try {
|
|
||||||
if (userInput === "y") {
|
|
||||||
// Create the app-example directory
|
|
||||||
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
|
||||||
console.log(`📁 /${exampleDir} directory created.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move old directories to new app-example directory or delete them
|
|
||||||
for (const dir of oldDirs) {
|
|
||||||
const oldDirPath = path.join(root, dir);
|
|
||||||
if (fs.existsSync(oldDirPath)) {
|
|
||||||
if (userInput === "y") {
|
|
||||||
const newDirPath = path.join(root, exampleDir, dir);
|
|
||||||
await fs.promises.rename(oldDirPath, newDirPath);
|
|
||||||
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
|
||||||
} else {
|
|
||||||
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
|
||||||
console.log(`❌ /${dir} deleted.`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(`➡️ /${dir} does not exist, skipping.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new /app directory
|
|
||||||
const newAppDirPath = path.join(root, newAppDir);
|
|
||||||
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
|
||||||
console.log("\n📁 New /app directory created.");
|
|
||||||
|
|
||||||
// Create index.tsx
|
|
||||||
const indexPath = path.join(newAppDirPath, "index.tsx");
|
|
||||||
await fs.promises.writeFile(indexPath, indexContent);
|
|
||||||
console.log("📄 app/index.tsx created.");
|
|
||||||
|
|
||||||
// Create _layout.tsx
|
|
||||||
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
|
||||||
await fs.promises.writeFile(layoutPath, layoutContent);
|
|
||||||
console.log("📄 app/_layout.tsx created.");
|
|
||||||
|
|
||||||
console.log("\n✅ Project reset complete. Next steps:");
|
|
||||||
console.log(
|
|
||||||
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
|
|
||||||
userInput === "y"
|
|
||||||
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
|
|
||||||
: ""
|
|
||||||
}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Error during script execution: ${error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
rl.question(
|
|
||||||
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
|
|
||||||
(answer) => {
|
|
||||||
const userInput = answer.trim().toLowerCase() || "y";
|
|
||||||
if (userInput === "y" || userInput === "n") {
|
|
||||||
moveDirectories(userInput).finally(() => rl.close());
|
|
||||||
} else {
|
|
||||||
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -12,6 +12,10 @@
|
|||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".expo/types/**/*.ts",
|
".expo/types/**/*.ts",
|
||||||
"expo-env.d.ts"
|
"expo-env.d.ts",
|
||||||
|
"nativewind-env.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"app-example/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user