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>
这个提交包含在:
徐勤民 2026-04-25 16:41:54 +08:00
父节点 37c591be9d
当前提交 e5adc00b44
共有 30 个文件被更改,包括 8855 次插入372 次删除

373
App.tsx
查看文件

@ -1,375 +1,22 @@
import React, {startTransition, useEffect, useRef, useState} from 'react'
import {
Alert,
Pressable,
ScrollView,
StatusBar,
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 React, { useEffect } from 'react'
import { StatusBar, useColorScheme } from 'react-native'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { UpdateSDK } from '@xuqm/rn-sdk'
import { AuthProvider } from './src/context/AuthContext'
import AppNavigator from './src/navigation/AppNavigator'
import pluginMeta from './plugin.json'
// Register this bundle as a plugin so UpdateSDK.checkRnUpdate() knows the current version.
UpdateSDK.registerPlugin(pluginMeta)
// Dev fallback: set app versionCode for simulators where native module is not linked.
UpdateSDK._devSetAppVersion(1, '1.0.0')
const APP_ID = 'ak_demo_chat'
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() {
export default function App() {
const isDarkMode = useColorScheme() === 'dark'
return (
<SafeAreaProvider>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<SafeAreaView style={styles.safeArea}>
<DemoConsole />
</SafeAreaView>
<AuthProvider>
<AppNavigator />
</AuthProvider>
</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 = {
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 path = require('path');
/**
* Metro configuration
* https://reactnative.dev/docs/metro
*
* @type {import('@react-native/metro-config').MetroConfig}
*/
const config = {};
const sdkRoot = path.resolve(__dirname, 'node_modules/@xuqm/rn-sdk');
const config = {
resolver: {
extraNodeModules: {
'@xuqm/rn-common': path.join(sdkRoot, 'packages/common'),
'@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);

查看文件

@ -10,15 +10,24 @@
"test": "jest"
},
"dependencies": {
"@nozbe/watermelondb": "^0.28.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",
"react": "19.2.3",
"react-native": "0.85.2",
"@react-native/new-app-screen": "0.85.2",
"react-native-safe-area-context": "^5.5.2"
"react-native-audio-recorder-player": "^4.5.0",
"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": {
"@babel/core": "^7.25.2",
"@babel/plugin-proposal-decorators": "^7.29.0",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.1.0",

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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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)
}

查看文件

@ -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 普通文件
查看文件

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

查看文件

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

查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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])
}

6708
yarn.lock 普通文件

文件差异内容过多而无法显示 加载差异