feat: complete production chat demo — all screens, real media, SDK remote init
- App.tsx: replaced dev console with AuthProvider + AppNavigator
- Auth: Login, Register, ResetPassword screens via demo-service
- Conversations: reactive WatermelonDB subscription with user profile enrichment
- Chat: SingleChat + GroupChat with full media (image/video/audio/file), revoke, pull-up load more
- Contacts: local contacts list + UserSearch with debounced fuzzy search
- Groups: GroupList, CreateGroup (fuzzy member picker), GroupMembers, GroupSettings
- Profile: view + EditProfile (nickname, gender)
- MessageSearch: local DB full-text search across conversations
- ChatInput: text, 20-emoji picker, image/video/audio/file send, tap-to-record audio
- DisconnectBanner: connection status with reconnect
- AuthContext: uses await XuqmSDK.initialize({ appId, serverUrl }) for remote config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
37c591be9d
当前提交
e5adc00b44
373
App.tsx
373
App.tsx
@ -1,375 +1,22 @@
|
|||||||
import React, {startTransition, useEffect, useRef, useState} from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import {
|
import { StatusBar, useColorScheme } from 'react-native'
|
||||||
Alert,
|
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||||
Pressable,
|
import { UpdateSDK } from '@xuqm/rn-sdk'
|
||||||
ScrollView,
|
import { AuthProvider } from './src/context/AuthContext'
|
||||||
StatusBar,
|
import AppNavigator from './src/navigation/AppNavigator'
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
useColorScheme,
|
|
||||||
View,
|
|
||||||
} from 'react-native'
|
|
||||||
import {SafeAreaProvider, SafeAreaView} from 'react-native-safe-area-context'
|
|
||||||
import {ImSDK, type ImEventListener, type ImMessage, type MsgType, UpdateSDK, XuqmSDK} from '@xuqm/rn-sdk'
|
|
||||||
import {MessageBubble} from './src/components/MessageBubble'
|
|
||||||
import {DEMO_CONTENT, MessageComposer} from './src/components/MessageComposer'
|
|
||||||
import {GroupChatPanel} from './src/components/GroupChatPanel'
|
|
||||||
import {UpdatePanel} from './src/components/UpdatePanel'
|
|
||||||
import pluginMeta from './plugin.json'
|
import pluginMeta from './plugin.json'
|
||||||
|
|
||||||
// Register this bundle as a plugin so UpdateSDK.checkRnUpdate() knows the current version.
|
|
||||||
UpdateSDK.registerPlugin(pluginMeta)
|
UpdateSDK.registerPlugin(pluginMeta)
|
||||||
|
|
||||||
// Dev fallback: set app versionCode for simulators where native module is not linked.
|
|
||||||
UpdateSDK._devSetAppVersion(1, '1.0.0')
|
UpdateSDK._devSetAppVersion(1, '1.0.0')
|
||||||
|
|
||||||
const APP_ID = 'ak_demo_chat'
|
export default function App() {
|
||||||
const MODULE_ID = pluginMeta.moduleId
|
|
||||||
|
|
||||||
type DemoUser = {id: string; nickname: string}
|
|
||||||
|
|
||||||
const DEMO_USERS: DemoUser[] = [
|
|
||||||
{id: 'demo_alice', nickname: 'Alice'},
|
|
||||||
{id: 'demo_bob', nickname: 'Bob'},
|
|
||||||
]
|
|
||||||
|
|
||||||
const ALL_DEMO_TYPES: MsgType[] = [
|
|
||||||
'TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'FILE', 'CUSTOM',
|
|
||||||
'LOCATION', 'NOTIFY', 'RICH_TEXT', 'CALL_AUDIO', 'CALL_VIDEO', 'FORWARD',
|
|
||||||
]
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const isDarkMode = useColorScheme() === 'dark'
|
const isDarkMode = useColorScheme() === 'dark'
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<AuthProvider>
|
||||||
<DemoConsole />
|
<AppNavigator />
|
||||||
</SafeAreaView>
|
</AuthProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DemoConsole() {
|
|
||||||
const [currentUser, setCurrentUser] = useState<DemoUser>(DEMO_USERS[0])
|
|
||||||
const [connected, setConnected] = useState(false)
|
|
||||||
const [singleMessages, setSingleMessages] = useState<ImMessage[]>([])
|
|
||||||
const [groupMessages, setGroupMessages] = useState<ImMessage[]>([])
|
|
||||||
const [sending, setSending] = useState(false)
|
|
||||||
const [activityLog, setActivityLog] = useState<string[]>([])
|
|
||||||
const activeUserRef = useRef(currentUser)
|
|
||||||
const peerUser = DEMO_USERS.find(u => u.id !== currentUser.id) ?? DEMO_USERS[1]
|
|
||||||
|
|
||||||
const appendLog = (msg: string) => {
|
|
||||||
startTransition(() => {
|
|
||||||
setActivityLog(prev =>
|
|
||||||
[`[${new Date().toLocaleTimeString()}] ${msg}`, ...prev].slice(0, 30),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeSingle = (message: ImMessage) => {
|
|
||||||
startTransition(() => {
|
|
||||||
setSingleMessages(prev => {
|
|
||||||
const exists = prev.some(m => m.id === message.id)
|
|
||||||
if (exists) return prev.map(m => m.id === message.id ? message : m)
|
|
||||||
return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeGroup = (message: ImMessage) => {
|
|
||||||
startTransition(() => {
|
|
||||||
setGroupMessages(prev => {
|
|
||||||
const exists = prev.some(m => m.id === message.id)
|
|
||||||
if (exists) return prev.map(m => m.id === message.id ? message : m)
|
|
||||||
return [...prev, message].sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
XuqmSDK.init({
|
|
||||||
appId: APP_ID,
|
|
||||||
debug: __DEV__,
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
activeUserRef.current = currentUser
|
|
||||||
}, [currentUser])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const listener: ImEventListener = {
|
|
||||||
onConnected() {
|
|
||||||
setConnected(true)
|
|
||||||
appendLog(`WebSocket 已连接:${activeUserRef.current.nickname}`)
|
|
||||||
},
|
|
||||||
onDisconnected(reason) {
|
|
||||||
setConnected(false)
|
|
||||||
appendLog(`WebSocket 断开:${reason || 'unknown'}`)
|
|
||||||
},
|
|
||||||
onMessage(msg) {
|
|
||||||
if (msg.chatType === 'SINGLE') mergeSingle(msg)
|
|
||||||
else mergeGroup(msg)
|
|
||||||
appendLog(`收到 [${msg.chatType}/${msg.msgType}] from ${msg.fromUserId}`)
|
|
||||||
},
|
|
||||||
onGroupMessage(msg) {
|
|
||||||
mergeGroup(msg)
|
|
||||||
appendLog(`收到群消息 [${msg.msgType}]`)
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
appendLog(`IM 错误:${error}`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
ImSDK.addListener(listener)
|
|
||||||
return () => {
|
|
||||||
ImSDK.removeListener(listener)
|
|
||||||
ImSDK.disconnect()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const run = async () => {
|
|
||||||
try {
|
|
||||||
ImSDK.disconnect()
|
|
||||||
setConnected(false)
|
|
||||||
setSingleMessages([])
|
|
||||||
await ImSDK.login(currentUser.id, currentUser.nickname)
|
|
||||||
appendLog(`已登录 IM:${currentUser.nickname}`)
|
|
||||||
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
|
||||||
history.slice().reverse().forEach(m => mergeSingle(m))
|
|
||||||
setConnected(ImSDK.isConnected())
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : 'IM 登录失败'
|
|
||||||
appendLog(`连接失败:${msg}`)
|
|
||||||
Alert.alert('IM 登录失败', msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run()
|
|
||||||
}, [currentUser, peerUser.id])
|
|
||||||
|
|
||||||
const handleSendSingle = async (msgType: MsgType, content: string) => {
|
|
||||||
setSending(true)
|
|
||||||
try {
|
|
||||||
const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', msgType, content)
|
|
||||||
mergeSingle(sent)
|
|
||||||
appendLog(`已发送 [${msgType}] 给 ${peerUser.nickname}`)
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '发送失败'
|
|
||||||
appendLog(`发送失败 [${msgType}]:${msg}`)
|
|
||||||
Alert.alert('发送失败', msg)
|
|
||||||
} finally {
|
|
||||||
setSending(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSendAllSingle = async () => {
|
|
||||||
setSending(true)
|
|
||||||
appendLog(`开始发送全部 ${ALL_DEMO_TYPES.length} 种消息类型(单聊)…`)
|
|
||||||
for (const type of ALL_DEMO_TYPES) {
|
|
||||||
const content = DEMO_CONTENT[type]
|
|
||||||
if (!content) continue
|
|
||||||
try {
|
|
||||||
const sent = await ImSDK.sendMessage(peerUser.id, 'SINGLE', type, content)
|
|
||||||
mergeSingle(sent)
|
|
||||||
appendLog(`✓ [${type}]`)
|
|
||||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
|
||||||
} catch (e) {
|
|
||||||
appendLog(`✗ [${type}]:${e instanceof Error ? e.message : 'error'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
appendLog('单聊全类型演示完成')
|
|
||||||
setSending(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRevokeSingle = async (messageId: string) => {
|
|
||||||
try {
|
|
||||||
const revoked = await ImSDK.revokeMessage(messageId)
|
|
||||||
mergeSingle(revoked)
|
|
||||||
appendLog(`消息已撤回:${messageId}`)
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '撤回失败'
|
|
||||||
Alert.alert('撤回失败', msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReloadSingle = async () => {
|
|
||||||
try {
|
|
||||||
const history = await ImSDK.fetchHistory(peerUser.id, 0, 50)
|
|
||||||
setSingleMessages(history.slice().reverse())
|
|
||||||
appendLog(`历史消息已刷新,共 ${history.length} 条`)
|
|
||||||
} catch (e) {
|
|
||||||
appendLog(`历史拉取失败:${e instanceof Error ? e.message : 'error'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
|
||||||
<Text style={styles.eyebrow}>XuqmGroup RN SDK Demo</Text>
|
|
||||||
<Text style={styles.title}>IM · 全消息类型 · 群聊 · App 更新 · 热更新</Text>
|
|
||||||
<Text style={styles.subtitle}>
|
|
||||||
演示单聊/群聊全消息类型、整包更新和 RN 插件热更新完整流程。
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Section 1: 当前会话 */}
|
|
||||||
<SectionCard title="1. 当前会话">
|
|
||||||
<View style={styles.userRow}>
|
|
||||||
{DEMO_USERS.map(user => {
|
|
||||||
const active = user.id === currentUser.id
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={user.id}
|
|
||||||
onPress={() => setCurrentUser(user)}
|
|
||||||
style={[styles.userChip, active && styles.userChipActive]}>
|
|
||||||
<Text style={[styles.userChipText, active && styles.userChipTextActive]}>
|
|
||||||
{user.nickname}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.meta}>当前用户:{currentUser.nickname}({currentUser.id})</Text>
|
|
||||||
<Text style={styles.meta}>单聊对象:{peerUser.nickname}({peerUser.id})</Text>
|
|
||||||
<View style={styles.statusRow}>
|
|
||||||
<View style={[styles.dot, connected ? styles.dotGreen : styles.dotGray]} />
|
|
||||||
<Text style={styles.meta}>{connected ? 'IM 已连接' : '连接中 / 未连接'}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.row}>
|
|
||||||
<PrimaryBtn title="刷新历史" onPress={handleReloadSingle} />
|
|
||||||
<GhostBtn title="重新登录" onPress={() => {
|
|
||||||
ImSDK.disconnect()
|
|
||||||
setConnected(false)
|
|
||||||
setSingleMessages([])
|
|
||||||
ImSDK.login(currentUser.id, currentUser.nickname)
|
|
||||||
.then(() => ImSDK.fetchHistory(peerUser.id, 0, 50))
|
|
||||||
.then(h => { h.slice().reverse().forEach(m => mergeSingle(m)); setConnected(ImSDK.isConnected()) })
|
|
||||||
.catch(e => appendLog(`重登录失败:${e instanceof Error ? e.message : 'error'}`))
|
|
||||||
}} />
|
|
||||||
</View>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Section 2: 单聊 */}
|
|
||||||
<SectionCard title="2. 单聊演示(全 12 种消息类型)">
|
|
||||||
<Text style={styles.hint}>长按自己的消息可撤回。"全部类型演示"依次发送所有 12 种消息类型。</Text>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.chatPanel}
|
|
||||||
contentContainerStyle={styles.chatContent}
|
|
||||||
showsVerticalScrollIndicator={false}>
|
|
||||||
{singleMessages.length === 0 ? (
|
|
||||||
<Text style={styles.empty}>还没有消息,发一条试试看。</Text>
|
|
||||||
) : (
|
|
||||||
singleMessages.map(m => (
|
|
||||||
<MessageBubble
|
|
||||||
key={m.id}
|
|
||||||
message={m}
|
|
||||||
isOwn={m.fromUserId === currentUser.id}
|
|
||||||
peerNickname={peerUser.nickname}
|
|
||||||
onRevoke={handleRevokeSingle}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
<MessageComposer
|
|
||||||
onSend={handleSendSingle}
|
|
||||||
onSendAll={handleSendAllSingle}
|
|
||||||
disabled={sending || !connected}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Section 3: 群聊 */}
|
|
||||||
<SectionCard title="3. 群聊演示">
|
|
||||||
<Text style={styles.hint}>
|
|
||||||
点击"创建演示群"创建包含 Alice+Bob 的群组,"加载群列表"列出已有群,然后即可发送群消息。
|
|
||||||
</Text>
|
|
||||||
<GroupChatPanel
|
|
||||||
currentUserId={currentUser.id}
|
|
||||||
peerUserId={peerUser.id}
|
|
||||||
peerNickname={peerUser.nickname}
|
|
||||||
appId={APP_ID}
|
|
||||||
onLog={appendLog}
|
|
||||||
groupMessages={groupMessages}
|
|
||||||
onMergeMessage={mergeGroup}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Section 4: 更新演示 */}
|
|
||||||
<SectionCard title="4. 更新演示">
|
|
||||||
<UpdatePanel
|
|
||||||
rnModuleId={MODULE_ID}
|
|
||||||
onLog={appendLog}
|
|
||||||
/>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Section 5: 活动日志 */}
|
|
||||||
<SectionCard title="5. 活动日志">
|
|
||||||
{activityLog.length === 0 ? (
|
|
||||||
<Text style={styles.empty}>暂无日志</Text>
|
|
||||||
) : (
|
|
||||||
activityLog.map((item, i) => (
|
|
||||||
<Text key={i} style={styles.logLine}>{item}</Text>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
</ScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionCard({title, children}: {title: string; children: React.ReactNode}) {
|
|
||||||
return (
|
|
||||||
<View style={styles.card}>
|
|
||||||
<Text style={styles.cardTitle}>{title}</Text>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PrimaryBtn({title, onPress, disabled}: {title: string; onPress: () => void; disabled?: boolean}) {
|
|
||||||
return (
|
|
||||||
<Pressable onPress={onPress} disabled={disabled} style={[styles.primaryBtn, disabled && styles.btnDisabled]}>
|
|
||||||
<Text style={styles.primaryBtnText}>{title}</Text>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GhostBtn({title, onPress}: {title: string; onPress: () => void}) {
|
|
||||||
return (
|
|
||||||
<Pressable onPress={onPress} style={styles.ghostBtn}>
|
|
||||||
<Text style={styles.ghostBtnText}>{title}</Text>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
safeArea: {flex: 1, backgroundColor: '#f5efe3'},
|
|
||||||
scroll: {flex: 1, backgroundColor: '#f5efe3'},
|
|
||||||
content: {padding: 18, gap: 14, paddingBottom: 40},
|
|
||||||
eyebrow: {fontSize: 13, fontWeight: '700', color: '#915f34', textTransform: 'uppercase', letterSpacing: 1.1},
|
|
||||||
title: {fontSize: 26, lineHeight: 32, fontWeight: '800', color: '#1f2933'},
|
|
||||||
subtitle: {fontSize: 13, lineHeight: 20, color: '#4b5563'},
|
|
||||||
card: {borderRadius: 24, padding: 16, backgroundColor: '#fffaf2', borderWidth: 1, borderColor: '#ead7b7', gap: 12},
|
|
||||||
cardTitle: {fontSize: 17, fontWeight: '800', color: '#1f2933'},
|
|
||||||
hint: {fontSize: 13, lineHeight: 19, color: '#6b7280'},
|
|
||||||
userRow: {flexDirection: 'row', gap: 10},
|
|
||||||
userChip: {paddingHorizontal: 16, paddingVertical: 10, borderRadius: 999, backgroundColor: '#efe2c5'},
|
|
||||||
userChipActive: {backgroundColor: '#1f2933'},
|
|
||||||
userChipText: {color: '#4b5563', fontWeight: '700'},
|
|
||||||
userChipTextActive: {color: '#fffdf8'},
|
|
||||||
statusRow: {flexDirection: 'row', alignItems: 'center', gap: 6},
|
|
||||||
dot: {width: 8, height: 8, borderRadius: 4},
|
|
||||||
dotGreen: {backgroundColor: '#22c55e'},
|
|
||||||
dotGray: {backgroundColor: '#9ca3af'},
|
|
||||||
meta: {fontSize: 13, color: '#4b5563'},
|
|
||||||
row: {flexDirection: 'row', gap: 10},
|
|
||||||
chatPanel: {maxHeight: 340, borderRadius: 18, backgroundColor: '#f4ecdf'},
|
|
||||||
chatContent: {padding: 12, gap: 6},
|
|
||||||
empty: {fontSize: 14, color: '#7c7f85', textAlign: 'center', paddingVertical: 20},
|
|
||||||
primaryBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1f2933', paddingHorizontal: 16},
|
|
||||||
primaryBtnText: {color: '#fffdf8', fontSize: 14, fontWeight: '800'},
|
|
||||||
ghostBtn: {minHeight: 44, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: '#efe2c5', paddingHorizontal: 16},
|
|
||||||
ghostBtnText: {color: '#4b5563', fontSize: 14, fontWeight: '700'},
|
|
||||||
btnDisabled: {opacity: 0.45},
|
|
||||||
logLine: {fontSize: 11, lineHeight: 17, color: '#374151', fontFamily: 'monospace'},
|
|
||||||
})
|
|
||||||
|
|
||||||
export default App
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
presets: ['module:@react-native/babel-preset'],
|
presets: ['module:@react-native/babel-preset'],
|
||||||
|
plugins: [['@babel/plugin-proposal-decorators', { legacy: true }]],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,11 +1,19 @@
|
|||||||
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
|
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
/**
|
const sdkRoot = path.resolve(__dirname, 'node_modules/@xuqm/rn-sdk');
|
||||||
* Metro configuration
|
|
||||||
* https://reactnative.dev/docs/metro
|
const config = {
|
||||||
*
|
resolver: {
|
||||||
* @type {import('@react-native/metro-config').MetroConfig}
|
extraNodeModules: {
|
||||||
*/
|
'@xuqm/rn-common': path.join(sdkRoot, 'packages/common'),
|
||||||
const config = {};
|
'@xuqm/rn-im': path.join(sdkRoot, 'packages/im'),
|
||||||
|
'@xuqm/rn-push': path.join(sdkRoot, 'packages/push'),
|
||||||
|
'@xuqm/rn-update': path.join(sdkRoot, 'packages/update'),
|
||||||
|
// WatermelonDB is a peerDep of rn-im; resolve from demo's node_modules
|
||||||
|
'@nozbe/watermelondb': path.resolve(__dirname, 'node_modules/@nozbe/watermelondb'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
|
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
|
||||||
|
|||||||
13
package.json
13
package.json
@ -10,15 +10,24 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nozbe/watermelondb": "^0.28.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"@react-native/new-app-screen": "0.85.2",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.15.10",
|
||||||
|
"@react-navigation/native": "^7.2.2",
|
||||||
|
"@react-navigation/native-stack": "^7.14.12",
|
||||||
"@xuqm/rn-sdk": "file:../XuqmGroup-RNSDK",
|
"@xuqm/rn-sdk": "file:../XuqmGroup-RNSDK",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.2",
|
"react-native": "0.85.2",
|
||||||
"@react-native/new-app-screen": "0.85.2",
|
"react-native-audio-recorder-player": "^4.5.0",
|
||||||
"react-native-safe-area-context": "^5.5.2"
|
"react-native-document-picker": "^9.3.1",
|
||||||
|
"react-native-image-picker": "^8.2.1",
|
||||||
|
"react-native-safe-area-context": "^5.5.2",
|
||||||
|
"react-native-screens": "^4.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
"@babel/plugin-proposal-decorators": "^7.29.0",
|
||||||
"@babel/preset-env": "^7.25.3",
|
"@babel/preset-env": "^7.25.3",
|
||||||
"@babel/runtime": "^7.25.0",
|
"@babel/runtime": "^7.25.0",
|
||||||
"@react-native-community/cli": "20.1.0",
|
"@react-native-community/cli": "20.1.0",
|
||||||
|
|||||||
85
src/api/demo.ts
普通文件
85
src/api/demo.ts
普通文件
@ -0,0 +1,85 @@
|
|||||||
|
import { load, K } from '../utils/storage'
|
||||||
|
|
||||||
|
const BASE = 'https://sentry.xuqinmin.com/api/demo'
|
||||||
|
const APP_ID = 'ak_demo_chat'
|
||||||
|
|
||||||
|
async function getToken(): Promise<string | null> {
|
||||||
|
return load<string>(K.DEMO_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: { method?: string; body?: unknown; params?: Record<string, string>; skipAuth?: boolean } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
let url = BASE + path
|
||||||
|
const params = { appId: APP_ID, ...(options.params ?? {}) }
|
||||||
|
url += '?' + new URLSearchParams(params).toString()
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (!options.skipAuth) {
|
||||||
|
const token = await getToken()
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: options.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = await res.json()
|
||||||
|
if (!res.ok) throw new Error(json.message ?? `HTTP ${res.status}`)
|
||||||
|
return (json.data ?? json) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
userId: string
|
||||||
|
nickname: string
|
||||||
|
avatar: string
|
||||||
|
gender: 'UNKNOWN' | 'MALE' | 'FEMALE'
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
demoToken: string
|
||||||
|
imToken: string
|
||||||
|
profile: UserProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
export const demoApi = {
|
||||||
|
register(userId: string, password: string, nickname: string): Promise<AuthResult> {
|
||||||
|
return request('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
skipAuth: true,
|
||||||
|
body: { appId: APP_ID, userId, password, nickname },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
login(userId: string, password: string): Promise<AuthResult> {
|
||||||
|
return request('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
skipAuth: true,
|
||||||
|
body: { appId: APP_ID, userId, password },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getProfile(): Promise<UserProfile> {
|
||||||
|
return request('/user/profile')
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProfile(data: Partial<Pick<UserProfile, 'nickname' | 'avatar' | 'gender'>>): Promise<UserProfile> {
|
||||||
|
return request('/user/profile', { method: 'PUT', body: data })
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPassword(userId: string, newPassword: string): Promise<void> {
|
||||||
|
return request('/auth/reset-password', { method: 'POST', skipAuth: true, body: { appId: APP_ID, userId, newPassword } })
|
||||||
|
},
|
||||||
|
|
||||||
|
changePassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||||
|
return request('/user/change-password', { method: 'POST', body: { oldPassword, newPassword } })
|
||||||
|
},
|
||||||
|
|
||||||
|
searchUsers(keyword: string): Promise<UserProfile[]> {
|
||||||
|
return request('/users/search', { params: { keyword } })
|
||||||
|
},
|
||||||
|
}
|
||||||
201
src/components/ChatInput.tsx
普通文件
201
src/components/ChatInput.tsx
普通文件
@ -0,0 +1,201 @@
|
|||||||
|
import React, { useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, StyleSheet, Alert,
|
||||||
|
Platform, ActivityIndicator,
|
||||||
|
} from 'react-native'
|
||||||
|
import { launchImageLibrary } from 'react-native-image-picker'
|
||||||
|
import DocumentPicker from 'react-native-document-picker'
|
||||||
|
import AudioRecorderPlayer from 'react-native-audio-recorder-player'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
|
import type { ChatType, ImMessage } from '@xuqm/rn-sdk'
|
||||||
|
|
||||||
|
const EMOJI_LIST = [
|
||||||
|
'😀','😂','🥹','😍','🥰','😎','😭','😤','🤔','😴',
|
||||||
|
'👍','👎','❤️','🔥','🎉','💯','🙏','✅','😊','🤣',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
toId: string
|
||||||
|
chatType: ChatType
|
||||||
|
onSent(msg: ImMessage): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorder = new AudioRecorderPlayer()
|
||||||
|
|
||||||
|
export default function ChatInput({ toId, chatType, onSent }: Props) {
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [showEmoji, setShowEmoji] = useState(false)
|
||||||
|
const [recording, setRecording] = useState(false)
|
||||||
|
const [recordPath, setRecordPath] = useState<string | null>(null)
|
||||||
|
const recordStart = useRef<number>(0)
|
||||||
|
const inputRef = useRef<TextInput>(null)
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const t = text.trim()
|
||||||
|
if (!t || sending) return
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const msg = await ImSDK.sendMessage(toId, chatType, 'TEXT', t)
|
||||||
|
setText('')
|
||||||
|
onSent(msg)
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('发送失败', e?.message ?? '请重试')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendImage = async () => {
|
||||||
|
const result = await launchImageLibrary({ mediaType: 'photo', quality: 0.85, includeExtra: true })
|
||||||
|
if (!result.assets?.length) return
|
||||||
|
const asset = result.assets[0]
|
||||||
|
if (!asset.uri) return
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const msg = await ImSDK.sendImageMessage(toId, chatType, asset.uri, asset.width, asset.height)
|
||||||
|
onSent(msg)
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('发送失败', e?.message ?? '图片上传失败')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendVideo = async () => {
|
||||||
|
const result = await launchImageLibrary({ mediaType: 'video', includeExtra: true })
|
||||||
|
if (!result.assets?.length) return
|
||||||
|
const asset = result.assets[0]
|
||||||
|
if (!asset.uri) return
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const msg = await ImSDK.sendVideoMessage(toId, chatType, asset.uri, undefined, asset.duration)
|
||||||
|
onSent(msg)
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('发送失败', e?.message ?? '视频上传失败')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendFile = async () => {
|
||||||
|
try {
|
||||||
|
const res = await DocumentPicker.pickSingle({ type: [DocumentPicker.types.allFiles] })
|
||||||
|
if (!res.uri) return
|
||||||
|
setSending(true)
|
||||||
|
const msg = await ImSDK.sendFileMessage(toId, chatType, res.uri, res.name ?? 'file', res.size ?? 0)
|
||||||
|
onSent(msg)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!DocumentPicker.isCancel(e)) {
|
||||||
|
Alert.alert('发送失败', e?.message ?? '文件上传失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleRecord = async () => {
|
||||||
|
if (recording) {
|
||||||
|
const path = await recorder.stopRecorder()
|
||||||
|
recorder.removeRecordBackListener()
|
||||||
|
setRecording(false)
|
||||||
|
const duration = Math.round((Date.now() - recordStart.current) / 1000)
|
||||||
|
if (duration < 1) { Alert.alert('提示', '录音时间太短'); return }
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const msg = await ImSDK.sendAudioMessage(toId, chatType, path, duration)
|
||||||
|
onSent(msg)
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('发送失败', e?.message ?? '语音上传失败')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const path = Platform.OS === 'android' ? 'xuqm_audio.mp4' : 'xuqm_audio.m4a'
|
||||||
|
await recorder.startRecorder(path)
|
||||||
|
recorder.addRecordBackListener(() => {})
|
||||||
|
recordStart.current = Date.now()
|
||||||
|
setRecording(true)
|
||||||
|
setRecordPath(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertEmoji = (emoji: string) => {
|
||||||
|
setText(prev => prev + emoji)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{showEmoji && (
|
||||||
|
<View style={styles.emojiPanel}>
|
||||||
|
<View style={styles.emojiGrid}>
|
||||||
|
{EMOJI_LIST.map(e => (
|
||||||
|
<TouchableOpacity key={e} style={styles.emojiBtn} onPress={() => insertEmoji(e)}>
|
||||||
|
<Text style={styles.emojiText}>{e}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.bar}>
|
||||||
|
<TouchableOpacity style={styles.iconBtn} onPress={toggleRecord}>
|
||||||
|
<Text style={styles.icon}>{recording ? '⏹' : '🎤'}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
style={styles.input}
|
||||||
|
placeholder={recording ? '松开停止录音...' : '输入消息'}
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
multiline
|
||||||
|
editable={!recording && !sending}
|
||||||
|
onFocus={() => setShowEmoji(false)}
|
||||||
|
returnKeyType="default"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.iconBtn} onPress={() => setShowEmoji(v => !v)}>
|
||||||
|
<Text style={styles.icon}>😊</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.iconBtn} onPress={sendImage}>
|
||||||
|
<Text style={styles.icon}>🖼️</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.iconBtn} onPress={sendVideo}>
|
||||||
|
<Text style={styles.icon}>🎬</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.iconBtn} onPress={sendFile}>
|
||||||
|
<Text style={styles.icon}>📎</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{sending
|
||||||
|
? <ActivityIndicator color="#07C160" style={styles.iconBtn} />
|
||||||
|
: (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.sendBtn, !text.trim() && styles.sendBtnDisabled]}
|
||||||
|
onPress={send}
|
||||||
|
disabled={!text.trim()}
|
||||||
|
>
|
||||||
|
<Text style={styles.sendBtnText}>发送</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
bar: { flexDirection: 'row', alignItems: 'flex-end', padding: 8, gap: 6, backgroundColor: '#fff', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e0e0e0' },
|
||||||
|
input: { flex: 1, maxHeight: 100, minHeight: 36, borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 18, paddingHorizontal: 12, paddingVertical: 8, fontSize: 15, backgroundColor: '#f9f9f9' },
|
||||||
|
iconBtn: { width: 32, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
icon: { fontSize: 20 },
|
||||||
|
sendBtn: { paddingHorizontal: 14, height: 36, backgroundColor: '#07C160', borderRadius: 18, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
sendBtnDisabled: { backgroundColor: '#b2dfdb' },
|
||||||
|
sendBtnText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||||
|
emojiPanel: { backgroundColor: '#f9f9f9', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e0e0e0' },
|
||||||
|
emojiGrid: { flexDirection: 'row', flexWrap: 'wrap', padding: 8 },
|
||||||
|
emojiBtn: { width: '10%', aspectRatio: 1, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
emojiText: { fontSize: 24 },
|
||||||
|
})
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
|
||||||
|
import type { ConversationData } from '@xuqm/rn-sdk'
|
||||||
|
import { formatTime } from '../utils/format'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: ConversationData & { targetName?: string; targetAvatar?: string }
|
||||||
|
onPress(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
function Avatar({ name, uri }: { name: string; uri?: string }) {
|
||||||
|
const letter = (name || '?').charAt(0).toUpperCase()
|
||||||
|
return (
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>{letter}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastMsgPreview(item: ConversationData): string {
|
||||||
|
switch (item.lastMsgType) {
|
||||||
|
case 'IMAGE': return '[图片]'
|
||||||
|
case 'VIDEO': return '[视频]'
|
||||||
|
case 'AUDIO': return '[语音]'
|
||||||
|
case 'FILE': return '[文件]'
|
||||||
|
default: return item.lastMsgContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversationItem({ item, onPress }: Props) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.7}>
|
||||||
|
<Avatar name={item.targetName ?? item.targetId} uri={item.targetAvatar} />
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.topRow}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>{item.targetName ?? item.targetId}</Text>
|
||||||
|
<Text style={styles.time}>{item.lastMsgTime ? formatTime(item.lastMsgTime) : ''}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.bottomRow}>
|
||||||
|
<Text style={[styles.preview, item.isMuted && styles.muted]} numberOfLines={1}>
|
||||||
|
{item.isMuted ? '[已静音] ' : ''}{lastMsgPreview(item)}
|
||||||
|
</Text>
|
||||||
|
{item.unreadCount > 0 && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{item.unreadCount > 99 ? '99+' : item.unreadCount}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
row: { flexDirection: 'row', padding: 12, alignItems: 'center', backgroundColor: '#fff' },
|
||||||
|
avatar: { width: 48, height: 48, borderRadius: 8, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||||
|
avatarText: { color: '#fff', fontSize: 20, fontWeight: '600' },
|
||||||
|
body: { flex: 1 },
|
||||||
|
topRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
||||||
|
name: { flex: 1, fontSize: 16, fontWeight: '500', color: '#111', marginRight: 8 },
|
||||||
|
time: { fontSize: 12, color: '#999' },
|
||||||
|
bottomRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
|
preview: { flex: 1, fontSize: 14, color: '#888', marginRight: 8 },
|
||||||
|
muted: { color: '#bbb' },
|
||||||
|
badge: { backgroundColor: '#ff3b30', borderRadius: 10, minWidth: 20, height: 20, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 4 },
|
||||||
|
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
||||||
|
})
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native'
|
||||||
|
import { useIM } from '../context/IMContext'
|
||||||
|
|
||||||
|
export default function DisconnectBanner() {
|
||||||
|
const { status, reconnect } = useIM()
|
||||||
|
if (status === 'connected') return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.banner}>
|
||||||
|
<Text style={styles.text}>{status === 'connecting' ? '连接中...' : '已断开连接'}</Text>
|
||||||
|
{status === 'disconnected' && (
|
||||||
|
<TouchableOpacity onPress={reconnect}>
|
||||||
|
<Text style={styles.retry}>重试</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
banner: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#ff9500',
|
||||||
|
paddingVertical: 6,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
text: { color: '#fff', fontSize: 13 },
|
||||||
|
retry: { color: '#fff', fontSize: 13, textDecorationLine: 'underline' },
|
||||||
|
})
|
||||||
98
src/context/AuthContext.tsx
普通文件
98
src/context/AuthContext.tsx
普通文件
@ -0,0 +1,98 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||||
|
import { XuqmSDK, ImSDK } from '@xuqm/rn-sdk'
|
||||||
|
import { demoApi, type UserProfile } from '../api/demo'
|
||||||
|
import { save, load, clearSession, K } from '../utils/storage'
|
||||||
|
|
||||||
|
const APP_ID = 'ak_demo_chat'
|
||||||
|
const SERVER_URL = 'https://sentry.xuqinmin.com'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
ready: boolean
|
||||||
|
userId: string | null
|
||||||
|
profile: UserProfile | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue extends AuthState {
|
||||||
|
login(userId: string, password: string): Promise<void>
|
||||||
|
register(userId: string, password: string, nickname: string): Promise<void>
|
||||||
|
logout(): Promise<void>
|
||||||
|
refreshProfile(): Promise<void>
|
||||||
|
updateProfile(data: Partial<Pick<UserProfile, 'nickname' | 'avatar' | 'gender'>>): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState<AuthState>({ ready: false, userId: null, profile: null })
|
||||||
|
|
||||||
|
const initSDK = useCallback(async (userId: string, imToken: string, profile: UserProfile) => {
|
||||||
|
await XuqmSDK.initialize({ appId: APP_ID, serverUrl: SERVER_URL })
|
||||||
|
await ImSDK.loginWithToken(userId, imToken, 'xuqm_im')
|
||||||
|
setState({ ready: true, userId, profile })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const restore = async () => {
|
||||||
|
try {
|
||||||
|
const demoToken = await load<string>(K.DEMO_TOKEN)
|
||||||
|
const imToken = await load<string>(K.IM_TOKEN)
|
||||||
|
const profile = await load<UserProfile>(K.PROFILE)
|
||||||
|
if (demoToken && imToken && profile) {
|
||||||
|
await initSDK(profile.userId, imToken, profile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await clearSession()
|
||||||
|
}
|
||||||
|
setState(s => ({ ...s, ready: true }))
|
||||||
|
}
|
||||||
|
restore()
|
||||||
|
}, [initSDK])
|
||||||
|
|
||||||
|
const handleAuthResult = useCallback(async (result: { demoToken: string; imToken: string; profile: UserProfile }) => {
|
||||||
|
await save(K.DEMO_TOKEN, result.demoToken)
|
||||||
|
await save(K.IM_TOKEN, result.imToken)
|
||||||
|
await save(K.PROFILE, result.profile)
|
||||||
|
await initSDK(result.profile.userId, result.imToken, result.profile)
|
||||||
|
}, [initSDK])
|
||||||
|
|
||||||
|
const login = useCallback(async (userId: string, password: string) => {
|
||||||
|
const result = await demoApi.login(userId, password)
|
||||||
|
await handleAuthResult(result)
|
||||||
|
}, [handleAuthResult])
|
||||||
|
|
||||||
|
const register = useCallback(async (userId: string, password: string, nickname: string) => {
|
||||||
|
const result = await demoApi.register(userId, password, nickname)
|
||||||
|
await handleAuthResult(result)
|
||||||
|
}, [handleAuthResult])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
ImSDK.disconnect()
|
||||||
|
await clearSession()
|
||||||
|
setState({ ready: true, userId: null, profile: null })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshProfile = useCallback(async () => {
|
||||||
|
const profile = await demoApi.getProfile()
|
||||||
|
await save(K.PROFILE, profile)
|
||||||
|
setState(s => ({ ...s, profile }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateProfile = useCallback(async (data: Partial<Pick<UserProfile, 'nickname' | 'avatar' | 'gender'>>) => {
|
||||||
|
const profile = await demoApi.updateProfile(data)
|
||||||
|
await save(K.PROFILE, profile)
|
||||||
|
setState(s => ({ ...s, profile }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ ...state, login, register, logout, refreshProfile, updateProfile }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
51
src/context/IMContext.tsx
普通文件
51
src/context/IMContext.tsx
普通文件
@ -0,0 +1,51 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ImSDK, type ImEventListener } from '@xuqm/rn-sdk'
|
||||||
|
import { useAuth } from './AuthContext'
|
||||||
|
|
||||||
|
type ConnStatus = 'connected' | 'connecting' | 'disconnected'
|
||||||
|
|
||||||
|
interface IMContextValue {
|
||||||
|
status: ConnStatus
|
||||||
|
reconnect(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMContext = createContext<IMContextValue>({ status: 'connecting', reconnect: () => {} })
|
||||||
|
|
||||||
|
export function IMProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { userId } = useAuth()
|
||||||
|
const [status, setStatus] = useState<ConnStatus>('connecting')
|
||||||
|
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const clearRetry = () => {
|
||||||
|
if (retryRef.current) { clearTimeout(retryRef.current); retryRef.current = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconnect = useCallback(() => {
|
||||||
|
setStatus('connecting')
|
||||||
|
ImSDK.reconnect().catch(() => setStatus('disconnected'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId) return
|
||||||
|
|
||||||
|
const listener: ImEventListener = {
|
||||||
|
onConnected() { clearRetry(); setStatus('connected') },
|
||||||
|
onDisconnected() {
|
||||||
|
setStatus('disconnected')
|
||||||
|
retryRef.current = setTimeout(reconnect, 5000)
|
||||||
|
},
|
||||||
|
onError() { setStatus('disconnected') },
|
||||||
|
}
|
||||||
|
ImSDK.addListener(listener)
|
||||||
|
return () => {
|
||||||
|
clearRetry()
|
||||||
|
ImSDK.removeListener(listener)
|
||||||
|
}
|
||||||
|
}, [userId, reconnect])
|
||||||
|
|
||||||
|
return <IMContext.Provider value={{ status, reconnect }}>{children}</IMContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIM(): IMContextValue {
|
||||||
|
return useContext(IMContext)
|
||||||
|
}
|
||||||
104
src/navigation/AppNavigator.tsx
普通文件
104
src/navigation/AppNavigator.tsx
普通文件
@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { NavigationContainer } from '@react-navigation/native'
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
||||||
|
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
|
||||||
|
import { Text, View, ActivityIndicator, StyleSheet } from 'react-native'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { IMProvider } from '../context/IMContext'
|
||||||
|
import type { AuthStackParams, MainTabParams, RootStackParams } from './types'
|
||||||
|
|
||||||
|
import LoginScreen from '../screens/auth/LoginScreen'
|
||||||
|
import RegisterScreen from '../screens/auth/RegisterScreen'
|
||||||
|
import ResetPasswordScreen from '../screens/auth/ResetPasswordScreen'
|
||||||
|
import ConversationListScreen from '../screens/conversation/ConversationListScreen'
|
||||||
|
import ContactsScreen from '../screens/contact/ContactsScreen'
|
||||||
|
import ProfileScreen from '../screens/profile/ProfileScreen'
|
||||||
|
import SingleChatScreen from '../screens/chat/SingleChatScreen'
|
||||||
|
import GroupChatScreen from '../screens/chat/GroupChatScreen'
|
||||||
|
import UserSearchScreen from '../screens/contact/UserSearchScreen'
|
||||||
|
import GroupListScreen from '../screens/group/GroupListScreen'
|
||||||
|
import CreateGroupScreen from '../screens/group/CreateGroupScreen'
|
||||||
|
import GroupMembersScreen from '../screens/group/GroupMembersScreen'
|
||||||
|
import GroupSettingsScreen from '../screens/group/GroupSettingsScreen'
|
||||||
|
import EditProfileScreen from '../screens/profile/EditProfileScreen'
|
||||||
|
import MessageSearchScreen from '../screens/chat/MessageSearchScreen'
|
||||||
|
|
||||||
|
const AuthStack = createNativeStackNavigator<AuthStackParams>()
|
||||||
|
const Tab = createBottomTabNavigator<MainTabParams>()
|
||||||
|
const Root = createNativeStackNavigator<RootStackParams>()
|
||||||
|
|
||||||
|
function TabIcon({ name, focused }: { name: string; focused: boolean }) {
|
||||||
|
const icons: Record<string, [string, string]> = {
|
||||||
|
ConversationList: ['💬', '🗨️'],
|
||||||
|
Contacts: ['👥', '👤'],
|
||||||
|
Profile: ['👤', '🧑'],
|
||||||
|
}
|
||||||
|
return <Text style={{ fontSize: 22, opacity: focused ? 1 : 0.5 }}>{(icons[name] ?? ['?', '?'])[focused ? 0 : 1]}</Text>
|
||||||
|
}
|
||||||
|
|
||||||
|
function MainTabs() {
|
||||||
|
return (
|
||||||
|
<Tab.Navigator
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
tabBarIcon: ({ focused }) => <TabIcon name={route.name} focused={focused} />,
|
||||||
|
tabBarActiveTintColor: '#07C160',
|
||||||
|
tabBarInactiveTintColor: '#888',
|
||||||
|
headerShown: false,
|
||||||
|
})}>
|
||||||
|
<Tab.Screen name="ConversationList" component={ConversationListScreen} options={{ title: '消息' }} />
|
||||||
|
<Tab.Screen name="Contacts" component={ContactsScreen} options={{ title: '通讯录' }} />
|
||||||
|
<Tab.Screen name="Profile" component={ProfileScreen} options={{ title: '我' }} />
|
||||||
|
</Tab.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppStack() {
|
||||||
|
return (
|
||||||
|
<IMProvider>
|
||||||
|
<Root.Navigator screenOptions={{ headerStyle: { backgroundColor: '#fff' }, headerTintColor: '#333' }}>
|
||||||
|
<Root.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
|
||||||
|
<Root.Screen name="SingleChat" component={SingleChatScreen} options={({ route }) => ({ title: route.params.targetName })} />
|
||||||
|
<Root.Screen name="GroupChat" component={GroupChatScreen} options={({ route }) => ({ title: route.params.groupName })} />
|
||||||
|
<Root.Screen name="UserSearch" component={UserSearchScreen} options={{ title: '搜索联系人' }} />
|
||||||
|
<Root.Screen name="GroupList" component={GroupListScreen} options={{ title: '群聊' }} />
|
||||||
|
<Root.Screen name="CreateGroup" component={CreateGroupScreen} options={{ title: '创建群聊' }} />
|
||||||
|
<Root.Screen name="GroupMembers" component={GroupMembersScreen} options={{ title: '群成员' }} />
|
||||||
|
<Root.Screen name="GroupSettings" component={GroupSettingsScreen} options={{ title: '群设置' }} />
|
||||||
|
<Root.Screen name="EditProfile" component={EditProfileScreen} options={{ title: '编辑资料' }} />
|
||||||
|
<Root.Screen name="MessageSearch" component={MessageSearchScreen} options={{ title: '搜索消息' }} />
|
||||||
|
</Root.Navigator>
|
||||||
|
</IMProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthFlow() {
|
||||||
|
return (
|
||||||
|
<AuthStack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
<AuthStack.Screen name="Login" component={LoginScreen} />
|
||||||
|
<AuthStack.Screen name="Register" component={RegisterScreen} />
|
||||||
|
<AuthStack.Screen name="ResetPassword" component={ResetPasswordScreen} />
|
||||||
|
</AuthStack.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppNavigator() {
|
||||||
|
const { ready, userId } = useAuth()
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loading}>
|
||||||
|
<ActivityIndicator size="large" color="#07C160" />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationContainer>
|
||||||
|
{userId ? <AppStack /> : <AuthFlow />}
|
||||||
|
</NavigationContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loading: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff' },
|
||||||
|
})
|
||||||
24
src/navigation/types.ts
普通文件
24
src/navigation/types.ts
普通文件
@ -0,0 +1,24 @@
|
|||||||
|
export type AuthStackParams = {
|
||||||
|
Login: undefined
|
||||||
|
Register: undefined
|
||||||
|
ResetPassword: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MainTabParams = {
|
||||||
|
ConversationList: undefined
|
||||||
|
Contacts: undefined
|
||||||
|
Profile: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RootStackParams = {
|
||||||
|
Main: undefined
|
||||||
|
SingleChat: { targetId: string; targetName: string; targetAvatar?: string }
|
||||||
|
GroupChat: { groupId: string; groupName: string }
|
||||||
|
UserSearch: undefined
|
||||||
|
GroupList: undefined
|
||||||
|
CreateGroup: undefined
|
||||||
|
GroupMembers: { groupId: string; groupName: string }
|
||||||
|
GroupSettings: { groupId: string; groupName: string; isAdmin: boolean }
|
||||||
|
EditProfile: undefined
|
||||||
|
MessageSearch: { targetId?: string; chatType?: 'SINGLE' | 'GROUP' }
|
||||||
|
}
|
||||||
75
src/screens/auth/LoginScreen.tsx
普通文件
75
src/screens/auth/LoginScreen.tsx
普通文件
@ -0,0 +1,75 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
||||||
|
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
|
||||||
|
} from 'react-native'
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import type { AuthStackParams } from '../../navigation/types'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AuthStackParams, 'Login'>
|
||||||
|
|
||||||
|
export default function LoginScreen({ navigation }: Props) {
|
||||||
|
const { login } = useAuth()
|
||||||
|
const [userId, setUserId] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
const uid = userId.trim()
|
||||||
|
const pwd = password.trim()
|
||||||
|
if (!uid || !pwd) { Alert.alert('提示', '请输入用户名和密码'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await login(uid, pwd)
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('登录失败', e?.message ?? '请检查用户名和密码')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>XuqmChat</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="用户名"
|
||||||
|
autoCapitalize="none"
|
||||||
|
value={userId}
|
||||||
|
onChangeText={setUserId}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="密码"
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity style={styles.btn} onPress={handleLogin} disabled={loading}>
|
||||||
|
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>登录</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('Register')}>
|
||||||
|
<Text style={styles.link}>注册账号</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('ResetPassword')}>
|
||||||
|
<Text style={styles.link}>忘记密码</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5', justifyContent: 'center' },
|
||||||
|
card: { margin: 24, backgroundColor: '#fff', borderRadius: 12, padding: 24, elevation: 2 },
|
||||||
|
title: { fontSize: 28, fontWeight: '700', color: '#07C160', textAlign: 'center', marginBottom: 32 },
|
||||||
|
input: { borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16 },
|
||||||
|
btn: { backgroundColor: '#07C160', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 4 },
|
||||||
|
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
|
row: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 16 },
|
||||||
|
link: { color: '#07C160', fontSize: 14 },
|
||||||
|
})
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
||||||
|
KeyboardAvoidingView, Platform, ActivityIndicator, Alert, ScrollView,
|
||||||
|
} from 'react-native'
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import type { AuthStackParams } from '../../navigation/types'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AuthStackParams, 'Register'>
|
||||||
|
|
||||||
|
export default function RegisterScreen({ navigation }: Props) {
|
||||||
|
const { register } = useAuth()
|
||||||
|
const [userId, setUserId] = useState('')
|
||||||
|
const [nickname, setNickname] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
const uid = userId.trim()
|
||||||
|
const nick = nickname.trim()
|
||||||
|
const pwd = password.trim()
|
||||||
|
if (!uid || !nick || !pwd) { Alert.alert('提示', '请填写所有字段'); return }
|
||||||
|
if (pwd !== confirm.trim()) { Alert.alert('提示', '两次密码不一致'); return }
|
||||||
|
if (pwd.length < 6) { Alert.alert('提示', '密码至少6位'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await register(uid, pwd, nick)
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('注册失败', e?.message ?? '请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
|
<ScrollView contentContainerStyle={styles.inner} keyboardShouldPersistTaps="handled">
|
||||||
|
<Text style={styles.title}>创建账号</Text>
|
||||||
|
<TextInput style={styles.input} placeholder="用户名(登录 ID)" autoCapitalize="none" value={userId} onChangeText={setUserId} />
|
||||||
|
<TextInput style={styles.input} placeholder="昵称" value={nickname} onChangeText={setNickname} />
|
||||||
|
<TextInput style={styles.input} placeholder="密码(至少6位)" secureTextEntry value={password} onChangeText={setPassword} />
|
||||||
|
<TextInput style={styles.input} placeholder="确认密码" secureTextEntry value={confirm} onChangeText={setConfirm} />
|
||||||
|
<TouchableOpacity style={styles.btn} onPress={handleRegister} disabled={loading}>
|
||||||
|
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>注册</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.link}>已有账号?去登录</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
inner: { padding: 24, paddingTop: 60 },
|
||||||
|
title: { fontSize: 26, fontWeight: '700', color: '#333', marginBottom: 28 },
|
||||||
|
input: { borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16, backgroundColor: '#fff' },
|
||||||
|
btn: { backgroundColor: '#07C160', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 4 },
|
||||||
|
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
|
backBtn: { alignItems: 'center', marginTop: 16 },
|
||||||
|
link: { color: '#07C160', fontSize: 14 },
|
||||||
|
})
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
||||||
|
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
|
||||||
|
} from 'react-native'
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import type { AuthStackParams } from '../../navigation/types'
|
||||||
|
import { demoApi } from '../../api/demo'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<AuthStackParams, 'ResetPassword'>
|
||||||
|
|
||||||
|
export default function ResetPasswordScreen({ navigation }: Props) {
|
||||||
|
const [userId, setUserId] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
const uid = userId.trim()
|
||||||
|
const pwd = newPassword.trim()
|
||||||
|
if (!uid || !pwd) { Alert.alert('提示', '请填写所有字段'); return }
|
||||||
|
if (pwd !== confirm.trim()) { Alert.alert('提示', '两次密码不一致'); return }
|
||||||
|
if (pwd.length < 6) { Alert.alert('提示', '密码至少6位'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await demoApi.resetPassword(uid, pwd)
|
||||||
|
Alert.alert('成功', '密码已重置,请重新登录', [{ text: '确定', onPress: () => navigation.navigate('Login') }])
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('失败', e?.message ?? '操作失败,请重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView style={styles.root} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
||||||
|
<View style={styles.inner}>
|
||||||
|
<Text style={styles.title}>重置密码</Text>
|
||||||
|
<TextInput style={styles.input} placeholder="用户名" autoCapitalize="none" value={userId} onChangeText={setUserId} />
|
||||||
|
<TextInput style={styles.input} placeholder="新密码(至少6位)" secureTextEntry value={newPassword} onChangeText={setNewPassword} />
|
||||||
|
<TextInput style={styles.input} placeholder="确认新密码" secureTextEntry value={confirm} onChangeText={setConfirm} />
|
||||||
|
<TouchableOpacity style={styles.btn} onPress={handleReset} disabled={loading}>
|
||||||
|
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>重置密码</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.backBtn} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.link}>返回登录</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5', justifyContent: 'center' },
|
||||||
|
inner: { padding: 24 },
|
||||||
|
title: { fontSize: 26, fontWeight: '700', color: '#333', marginBottom: 28 },
|
||||||
|
input: { borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16, marginBottom: 16, backgroundColor: '#fff' },
|
||||||
|
btn: { backgroundColor: '#07C160', borderRadius: 8, padding: 14, alignItems: 'center', marginTop: 4 },
|
||||||
|
btnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
|
backBtn: { alignItems: 'center', marginTop: 16 },
|
||||||
|
link: { color: '#07C160', fontSize: 14 },
|
||||||
|
})
|
||||||
122
src/screens/chat/GroupChatScreen.tsx
普通文件
122
src/screens/chat/GroupChatScreen.tsx
普通文件
@ -0,0 +1,122 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||||
|
KeyboardAvoidingView, Platform, TouchableOpacity, Text,
|
||||||
|
} from 'react-native'
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
|
import type { ImMessage, ImEventListener } from '@xuqm/rn-sdk'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
import { MessageBubble } from '../../components/MessageBubble'
|
||||||
|
import ChatInput from '../../components/ChatInput'
|
||||||
|
import DisconnectBanner from '../../components/DisconnectBanner'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParams, 'GroupChat'>
|
||||||
|
|
||||||
|
export default function GroupChatScreen({ route, navigation }: Props) {
|
||||||
|
const { groupId, groupName } = route.params
|
||||||
|
const { userId } = useAuth()
|
||||||
|
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const listRef = useRef<FlatList<ImMessage>>(null)
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async (p = 0) => {
|
||||||
|
try {
|
||||||
|
const history = await ImSDK.fetchGroupHistory(groupId, p, 30)
|
||||||
|
setMessages(prev => p === 0 ? history : [...history, ...prev])
|
||||||
|
setPage(p)
|
||||||
|
} catch {/* ignore */}
|
||||||
|
}, [groupId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory(0)
|
||||||
|
ImSDK.subscribeGroup(groupId)
|
||||||
|
ImSDK.markRead(groupId).catch(() => {})
|
||||||
|
|
||||||
|
const listener: ImEventListener = {
|
||||||
|
onGroupMessage(msg) {
|
||||||
|
if (msg.toId !== groupId) return
|
||||||
|
setMessages(prev => {
|
||||||
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [...prev, msg]
|
||||||
|
})
|
||||||
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ImSDK.addListener(listener)
|
||||||
|
return () => ImSDK.removeListener(listener)
|
||||||
|
}, [groupId, loadHistory])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('GroupSettings', { groupId, groupName, isAdmin: false })}>
|
||||||
|
<Text style={styles.settingsIcon}>⚙️</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}, [navigation, groupId, groupName])
|
||||||
|
|
||||||
|
const onSent = (msg: ImMessage) => {
|
||||||
|
setMessages(prev => {
|
||||||
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [...prev, msg]
|
||||||
|
})
|
||||||
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (messageId: string) => {
|
||||||
|
try {
|
||||||
|
const revoked = await ImSDK.revokeMessage(messageId)
|
||||||
|
setMessages(prev => prev.map(m => m.id === revoked.id ? revoked : m))
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('撤回失败', e?.message ?? '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (loadingMore) return
|
||||||
|
setLoadingMore(true)
|
||||||
|
await loadHistory(page + 1)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<DisconnectBanner />
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.flex}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||||
|
>
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={messages}
|
||||||
|
keyExtractor={m => m.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<MessageBubble
|
||||||
|
message={item}
|
||||||
|
isOwn={item.fromUserId === userId}
|
||||||
|
peerNickname={item.fromUserId}
|
||||||
|
onRevoke={handleRevoke}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
|
/>
|
||||||
|
<ChatInput toId={groupId} chatType="GROUP" onSent={onSent} />
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f0ede8' },
|
||||||
|
flex: { flex: 1 },
|
||||||
|
list: { padding: 8, paddingBottom: 4 },
|
||||||
|
settingsIcon: { fontSize: 20, marginRight: 8 },
|
||||||
|
})
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
|
||||||
|
SafeAreaView, ActivityIndicator,
|
||||||
|
} from 'react-native'
|
||||||
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { formatTime } from '../../utils/format'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParams, 'MessageSearch'>
|
||||||
|
|
||||||
|
export default function MessageSearchScreen({ route }: Props) {
|
||||||
|
const { targetId, chatType } = route.params ?? {}
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const { userId } = useAuth()
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [results, setResults] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const search = useCallback((text: string) => {
|
||||||
|
setKeyword(text)
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
if (!text.trim()) { setResults([]); return }
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const msgs = await ImSDK.searchMessages({
|
||||||
|
keyword: text.trim(),
|
||||||
|
toId: targetId,
|
||||||
|
chatType: chatType,
|
||||||
|
})
|
||||||
|
setResults(msgs)
|
||||||
|
} catch {
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}, [targetId, chatType])
|
||||||
|
|
||||||
|
const openChat = (item: any) => {
|
||||||
|
if (item.chatType === 'GROUP') {
|
||||||
|
;(navigation as any).navigate('GroupChat', { groupId: item.toId, groupName: item.toId })
|
||||||
|
} else {
|
||||||
|
const peerId = item.fromUserId === userId ? item.toId : item.fromUserId
|
||||||
|
;(navigation as any).navigate('SingleChat', { targetId: peerId, targetName: peerId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<View style={styles.searchBar}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="搜索消息内容"
|
||||||
|
value={keyword}
|
||||||
|
onChangeText={search}
|
||||||
|
autoFocus
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
/>
|
||||||
|
{loading && <ActivityIndicator color="#07C160" />}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<FlatList
|
||||||
|
data={results}
|
||||||
|
keyExtractor={item => item.serverId ?? item.id ?? Math.random().toString()}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const isMine = item.fromUserId === userId
|
||||||
|
const time = item.serverCreatedAt ? formatTime(item.serverCreatedAt) : ''
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.row} onPress={() => openChat(item)}>
|
||||||
|
<View style={styles.rowTop}>
|
||||||
|
<Text style={styles.sender}>{isMine ? '我' : item.fromUserId}</Text>
|
||||||
|
<Text style={styles.time}>{time}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.content} numberOfLines={2}>{item.content}</Text>
|
||||||
|
<Text style={styles.target}>
|
||||||
|
{item.chatType === 'GROUP' ? `群: ${item.toId}` : `与: ${item.fromUserId === userId ? item.toId : item.fromUserId}`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
|
ListEmptyComponent={
|
||||||
|
keyword.trim() && !loading
|
||||||
|
? <View style={styles.empty}><Text style={styles.emptyText}>未找到相关消息</Text></View>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
searchBar: { flexDirection: 'row', alignItems: 'center', margin: 12, backgroundColor: '#fff', borderRadius: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: '#e0e0e0' },
|
||||||
|
input: { flex: 1, fontSize: 16, paddingVertical: 10 },
|
||||||
|
row: { backgroundColor: '#fff', padding: 14 },
|
||||||
|
rowTop: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
||||||
|
sender: { fontSize: 13, fontWeight: '600', color: '#555' },
|
||||||
|
time: { fontSize: 12, color: '#999' },
|
||||||
|
content: { fontSize: 15, color: '#111', marginBottom: 4 },
|
||||||
|
target: { fontSize: 12, color: '#aaa' },
|
||||||
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0' },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 60 },
|
||||||
|
emptyText: { color: '#bbb', fontSize: 15 },
|
||||||
|
})
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||||
|
KeyboardAvoidingView, Platform, TouchableOpacity, Text,
|
||||||
|
} from 'react-native'
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
|
import type { ImMessage, ImEventListener } from '@xuqm/rn-sdk'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
import { MessageBubble } from '../../components/MessageBubble'
|
||||||
|
import ChatInput from '../../components/ChatInput'
|
||||||
|
import DisconnectBanner from '../../components/DisconnectBanner'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParams, 'SingleChat'>
|
||||||
|
|
||||||
|
export default function SingleChatScreen({ route, navigation }: Props) {
|
||||||
|
const { targetId, targetName, targetAvatar } = route.params
|
||||||
|
const { userId, profile } = useAuth()
|
||||||
|
const [messages, setMessages] = useState<ImMessage[]>([])
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const listRef = useRef<FlatList<ImMessage>>(null)
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async (p = 0) => {
|
||||||
|
try {
|
||||||
|
const history = await ImSDK.fetchHistory(targetId, p, 30)
|
||||||
|
setMessages(prev => p === 0 ? history : [...history, ...prev])
|
||||||
|
setPage(p)
|
||||||
|
} catch {/* ignore */}
|
||||||
|
}, [targetId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHistory(0)
|
||||||
|
|
||||||
|
const listener: ImEventListener = {
|
||||||
|
onMessage(msg) {
|
||||||
|
if ((msg.fromUserId === targetId && msg.toId === userId) ||
|
||||||
|
(msg.fromUserId === userId && msg.toId === targetId)) {
|
||||||
|
setMessages(prev => {
|
||||||
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [...prev, msg]
|
||||||
|
})
|
||||||
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ImSDK.addListener(listener)
|
||||||
|
ImSDK.markRead(targetId).catch(() => {})
|
||||||
|
|
||||||
|
return () => ImSDK.removeListener(listener)
|
||||||
|
}, [targetId, userId, loadHistory])
|
||||||
|
|
||||||
|
const onSent = (msg: ImMessage) => {
|
||||||
|
setMessages(prev => {
|
||||||
|
if (prev.find(m => m.id === msg.id)) return prev
|
||||||
|
return [...prev, msg]
|
||||||
|
})
|
||||||
|
setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (messageId: string) => {
|
||||||
|
try {
|
||||||
|
const revoked = await ImSDK.revokeMessage(messageId)
|
||||||
|
setMessages(prev => prev.map(m => m.id === revoked.id ? revoked : m))
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('撤回失败', e?.message ?? '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLongPress = (msg: ImMessage) => {
|
||||||
|
if (msg.fromUserId !== userId) return
|
||||||
|
if (msg.status === 'REVOKED' || msg.msgType === 'REVOKED') return
|
||||||
|
Alert.alert('操作', '撤回这条消息?', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{ text: '撤回', style: 'destructive', onPress: () => handleRevoke(msg.id) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (loadingMore) return
|
||||||
|
setLoadingMore(true)
|
||||||
|
await loadHistory(page + 1)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<DisconnectBanner />
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.flex}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||||
|
>
|
||||||
|
<FlatList
|
||||||
|
ref={listRef}
|
||||||
|
data={messages}
|
||||||
|
keyExtractor={m => m.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<MessageBubble
|
||||||
|
message={item}
|
||||||
|
isOwn={item.fromUserId === userId}
|
||||||
|
peerNickname={targetName}
|
||||||
|
onRevoke={handleRevoke}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
|
||||||
|
/>
|
||||||
|
<ChatInput toId={targetId} chatType="SINGLE" onSent={onSent} />
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f0ede8' },
|
||||||
|
flex: { flex: 1 },
|
||||||
|
list: { padding: 8, paddingBottom: 4 },
|
||||||
|
})
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
|
TextInput, Alert,
|
||||||
|
} from 'react-native'
|
||||||
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
|
import { load, save, K } from '../../utils/storage'
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
|
|
||||||
|
function ContactRow({ user, onPress }: { user: UserProfile; onPress(): void }) {
|
||||||
|
const letter = (user.nickname || user.userId).charAt(0).toUpperCase()
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.7}>
|
||||||
|
<View style={styles.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<Text style={styles.name}>{user.nickname}</Text>
|
||||||
|
<Text style={styles.uid}>@{user.userId}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.arrow}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactsScreen() {
|
||||||
|
const navigation = useNavigation<Nav>()
|
||||||
|
const [contacts, setContacts] = useState<UserProfile[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load<UserProfile[]>(K.CONTACTS).then(c => { if (c) setContacts(c) })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openChat = (user: UserProfile) => {
|
||||||
|
navigation.navigate('SingleChat', { targetId: user.userId, targetName: user.nickname, targetAvatar: user.avatar })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>通讯录</Text>
|
||||||
|
<View style={styles.headerActions}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('GroupList')} style={styles.headerBtn}>
|
||||||
|
<Text style={styles.headerBtnText}>群聊</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('UserSearch')} style={styles.headerBtn}>
|
||||||
|
<Text style={styles.headerBtnText}>添加</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={contacts}
|
||||||
|
keyExtractor={u => u.userId}
|
||||||
|
renderItem={({ item }) => <ContactRow user={item} onPress={() => openChat(item)} />}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyText}>还没有联系人</Text>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('UserSearch')}>
|
||||||
|
<Text style={styles.emptyLink}>搜索添加</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
|
||||||
|
title: { fontSize: 20, fontWeight: '700', color: '#111' },
|
||||||
|
headerActions: { flexDirection: 'row', gap: 12 },
|
||||||
|
headerBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
||||||
|
headerBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
||||||
|
avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||||
|
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
||||||
|
body: { flex: 1 },
|
||||||
|
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
||||||
|
uid: { fontSize: 13, color: '#888', marginTop: 2 },
|
||||||
|
arrow: { color: '#ccc', fontSize: 20 },
|
||||||
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 70 },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 80 },
|
||||||
|
emptyText: { color: '#bbb', fontSize: 15 },
|
||||||
|
emptyLink: { color: '#07C160', fontSize: 14, marginTop: 12 },
|
||||||
|
})
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
|
||||||
|
SafeAreaView, ActivityIndicator, Alert,
|
||||||
|
} from 'react-native'
|
||||||
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
|
import { load, save, K } from '../../utils/storage'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
|
|
||||||
|
export default function UserSearchScreen() {
|
||||||
|
const navigation = useNavigation<Nav>()
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [results, setResults] = useState<UserProfile[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const search = useCallback((text: string) => {
|
||||||
|
setKeyword(text)
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
if (!text.trim()) { setResults([]); return }
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await demoApi.searchUsers(text.trim())
|
||||||
|
setResults(res)
|
||||||
|
} catch {
|
||||||
|
setResults([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const addContact = async (user: UserProfile) => {
|
||||||
|
try {
|
||||||
|
const current = (await load<UserProfile[]>(K.CONTACTS)) ?? []
|
||||||
|
if (!current.find(c => c.userId === user.userId)) {
|
||||||
|
await save(K.CONTACTS, [...current, user])
|
||||||
|
}
|
||||||
|
Alert.alert('已添加', `${user.nickname} 已添加到通讯录`)
|
||||||
|
} catch {
|
||||||
|
Alert.alert('失败', '添加失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openChat = (user: UserProfile) => {
|
||||||
|
navigation.navigate('SingleChat', { targetId: user.userId, targetName: user.nickname, targetAvatar: user.avatar })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<View style={styles.searchBar}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="搜索用户名或昵称"
|
||||||
|
value={keyword}
|
||||||
|
onChangeText={search}
|
||||||
|
autoFocus
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
/>
|
||||||
|
{loading && <ActivityIndicator style={styles.spinner} color="#07C160" />}
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={results}
|
||||||
|
keyExtractor={u => u.userId}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const letter = (item.nickname || item.userId).charAt(0).toUpperCase()
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<TouchableOpacity style={styles.rowBody} onPress={() => openChat(item)}>
|
||||||
|
<View style={styles.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.name}>{item.nickname}</Text>
|
||||||
|
<Text style={styles.uid}>@{item.userId}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.addBtn} onPress={() => addContact(item)}>
|
||||||
|
<Text style={styles.addBtnText}>+ 添加</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
|
ListEmptyComponent={
|
||||||
|
keyword.trim() && !loading
|
||||||
|
? <View style={styles.empty}><Text style={styles.emptyText}>未找到用户</Text></View>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
searchBar: { flexDirection: 'row', alignItems: 'center', margin: 12, backgroundColor: '#fff', borderRadius: 8, paddingHorizontal: 12, borderWidth: 1, borderColor: '#e0e0e0' },
|
||||||
|
input: { flex: 1, fontSize: 16, paddingVertical: 10 },
|
||||||
|
spinner: { marginLeft: 8 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
||||||
|
rowBody: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
||||||
|
avatar: { width: 44, height: 44, borderRadius: 8, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||||
|
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
||||||
|
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
||||||
|
uid: { fontSize: 13, color: '#888' },
|
||||||
|
addBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
||||||
|
addBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||||
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 68 },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 60 },
|
||||||
|
emptyText: { color: '#bbb', fontSize: 15 },
|
||||||
|
})
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, FlatList, StyleSheet, Text, TouchableOpacity, SafeAreaView,
|
||||||
|
} from 'react-native'
|
||||||
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
|
import { ImSDK, type ConversationData } from '@xuqm/rn-sdk'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
import DisconnectBanner from '../../components/DisconnectBanner'
|
||||||
|
import ConversationItem from '../../components/ConversationItem'
|
||||||
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
|
|
||||||
|
interface ConvWithMeta extends ConversationData {
|
||||||
|
targetName: string
|
||||||
|
targetAvatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversationListScreen() {
|
||||||
|
const navigation = useNavigation<Nav>()
|
||||||
|
const [conversations, setConversations] = useState<ConvWithMeta[]>([])
|
||||||
|
const profileCache = useRef<Record<string, UserProfile>>({})
|
||||||
|
|
||||||
|
const enrichConv = async (conv: ConversationData): Promise<ConvWithMeta> => {
|
||||||
|
let profile = profileCache.current[conv.targetId]
|
||||||
|
if (!profile) {
|
||||||
|
try {
|
||||||
|
const results = await demoApi.searchUsers(conv.targetId)
|
||||||
|
profile = results.find(u => u.userId === conv.targetId) ?? { userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
||||||
|
profileCache.current[conv.targetId] = profile
|
||||||
|
} catch {
|
||||||
|
profile = { userId: conv.targetId, nickname: conv.targetId, avatar: '', gender: 'UNKNOWN', status: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...conv, targetName: profile.nickname, targetAvatar: profile.avatar }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = ImSDK.subscribeConversations(async (convs) => {
|
||||||
|
const enriched = await Promise.all(convs.map(enrichConv))
|
||||||
|
setConversations(enriched)
|
||||||
|
})
|
||||||
|
return unsub
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openConv = (item: ConvWithMeta) => {
|
||||||
|
ImSDK.markRead(item.targetId).catch(() => {})
|
||||||
|
if (item.chatType === 'GROUP') {
|
||||||
|
navigation.navigate('GroupChat', { groupId: item.targetId, groupName: item.targetName })
|
||||||
|
} else {
|
||||||
|
navigation.navigate('SingleChat', { targetId: item.targetId, targetName: item.targetName, targetAvatar: item.targetAvatar })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>消息</Text>
|
||||||
|
<TouchableOpacity onPress={() => navigation.navigate('UserSearch')}>
|
||||||
|
<Text style={styles.searchIcon}>🔍</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<DisconnectBanner />
|
||||||
|
<FlatList
|
||||||
|
data={conversations}
|
||||||
|
keyExtractor={item => item.targetId + item.chatType}
|
||||||
|
renderItem={({ item }) => <ConversationItem item={item} onPress={() => openConv(item)} />}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
|
ListEmptyComponent={<View style={styles.empty}><Text style={styles.emptyText}>暂无消息</Text></View>}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
|
||||||
|
title: { fontSize: 20, fontWeight: '700', color: '#111' },
|
||||||
|
searchIcon: { fontSize: 20 },
|
||||||
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 72 },
|
||||||
|
empty: { flex: 1, alignItems: 'center', paddingTop: 80 },
|
||||||
|
emptyText: { color: '#bbb', fontSize: 15 },
|
||||||
|
})
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
|
FlatList, ActivityIndicator, Alert, ScrollView,
|
||||||
|
} from 'react-native'
|
||||||
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
|
|
||||||
|
export default function CreateGroupScreen() {
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const [groupName, setGroupName] = useState('')
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState<UserProfile[]>([])
|
||||||
|
const [selected, setSelected] = useState<UserProfile[]>([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const search = useCallback((text: string) => {
|
||||||
|
setKeyword(text)
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||||
|
if (!text.trim()) { setSearchResults([]); return }
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setSearching(true)
|
||||||
|
try {
|
||||||
|
const res = await demoApi.searchUsers(text.trim())
|
||||||
|
setSearchResults(res)
|
||||||
|
} catch {
|
||||||
|
setSearchResults([])
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleSelect = (user: UserProfile) => {
|
||||||
|
setSelected(prev =>
|
||||||
|
prev.find(u => u.userId === user.userId)
|
||||||
|
? prev.filter(u => u.userId !== user.userId)
|
||||||
|
: [...prev, user]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const name = groupName.trim()
|
||||||
|
if (!name) { Alert.alert('提示', '请输入群名称'); return }
|
||||||
|
if (selected.length === 0) { Alert.alert('提示', '请选择至少一名成员'); return }
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const group = await ImSDK.createGroup(name, selected.map(u => u.userId))
|
||||||
|
navigation.goBack()
|
||||||
|
// Navigate to new group chat
|
||||||
|
;(navigation as any).navigate('GroupChat', { groupId: group.id, groupName: group.name })
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('失败', e?.message ?? '创建失败,请重试')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<ScrollView keyboardShouldPersistTaps="handled">
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>群名称</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="请输入群名称"
|
||||||
|
value={groupName}
|
||||||
|
onChangeText={setGroupName}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>已选成员 ({selected.length})</Text>
|
||||||
|
<View style={styles.chips}>
|
||||||
|
{selected.map(u => (
|
||||||
|
<TouchableOpacity key={u.userId} style={styles.chip} onPress={() => toggleSelect(u)}>
|
||||||
|
<Text style={styles.chipText}>{u.nickname} ✕</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.label}>搜索添加成员</Text>
|
||||||
|
<View style={styles.searchBar}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.searchInput}
|
||||||
|
placeholder="输入用户名或昵称"
|
||||||
|
value={keyword}
|
||||||
|
onChangeText={search}
|
||||||
|
clearButtonMode="while-editing"
|
||||||
|
/>
|
||||||
|
{searching && <ActivityIndicator color="#07C160" />}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{searchResults.map(user => {
|
||||||
|
const isSelected = selected.some(u => u.userId === user.userId)
|
||||||
|
const letter = (user.nickname || user.userId).charAt(0).toUpperCase()
|
||||||
|
return (
|
||||||
|
<TouchableOpacity key={user.userId} style={styles.row} onPress={() => toggleSelect(user)} activeOpacity={0.7}>
|
||||||
|
<View style={[styles.check, isSelected && styles.checkActive]}>
|
||||||
|
{isSelected && <Text style={styles.checkMark}>✓</Text>}
|
||||||
|
</View>
|
||||||
|
<View style={styles.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.name}>{user.nickname}</Text>
|
||||||
|
<Text style={styles.uid}>@{user.userId}</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity style={styles.createBtn} onPress={handleCreate} disabled={creating}>
|
||||||
|
{creating ? <ActivityIndicator color="#fff" /> : <Text style={styles.createBtnText}>创建群聊</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
section: { backgroundColor: '#fff', padding: 16, marginBottom: 8 },
|
||||||
|
label: { fontSize: 13, color: '#888', marginBottom: 8 },
|
||||||
|
input: { borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 10, fontSize: 16 },
|
||||||
|
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
|
chip: { backgroundColor: '#e8f9f0', borderRadius: 16, paddingHorizontal: 10, paddingVertical: 4, borderWidth: 1, borderColor: '#07C160' },
|
||||||
|
chipText: { color: '#07C160', fontSize: 13 },
|
||||||
|
searchBar: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, paddingHorizontal: 10 },
|
||||||
|
searchInput: { flex: 1, fontSize: 15, paddingVertical: 8 },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff', padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
|
||||||
|
check: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: '#e0e0e0', marginRight: 10, alignItems: 'center', justifyContent: 'center' },
|
||||||
|
checkActive: { borderColor: '#07C160', backgroundColor: '#07C160' },
|
||||||
|
checkMark: { color: '#fff', fontSize: 12, fontWeight: '700' },
|
||||||
|
avatar: { width: 40, height: 40, borderRadius: 6, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginRight: 10 },
|
||||||
|
avatarText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
|
name: { fontSize: 15, fontWeight: '500', color: '#111' },
|
||||||
|
uid: { fontSize: 12, color: '#888' },
|
||||||
|
footer: { padding: 16, backgroundColor: '#fff', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: '#e0e0e0' },
|
||||||
|
createBtn: { backgroundColor: '#07C160', borderRadius: 8, padding: 14, alignItems: 'center' },
|
||||||
|
createBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
|
})
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native'
|
||||||
|
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
|
import type { ImGroup } from '@xuqm/rn-sdk'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
|
|
||||||
|
function GroupRow({ group, onPress }: { group: ImGroup; onPress(): void }) {
|
||||||
|
const letter = (group.name || 'G').charAt(0).toUpperCase()
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.7}>
|
||||||
|
<View style={styles.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<Text style={styles.name}>{group.name}</Text>
|
||||||
|
<Text style={styles.memberCount}>{group.memberIds ? group.memberIds.split(',').filter(Boolean).length : 0} 人</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.arrow}>›</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GroupListScreen() {
|
||||||
|
const navigation = useNavigation<Nav>()
|
||||||
|
const [groups, setGroups] = useState<ImGroup[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetchGroups = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const list = await ImSDK.listGroups()
|
||||||
|
setGroups(list)
|
||||||
|
} catch {
|
||||||
|
/* silently fail */
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useFocusEffect(useCallback(() => { fetchGroups() }, []))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>群聊</Text>
|
||||||
|
<TouchableOpacity style={styles.createBtn} onPress={() => navigation.navigate('CreateGroup')}>
|
||||||
|
<Text style={styles.createBtnText}>+ 创建</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator style={{ flex: 1 }} color="#07C160" />
|
||||||
|
: (
|
||||||
|
<FlatList
|
||||||
|
data={groups}
|
||||||
|
keyExtractor={g => g.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<GroupRow
|
||||||
|
group={item}
|
||||||
|
onPress={() => navigation.navigate('GroupChat', { groupId: item.id, groupName: item.name })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
|
ListEmptyComponent={<View style={styles.empty}><Text style={styles.emptyText}>还没有群聊</Text></View>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
|
||||||
|
title: { fontSize: 18, fontWeight: '700', color: '#111' },
|
||||||
|
createBtn: { paddingHorizontal: 12, paddingVertical: 6, backgroundColor: '#07C160', borderRadius: 6 },
|
||||||
|
createBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
||||||
|
avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#5856d6', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||||
|
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
||||||
|
body: { flex: 1 },
|
||||||
|
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
||||||
|
memberCount: { fontSize: 13, color: '#888', marginTop: 2 },
|
||||||
|
arrow: { color: '#ccc', fontSize: 20 },
|
||||||
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 70 },
|
||||||
|
empty: { alignItems: 'center', paddingTop: 80 },
|
||||||
|
emptyText: { color: '#bbb', fontSize: 15 },
|
||||||
|
})
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert,
|
||||||
|
} from 'react-native'
|
||||||
|
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
|
import { demoApi, type UserProfile } from '../../api/demo'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParams, 'GroupMembers'>
|
||||||
|
|
||||||
|
export default function GroupMembersScreen({ route }: Props) {
|
||||||
|
const { groupId, groupName } = route.params
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const [members, setMembers] = useState<UserProfile[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const groups = await ImSDK.listGroups()
|
||||||
|
const group = groups.find(g => g.id === groupId)
|
||||||
|
if (!group) return
|
||||||
|
const ids = group.memberIds.split(',').filter(Boolean)
|
||||||
|
const profiles = await Promise.all(
|
||||||
|
ids.map(async id => {
|
||||||
|
const res = await demoApi.searchUsers(id)
|
||||||
|
return res.find(u => u.userId === id) ?? { userId: id, nickname: id, avatar: '', gender: 'UNKNOWN' as const, status: '' }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setMembers(profiles)
|
||||||
|
} catch {
|
||||||
|
/* silently fail */
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [groupId])
|
||||||
|
|
||||||
|
useFocusEffect(useCallback(() => { load() }, [load]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator style={{ flex: 1 }} color="#07C160" />
|
||||||
|
: (
|
||||||
|
<FlatList
|
||||||
|
data={members}
|
||||||
|
keyExtractor={u => u.userId}
|
||||||
|
renderItem={({ item }) => {
|
||||||
|
const letter = (item.nickname || item.userId).charAt(0).toUpperCase()
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<View style={styles.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<Text style={styles.name}>{item.nickname}</Text>
|
||||||
|
<Text style={styles.uid}>@{item.userId}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
|
ListHeaderComponent={<Text style={styles.count}>{members.length} 名成员</Text>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
count: { padding: 12, fontSize: 13, color: '#888', backgroundColor: '#f5f5f5' },
|
||||||
|
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
||||||
|
avatar: { width: 44, height: 44, borderRadius: 8, backgroundColor: '#5856d6', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
|
||||||
|
avatarText: { color: '#fff', fontSize: 17, fontWeight: '600' },
|
||||||
|
body: { flex: 1 },
|
||||||
|
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
||||||
|
uid: { fontSize: 13, color: '#888', marginTop: 2 },
|
||||||
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 68 },
|
||||||
|
})
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, TextInput } from 'react-native'
|
||||||
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<RootStackParams, 'GroupSettings'>
|
||||||
|
|
||||||
|
export default function GroupSettingsScreen({ route, navigation }: Props) {
|
||||||
|
const { groupId, groupName, isAdmin } = route.params
|
||||||
|
const nav = useNavigation()
|
||||||
|
|
||||||
|
function row(label: string, value?: string, onPress?: () => void) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress} activeOpacity={onPress ? 0.7 : 1}>
|
||||||
|
<Text style={styles.rowLabel}>{label}</Text>
|
||||||
|
<View style={styles.rowRight}>
|
||||||
|
{value !== undefined && <Text style={styles.rowValue}>{value}</Text>}
|
||||||
|
{onPress && <Text style={styles.arrow}>›</Text>}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMembers = () => nav.navigate('GroupMembers' as any, { groupId, groupName })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<View style={styles.section}>
|
||||||
|
{row('群名称', groupName)}
|
||||||
|
<View style={styles.sep} />
|
||||||
|
{row('群成员', undefined, openMembers)}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<View style={styles.sep} />
|
||||||
|
{row('群管理', undefined, () => Alert.alert('提示', '管理功能开发中'))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.leaveBtn}
|
||||||
|
onPress={() => Alert.alert('退出群聊', '确定要退出吗?', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{ text: '退出', style: 'destructive', onPress: () => nav.goBack() },
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Text style={styles.leaveBtnText}>退出群聊</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
section: { backgroundColor: '#fff', marginTop: 16 },
|
||||||
|
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16 },
|
||||||
|
rowLabel: { fontSize: 16, color: '#333' },
|
||||||
|
rowRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
|
rowValue: { fontSize: 15, color: '#888' },
|
||||||
|
arrow: { color: '#ccc', fontSize: 20 },
|
||||||
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 16 },
|
||||||
|
leaveBtn: { margin: 16, padding: 16, backgroundColor: '#fff', borderRadius: 8, alignItems: 'center' },
|
||||||
|
leaveBtnText: { color: '#ff3b30', fontSize: 16, fontWeight: '600' },
|
||||||
|
})
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
|
ActivityIndicator, Alert, ScrollView,
|
||||||
|
} from 'react-native'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
|
||||||
|
type Gender = 'UNKNOWN' | 'MALE' | 'FEMALE'
|
||||||
|
|
||||||
|
export default function EditProfileScreen() {
|
||||||
|
const navigation = useNavigation()
|
||||||
|
const { profile, updateProfile } = useAuth()
|
||||||
|
const [nickname, setNickname] = useState(profile?.nickname ?? '')
|
||||||
|
const [gender, setGender] = useState<Gender>(profile?.gender ?? 'UNKNOWN')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const nick = nickname.trim()
|
||||||
|
if (!nick) { Alert.alert('提示', '昵称不能为空'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await updateProfile({ nickname: nick, gender })
|
||||||
|
navigation.goBack()
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('失败', e?.message ?? '保存失败,请重试')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<ScrollView contentContainerStyle={styles.inner}>
|
||||||
|
<Text style={styles.label}>昵称</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={nickname}
|
||||||
|
onChangeText={setNickname}
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
maxLength={32}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>性别</Text>
|
||||||
|
<View style={styles.genderRow}>
|
||||||
|
{(['MALE', 'FEMALE', 'UNKNOWN'] as Gender[]).map(g => {
|
||||||
|
const label = { MALE: '男', FEMALE: '女', UNKNOWN: '不设置' }[g]
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={g}
|
||||||
|
style={[styles.genderBtn, gender === g && styles.genderBtnActive]}
|
||||||
|
onPress={() => setGender(g)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.genderBtnText, gender === g && styles.genderBtnTextActive]}>{label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.saveBtn} onPress={save} disabled={loading}>
|
||||||
|
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveBtnText}>保存</Text>}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
inner: { padding: 20 },
|
||||||
|
label: { fontSize: 13, color: '#888', marginBottom: 8, marginTop: 16 },
|
||||||
|
input: { backgroundColor: '#fff', borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16 },
|
||||||
|
genderRow: { flexDirection: 'row', gap: 12 },
|
||||||
|
genderBtn: { flex: 1, padding: 12, borderRadius: 8, borderWidth: 1, borderColor: '#e0e0e0', backgroundColor: '#fff', alignItems: 'center' },
|
||||||
|
genderBtnActive: { borderColor: '#07C160', backgroundColor: '#e8f9f0' },
|
||||||
|
genderBtnText: { fontSize: 15, color: '#555' },
|
||||||
|
genderBtnTextActive: { color: '#07C160', fontWeight: '600' },
|
||||||
|
saveBtn: { marginTop: 32, backgroundColor: '#07C160', borderRadius: 8, padding: 14, alignItems: 'center' },
|
||||||
|
saveBtnText: { color: '#fff', fontSize: 16, fontWeight: '600' },
|
||||||
|
})
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, ScrollView } from 'react-native'
|
||||||
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
|
||||||
|
type Nav = NativeStackNavigationProp<RootStackParams>
|
||||||
|
|
||||||
|
function Row({ label, value, onPress }: { label: string; value?: string; onPress?(): void }) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress} activeOpacity={onPress ? 0.7 : 1}>
|
||||||
|
<Text style={styles.rowLabel}>{label}</Text>
|
||||||
|
<View style={styles.rowRight}>
|
||||||
|
{value !== undefined && <Text style={styles.rowValue} numberOfLines={1}>{value}</Text>}
|
||||||
|
{onPress && <Text style={styles.arrow}>›</Text>}
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const navigation = useNavigation<Nav>()
|
||||||
|
const { profile, logout } = useAuth()
|
||||||
|
|
||||||
|
const confirmLogout = () => {
|
||||||
|
Alert.alert('退出登录', '确定要退出吗?', [
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{ text: '退出', style: 'destructive', onPress: () => logout() },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const genderLabel = (g?: string) => ({ MALE: '男', FEMALE: '女', UNKNOWN: '未设置' }[g ?? 'UNKNOWN'] ?? '未设置')
|
||||||
|
const letter = (profile?.nickname || profile?.userId || '?').charAt(0).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.root}>
|
||||||
|
<ScrollView>
|
||||||
|
<View style={styles.avatarSection}>
|
||||||
|
<View style={styles.avatarCircle}><Text style={styles.avatarText}>{letter}</Text></View>
|
||||||
|
<Text style={styles.nickname}>{profile?.nickname ?? '-'}</Text>
|
||||||
|
<Text style={styles.userId}>@{profile?.userId ?? '-'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Row label="昵称" value={profile?.nickname} onPress={() => navigation.navigate('EditProfile')} />
|
||||||
|
<View style={styles.sep} />
|
||||||
|
<Row label="性别" value={genderLabel(profile?.gender)} onPress={() => navigation.navigate('EditProfile')} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.logoutBtn} onPress={confirmLogout}>
|
||||||
|
<Text style={styles.logoutText}>退出登录</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
|
avatarSection: { alignItems: 'center', paddingTop: 40, paddingBottom: 24, backgroundColor: '#fff', marginBottom: 16 },
|
||||||
|
avatarCircle: { width: 80, height: 80, borderRadius: 40, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginBottom: 12 },
|
||||||
|
avatarText: { color: '#fff', fontSize: 32, fontWeight: '700' },
|
||||||
|
nickname: { fontSize: 20, fontWeight: '700', color: '#111', marginBottom: 4 },
|
||||||
|
userId: { fontSize: 13, color: '#888' },
|
||||||
|
section: { backgroundColor: '#fff', marginBottom: 16 },
|
||||||
|
row: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16 },
|
||||||
|
rowLabel: { fontSize: 16, color: '#333' },
|
||||||
|
rowRight: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||||
|
rowValue: { fontSize: 15, color: '#888', maxWidth: 160 },
|
||||||
|
arrow: { color: '#ccc', fontSize: 20 },
|
||||||
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 16 },
|
||||||
|
logoutBtn: { margin: 16, padding: 16, backgroundColor: '#fff', borderRadius: 8, alignItems: 'center' },
|
||||||
|
logoutText: { color: '#ff3b30', fontSize: 16, fontWeight: '600' },
|
||||||
|
})
|
||||||
33
src/utils/format.ts
普通文件
33
src/utils/format.ts
普通文件
@ -0,0 +1,33 @@
|
|||||||
|
export function formatTime(isoOrMs: string | number): string {
|
||||||
|
const d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
const dayMs = 86400000
|
||||||
|
|
||||||
|
if (diff < dayMs && d.getDate() === now.getDate()) {
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
if (diff < 2 * dayMs) return '昨天'
|
||||||
|
if (diff < 7 * dayMs) {
|
||||||
|
return ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
|
||||||
|
? `周${'日一二三四五六'[d.getDay()]}`
|
||||||
|
: '上周'
|
||||||
|
}
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes}B`
|
||||||
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`
|
||||||
|
return `${(bytes / 1048576).toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function avatarLabel(nickname: string): string {
|
||||||
|
return (nickname || '?').slice(0, 1).toUpperCase()
|
||||||
|
}
|
||||||
26
src/utils/storage.ts
普通文件
26
src/utils/storage.ts
普通文件
@ -0,0 +1,26 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
|
||||||
|
export const K = {
|
||||||
|
DEMO_TOKEN: '@xuqm:demoToken',
|
||||||
|
IM_TOKEN: '@xuqm:imToken',
|
||||||
|
PROFILE: '@xuqm:profile',
|
||||||
|
CONTACTS: '@xuqm:contacts',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export async function save(key: string, value: unknown): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(key, JSON.stringify(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load<T>(key: string): Promise<T | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(key)
|
||||||
|
if (!raw) return null
|
||||||
|
try { return JSON.parse(raw) as T } catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(key: string): Promise<void> {
|
||||||
|
await AsyncStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSession(): Promise<void> {
|
||||||
|
await AsyncStorage.multiRemove([K.DEMO_TOKEN, K.IM_TOKEN, K.PROFILE])
|
||||||
|
}
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户