- 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>
68 行
2.8 KiB
TypeScript
68 行
2.8 KiB
TypeScript
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' },
|
|
})
|