diff --git a/src/components/ConversationItem.tsx b/src/components/ConversationItem.tsx
index c994dc7..8fc1959 100644
--- a/src/components/ConversationItem.tsx
+++ b/src/components/ConversationItem.tsx
@@ -1,17 +1,21 @@
import React from 'react'
-import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'
+import { View, Text, StyleSheet, TouchableOpacity, Image } 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
+ onLongPress?(): void
}
function Avatar({ name, uri }: { name: string; uri?: string }) {
const letter = (name || '?').charAt(0).toUpperCase()
+ if (uri) {
+ return
+ }
return (
-
+
{letter}
)
@@ -27,24 +31,35 @@ function lastMsgPreview(item: ConversationData): string {
}
}
-export default function ConversationItem({ item, onPress }: Props) {
+export default function ConversationItem({ item, onPress, onLongPress }: Props) {
return (
-
+
- {item.targetName ?? item.targetId}
+
+ {item.isPinned && 📌}
+ {item.targetName ?? item.targetId}
+
{item.lastMsgTime ? formatTime(item.lastMsgTime) : ''}
- {item.isMuted ? '[已静音] ' : ''}{lastMsgPreview(item)}
+ {item.isMuted ? '[静音] ' : ''}{lastMsgPreview(item)}
- {item.unreadCount > 0 && (
+ {item.unreadCount > 0 && !item.isMuted && (
{item.unreadCount > 99 ? '99+' : item.unreadCount}
)}
+ {item.unreadCount > 0 && item.isMuted && (
+
+ )}
@@ -53,15 +68,20 @@ export default function ConversationItem({ item, onPress }: Props) {
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 },
+ rowPinned: { backgroundColor: '#f9f9f9' },
+ avatar: { width: 48, height: 48, borderRadius: 8, marginRight: 12 },
+ avatarFallback: { backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center' },
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 },
+ topRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
+ nameRow: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 4, marginRight: 8 },
+ pinIcon: { fontSize: 11 },
+ name: { flex: 1, fontSize: 16, fontWeight: '500', color: '#111' },
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' },
+ mutedDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: '#ccc' },
})
diff --git a/src/components/MessageBubble.tsx b/src/components/MessageBubble.tsx
index 14e78ad..b202319 100644
--- a/src/components/MessageBubble.tsx
+++ b/src/components/MessageBubble.tsx
@@ -1,5 +1,6 @@
-import React from 'react'
-import {Alert, Pressable, StyleSheet, Text, View} from 'react-native'
+import React, {useRef, useState} from 'react'
+import {Alert, Dimensions, Image, Pressable, StyleSheet, Text, View} from 'react-native'
+import AudioRecorderPlayer from 'react-native-audio-recorder-player'
import type {ImMessage} from '@xuqm/rn-sdk'
interface Props {
@@ -9,6 +10,13 @@ interface Props {
onRevoke?: (messageId: string) => void
}
+const SCREEN_W = Dimensions.get('window').width
+const IMG_MAX_W = SCREEN_W - 120
+const IMG_MIN_W = 80
+
+const globalPlayer = new AudioRecorderPlayer()
+let currentlyPlayingId: string | null = null
+
function parseContent(content: string): Record {
try {
return JSON.parse(content) as Record
@@ -29,6 +37,84 @@ function formatSeconds(s: number): string {
return `${m}:${String(sec).padStart(2, '0')}`
}
+function AvatarImage({uri, letter}: {uri?: string; letter: string}) {
+ if (uri) {
+ return
+ }
+ return (
+
+ {letter}
+
+ )
+}
+
+function AudioBubble({message}: {message: ImMessage}) {
+ const data = parseContent(message.content)
+ const url = typeof data.url === 'string' ? data.url : null
+ const duration = typeof data.duration === 'number' ? data.duration as number : 0
+ const size = typeof data.size === 'number' ? data.size as number : 0
+ const [playing, setPlaying] = useState(false)
+ const playingRef = useRef(false)
+
+ const togglePlay = async () => {
+ if (!url) return
+ if (playingRef.current) {
+ await globalPlayer.stopPlayer()
+ globalPlayer.removePlayBackListener()
+ playingRef.current = false
+ currentlyPlayingId = null
+ setPlaying(false)
+ } else {
+ if (currentlyPlayingId && currentlyPlayingId !== message.id) {
+ await globalPlayer.stopPlayer()
+ globalPlayer.removePlayBackListener()
+ }
+ currentlyPlayingId = message.id
+ playingRef.current = true
+ setPlaying(true)
+ try {
+ await globalPlayer.startPlayer(url)
+ globalPlayer.addPlayBackListener(e => {
+ if (e.currentPosition >= e.duration) {
+ globalPlayer.stopPlayer()
+ globalPlayer.removePlayBackListener()
+ playingRef.current = false
+ currentlyPlayingId = null
+ setPlaying(false)
+ }
+ })
+ } catch {
+ playingRef.current = false
+ currentlyPlayingId = null
+ setPlaying(false)
+ }
+ }
+ }
+
+ return (
+
+ {playing ? '⏹' : '▶'}
+
+
+ {[...Array(12)].map((_, i) => (
+
+ ))}
+
+
+ {duration > 0 ? formatSeconds(duration) : '语音'}
+ {size > 0 ? ` · ${formatBytes(size)}` : ''}
+
+
+
+ )
+}
+
function MessageContent({message}: {message: ImMessage}) {
const {msgType, content, status} = message
@@ -57,51 +143,69 @@ function MessageContent({message}: {message: ImMessage}) {
case 'IMAGE': {
const data = parseContent(content)
- const w = typeof data.width === 'number' ? data.width : '?'
- const h = typeof data.height === 'number' ? data.height : '?'
+ const url = typeof data.url === 'string' ? data.url : null
+ const thumbnailUrl = typeof data.thumbnailUrl === 'string' ? data.thumbnailUrl : url
+ const w = typeof data.width === 'number' ? data.width as number : 0
+ const h = typeof data.height === 'number' ? data.height as number : 0
+ const displayUri = thumbnailUrl || url
+
+ if (displayUri) {
+ const ratio = w > 0 && h > 0 ? h / w : 1
+ const displayW = Math.max(IMG_MIN_W, Math.min(IMG_MAX_W, w > 0 ? w : IMG_MAX_W))
+ const displayH = Math.round(displayW * ratio)
+ return (
+
+ )
+ }
return (
🖼️
-
- 图片 {w}×{h}
- {typeof data.url === 'string' && (
- {data.url as string}
- )}
-
+ 图片
)
}
case 'VIDEO': {
const data = parseContent(content)
- const duration = typeof data.duration === 'number' ? formatSeconds(data.duration as number) : '?'
+ const thumbnailUrl = typeof data.thumbnailUrl === 'string' ? data.thumbnailUrl : null
+ const duration = typeof data.duration === 'number' ? formatSeconds(data.duration as number) : ''
const size = typeof data.size === 'number' ? formatBytes(data.size as number) : ''
+
+ if (thumbnailUrl) {
+ const displayW = IMG_MAX_W
+ const displayH = Math.round(displayW * 9 / 16)
+ return (
+
+
+
+ ▶
+ {duration ? {duration} : null}
+
+
+ )
+ }
+
return (
🎬
- 视频 {duration}{size ? ` · ${size}` : ''}
- {typeof data.url === 'string' && (
- {data.url as string}
- )}
+ 视频{duration ? ` · ${duration}` : ''}
+ {size ? {size} : null}
)
}
- case 'AUDIO': {
- const data = parseContent(content)
- const duration = typeof data.duration === 'number' ? formatSeconds(data.duration as number) : '?'
- const size = typeof data.size === 'number' ? formatBytes(data.size as number) : ''
- return (
-
- 🎵
-
- 语音消息 {duration}{size ? ` · ${size}` : ''}
-
-
- )
- }
+ case 'AUDIO':
+ return
case 'FILE': {
const data = parseContent(content)
@@ -209,28 +313,32 @@ export function MessageBubble({message, isOwn, peerNickname, onRevoke}: Props) {
if (!canRevoke) return
Alert.alert('操作', '撤回这条消息?', [
{text: '取消', style: 'cancel'},
- {
- text: '撤回',
- style: 'destructive',
- onPress: () => onRevoke?.(message.id),
- },
+ {text: '撤回', style: 'destructive', onPress: () => onRevoke?.(message.id)},
])
}
const senderLabel = isOwn ? '我' : peerNickname
const isRevoked = message.status === 'REVOKED' || message.msgType === 'REVOKED'
+ const senderLetter = (senderLabel || '?').charAt(0).toUpperCase()
+ const avatarUri = (message as any).senderAvatar as string | undefined
return (
+ {!isOwn && (
+
+ )}
- {senderLabel} · {message.msgType}
+ {senderLabel}
{message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''}
+ {isOwn && (
+
+ )}
)
}
@@ -238,119 +346,74 @@ export function MessageBubble({message, isOwn, peerNickname, onRevoke}: Props) {
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
- marginBottom: 4,
+ marginBottom: 8,
+ paddingHorizontal: 8,
+ alignItems: 'flex-end',
+ gap: 8,
},
- rowOwn: {
- justifyContent: 'flex-end',
- },
- rowPeer: {
- justifyContent: 'flex-start',
+ rowOwn: {justifyContent: 'flex-end'},
+ rowPeer: {justifyContent: 'flex-start'},
+ avatarImg: {width: 36, height: 36, borderRadius: 18},
+ avatarFallback: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ backgroundColor: '#07C160',
+ alignItems: 'center',
+ justifyContent: 'center',
},
+ avatarLetter: {color: '#fff', fontSize: 15, fontWeight: '700'},
bubble: {
- maxWidth: '85%',
+ maxWidth: '75%',
borderRadius: 18,
paddingHorizontal: 12,
paddingVertical: 10,
gap: 4,
},
- bubbleOwn: {
- backgroundColor: '#cae7d8',
+ bubbleOwn: {backgroundColor: '#cae7d8'},
+ bubblePeer: {backgroundColor: '#ffffff', borderWidth: 1, borderColor: '#ddd3c2'},
+ bubbleRevoked: {backgroundColor: '#f0f0f0', borderWidth: 1, borderColor: '#e0e0e0'},
+ metaLine: {fontSize: 11, color: '#6b7280', fontWeight: '700'},
+ textContent: {fontSize: 15, lineHeight: 21, color: '#111827'},
+ revokedText: {fontSize: 14, color: '#9ca3af', fontStyle: 'italic'},
+ mediaRow: {flexDirection: 'row', alignItems: 'flex-start', gap: 8},
+ mediaIcon: {fontSize: 22, lineHeight: 28},
+ mediaInfo: {flex: 1, gap: 2},
+ mediaLabel: {fontSize: 14, fontWeight: '600', color: '#111827'},
+ mediaDesc: {fontSize: 12, color: '#6b7280'},
+ mediaUrl: {fontSize: 11, color: '#9ca3af'},
+ videoOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4,
},
- bubblePeer: {
- backgroundColor: '#ffffff',
- borderWidth: 1,
- borderColor: '#ddd3c2',
- },
- bubbleRevoked: {
- backgroundColor: '#f0f0f0',
- borderWidth: 1,
- borderColor: '#e0e0e0',
- },
- metaLine: {
- fontSize: 11,
- color: '#6b7280',
- fontWeight: '700',
- },
- textContent: {
- fontSize: 15,
- lineHeight: 21,
- color: '#111827',
- },
- revokedText: {
- fontSize: 14,
- color: '#9ca3af',
- fontStyle: 'italic',
- },
- mediaRow: {
+ videoPlayIcon: {fontSize: 36, color: '#fff', textShadowColor: 'rgba(0,0,0,0.5)', textShadowRadius: 4, textShadowOffset: {width: 0, height: 1}},
+ videoDuration: {fontSize: 12, color: '#fff', fontWeight: '600', textShadowColor: 'rgba(0,0,0,0.5)', textShadowRadius: 4, textShadowOffset: {width: 0, height: 1}},
+ audioBubble: {
flexDirection: 'row',
- alignItems: 'flex-start',
- gap: 8,
- },
- mediaIcon: {
- fontSize: 22,
- lineHeight: 28,
- },
- mediaInfo: {
- flex: 1,
- gap: 2,
- },
- mediaLabel: {
- fontSize: 14,
- fontWeight: '600',
- color: '#111827',
- },
- mediaDesc: {
- fontSize: 12,
- color: '#6b7280',
- },
- mediaUrl: {
- fontSize: 11,
- color: '#9ca3af',
- },
- notifyBox: {
- gap: 4,
- paddingVertical: 2,
- },
- notifyTitle: {
- fontSize: 14,
- fontWeight: '700',
- color: '#1d4ed8',
- },
- notifyBody: {
- fontSize: 13,
- color: '#374151',
- },
- customBox: {
- gap: 4,
- paddingVertical: 2,
- },
- customTitle: {
- fontSize: 14,
- fontWeight: '700',
- color: '#7c3aed',
- },
- customDesc: {
- fontSize: 13,
- color: '#374151',
- },
- customRaw: {
- fontSize: 10,
- color: '#9ca3af',
- fontFamily: 'monospace',
- },
- forwardBox: {
- borderLeftWidth: 3,
- borderLeftColor: '#d1d5db',
- paddingLeft: 8,
- gap: 4,
- },
- forwardLabel: {
- fontSize: 12,
- fontWeight: '700',
- color: '#6b7280',
- },
- forwardContent: {
- fontSize: 13,
- color: '#374151',
+ alignItems: 'center',
+ gap: 10,
+ paddingVertical: 4,
+ minWidth: 120,
},
+ audioIcon: {fontSize: 20, color: '#07C160'},
+ audioInfo: {flex: 1, gap: 4},
+ audioWave: {flexDirection: 'row', alignItems: 'center', gap: 2, height: 20},
+ audioBar: {width: 3, borderRadius: 2, backgroundColor: '#07C160'},
+ audioDuration: {fontSize: 12, color: '#6b7280'},
+ notifyBox: {gap: 4, paddingVertical: 2},
+ notifyTitle: {fontSize: 14, fontWeight: '700', color: '#1d4ed8'},
+ notifyBody: {fontSize: 13, color: '#374151'},
+ customBox: {gap: 4, paddingVertical: 2},
+ customTitle: {fontSize: 14, fontWeight: '700', color: '#7c3aed'},
+ customDesc: {fontSize: 13, color: '#374151'},
+ customRaw: {fontSize: 10, color: '#9ca3af', fontFamily: 'monospace'},
+ forwardBox: {borderLeftWidth: 3, borderLeftColor: '#d1d5db', paddingLeft: 8, gap: 4},
+ forwardLabel: {fontSize: 12, fontWeight: '700', color: '#6b7280'},
+ forwardContent: {fontSize: 13, color: '#374151'},
})
diff --git a/src/screens/chat/SingleChatScreen.tsx b/src/screens/chat/SingleChatScreen.tsx
index 0529d18..d9810ea 100644
--- a/src/screens/chat/SingleChatScreen.tsx
+++ b/src/screens/chat/SingleChatScreen.tsx
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
View, FlatList, StyleSheet, Alert, SafeAreaView,
- KeyboardAvoidingView, Platform, TouchableOpacity, Text,
+ KeyboardAvoidingView, Platform,
} from 'react-native'
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
import { ImSDK } from '@xuqm/rn-sdk'
@@ -67,15 +67,6 @@ export default function SingleChatScreen({ route, navigation }: Props) {
}
}
- 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)
diff --git a/src/screens/contact/ContactsScreen.tsx b/src/screens/contact/ContactsScreen.tsx
index b42ea50..f7b2940 100644
--- a/src/screens/contact/ContactsScreen.tsx
+++ b/src/screens/contact/ContactsScreen.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react'
import {
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
- ActivityIndicator,
+ ActivityIndicator, Image,
} from 'react-native'
import { useNavigation, useFocusEffect } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -16,7 +16,13 @@ function ContactRow({ user, onPress }: { user: UserProfile; onPress(): void }) {
const letter = (user.nickname || user.userId).charAt(0).toUpperCase()
return (
- {letter}
+ {user.avatar ? (
+
+ ) : (
+
+ {letter}
+
+ )}
{user.nickname}
@{user.userId}
@@ -109,7 +115,8 @@ const styles = StyleSheet.create({
headerBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
loadingIndicator: { marginVertical: 8 },
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
- avatar: { width: 46, height: 46, borderRadius: 8, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center', marginRight: 12 },
+ avatar: { width: 46, height: 46, borderRadius: 8, marginRight: 12 },
+ avatarFallback: { backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center' },
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
body: { flex: 1 },
name: { fontSize: 16, fontWeight: '500', color: '#111' },
diff --git a/src/screens/contact/UserSearchScreen.tsx b/src/screens/contact/UserSearchScreen.tsx
index 292399f..0bb8ee9 100644
--- a/src/screens/contact/UserSearchScreen.tsx
+++ b/src/screens/contact/UserSearchScreen.tsx
@@ -1,7 +1,7 @@
import React, { useState, useCallback, useRef } from 'react'
import {
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
- SafeAreaView, ActivityIndicator, Alert,
+ SafeAreaView, ActivityIndicator, Alert, Image,
} from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -74,7 +74,13 @@ export default function UserSearchScreen() {
return (
openChat(item)}>
- {letter}
+ {item.avatar ? (
+
+ ) : (
+
+ {letter}
+
+ )}
{item.nickname}
@{item.userId}
@@ -104,7 +110,8 @@ const styles = StyleSheet.create({
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 },
+ avatar: { width: 44, height: 44, borderRadius: 8, marginRight: 12 },
+ avatarFallback: { backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center' },
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
name: { fontSize: 16, fontWeight: '500', color: '#111' },
uid: { fontSize: 13, color: '#888' },
diff --git a/src/screens/conversation/ConversationListScreen.tsx b/src/screens/conversation/ConversationListScreen.tsx
index 6d5bd3b..974aafa 100644
--- a/src/screens/conversation/ConversationListScreen.tsx
+++ b/src/screens/conversation/ConversationListScreen.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import {
- View, FlatList, StyleSheet, Text, TouchableOpacity, SafeAreaView,
+ View, FlatList, StyleSheet, Text, TouchableOpacity, SafeAreaView, Alert,
} from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
@@ -39,6 +39,10 @@ export default function ConversationListScreen() {
useEffect(() => {
const unsub = ImSDK.subscribeConversations(async (convs) => {
const enriched = await Promise.all(convs.map(enrichConv))
+ enriched.sort((a, b) => {
+ if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1
+ return (b.lastMsgTime ?? 0) - (a.lastMsgTime ?? 0)
+ })
setConversations(enriched)
})
return unsub
@@ -53,6 +57,22 @@ export default function ConversationListScreen() {
}
}
+ const handleLongPress = (item: ConvWithMeta) => {
+ const pinLabel = item.isPinned ? '取消置顶' : '置顶'
+ const muteLabel = item.isMuted ? '取消静音' : '静音'
+ Alert.alert(item.targetName, undefined, [
+ {
+ text: pinLabel,
+ onPress: () => ImSDK.setConversationPinned(item.targetId, item.chatType, !item.isPinned).catch(() => {}),
+ },
+ {
+ text: muteLabel,
+ onPress: () => ImSDK.setConversationMuted(item.targetId, item.chatType, !item.isMuted).catch(() => {}),
+ },
+ { text: '取消', style: 'cancel' },
+ ])
+ }
+
return (
@@ -65,7 +85,9 @@ export default function ConversationListScreen() {
item.targetId + item.chatType}
- renderItem={({ item }) => openConv(item)} />}
+ renderItem={({ item }) => (
+ openConv(item)} onLongPress={() => handleLongPress(item)} />
+ )}
ItemSeparatorComponent={() => }
ListEmptyComponent={暂无消息}
/>
diff --git a/src/screens/group/GroupListScreen.tsx b/src/screens/group/GroupListScreen.tsx
index 750b014..3efba80 100644
--- a/src/screens/group/GroupListScreen.tsx
+++ b/src/screens/group/GroupListScreen.tsx
@@ -12,6 +12,11 @@ import { useAuth } from '../../context/AuthContext'
type Nav = NativeStackNavigationProp
+function parseMemberIds(memberIds: string): string[] {
+ try { return JSON.parse(memberIds || '[]') }
+ catch { return memberIds ? memberIds.split(',').filter(Boolean) : [] }
+}
+
function GroupRow({ group, onPress }: { group: ImGroup; onPress(): void }) {
const letter = (group.name || 'G').charAt(0).toUpperCase()
return (
@@ -19,7 +24,7 @@ function GroupRow({ group, onPress }: { group: ImGroup; onPress(): void }) {
{letter}
{group.name}
- {group.memberIds ? group.memberIds.split(',').filter(Boolean).length : 0} 人
+ {parseMemberIds(group.memberIds).length} 人
›
diff --git a/src/screens/group/GroupMembersScreen.tsx b/src/screens/group/GroupMembersScreen.tsx
index 9263f45..7f3d3d0 100644
--- a/src/screens/group/GroupMembersScreen.tsx
+++ b/src/screens/group/GroupMembersScreen.tsx
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'
import {
- View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert,
+ View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert, Image,
} from 'react-native'
import { useNavigation, useFocusEffect } from '@react-navigation/native'
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
@@ -22,7 +22,9 @@ export default function GroupMembersScreen({ route }: Props) {
const groups = await ImSDK.listGroups()
const group = groups.find(g => g.id === groupId)
if (!group) return
- const ids = group.memberIds.split(',').filter(Boolean)
+ let ids: string[]
+ try { ids = JSON.parse(group.memberIds || '[]') }
+ catch { ids = group.memberIds ? group.memberIds.split(',').filter(Boolean) : [] }
const profiles = await Promise.all(
ids.map(async id => {
const res = await demoApi.searchUsers(id)
@@ -51,7 +53,13 @@ export default function GroupMembersScreen({ route }: Props) {
const letter = (item.nickname || item.userId).charAt(0).toUpperCase()
return (
- {letter}
+ {item.avatar ? (
+
+ ) : (
+
+ {letter}
+
+ )}
{item.nickname}
@{item.userId}
@@ -72,7 +80,8 @@ 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 },
+ avatar: { width: 44, height: 44, borderRadius: 8, marginRight: 12 },
+ avatarFallback: { backgroundColor: '#5856d6', alignItems: 'center', justifyContent: 'center' },
avatarText: { color: '#fff', fontSize: 17, fontWeight: '600' },
body: { flex: 1 },
name: { fontSize: 16, fontWeight: '500', color: '#111' },
diff --git a/src/screens/group/GroupSettingsScreen.tsx b/src/screens/group/GroupSettingsScreen.tsx
index 3964f3f..b30d9cc 100644
--- a/src/screens/group/GroupSettingsScreen.tsx
+++ b/src/screens/group/GroupSettingsScreen.tsx
@@ -1,16 +1,18 @@
import React, { useState } from 'react'
-import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, TextInput } from 'react-native'
+import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, 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'
type Props = NativeStackScreenProps
-export default function GroupSettingsScreen({ route, navigation }: Props) {
+export default function GroupSettingsScreen({ route }: Props) {
const { groupId, groupName, isAdmin } = route.params
const nav = useNavigation()
+ const [leaving, setLeaving] = useState(false)
- function row(label: string, value?: string, onPress?: () => void) {
+ function Row({ label, value, onPress }: { label: string; value?: string; onPress?: () => void }) {
return (
{label}
@@ -22,30 +24,53 @@ export default function GroupSettingsScreen({ route, navigation }: Props) {
)
}
- const openMembers = () => nav.navigate('GroupMembers' as any, { groupId, groupName })
+ const openMembers = () => (nav as any).navigate('GroupMembers', { groupId, groupName })
+
+ const handleLeave = () => {
+ Alert.alert('退出群聊', '退出后将不再收到该群消息,确定吗?', [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '退出',
+ style: 'destructive',
+ onPress: async () => {
+ setLeaving(true)
+ try {
+ await ImSDK.leaveGroup(groupId)
+ nav.goBack()
+ nav.goBack()
+ } catch (e: any) {
+ Alert.alert('退出失败', e?.message ?? '请稍后重试')
+ } finally {
+ setLeaving(false)
+ }
+ },
+ },
+ ])
+ }
return (
- {row('群名称', groupName)}
+
- {row('群成员', undefined, openMembers)}
+
{isAdmin && (
<>
- {row('群管理', undefined, () => Alert.alert('提示', '管理功能开发中'))}
+ (nav as any).navigate('UserSearch')} />
>
)}
Alert.alert('退出群聊', '确定要退出吗?', [
- { text: '取消', style: 'cancel' },
- { text: '退出', style: 'destructive', onPress: () => nav.goBack() },
- ])}
+ style={[styles.leaveBtn, leaving && styles.leaveBtnDisabled]}
+ onPress={handleLeave}
+ disabled={leaving}
>
- 退出群聊
+ {leaving
+ ?
+ : 退出群聊
+ }
)
@@ -61,5 +86,6 @@ const styles = StyleSheet.create({
arrow: { color: '#ccc', fontSize: 20 },
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 16 },
leaveBtn: { margin: 16, padding: 16, backgroundColor: '#fff', borderRadius: 8, alignItems: 'center' },
+ leaveBtnDisabled: { opacity: 0.6 },
leaveBtnText: { color: '#ff3b30', fontSize: 16, fontWeight: '600' },
})
diff --git a/src/screens/profile/EditProfileScreen.tsx b/src/screens/profile/EditProfileScreen.tsx
index 2d5cc9b..976991a 100644
--- a/src/screens/profile/EditProfileScreen.tsx
+++ b/src/screens/profile/EditProfileScreen.tsx
@@ -1,8 +1,10 @@
import React, { useState } from 'react'
import {
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
- ActivityIndicator, Alert, ScrollView,
+ ActivityIndicator, Alert, ScrollView, Image,
} from 'react-native'
+import { launchImageLibrary } from 'react-native-image-picker'
+import { uploadFile } from '@xuqm/rn-sdk'
import { useAuth } from '../../context/AuthContext'
import { useNavigation } from '@react-navigation/native'
@@ -12,15 +14,36 @@ export default function EditProfileScreen() {
const navigation = useNavigation()
const { profile, updateProfile } = useAuth()
const [nickname, setNickname] = useState(profile?.nickname ?? '')
- const [gender, setGender] = useState(profile?.gender ?? 'UNKNOWN')
+ const [gender, setGender] = useState((profile?.gender as Gender) ?? 'UNKNOWN')
+ const [avatarUri, setAvatarUri] = useState(profile?.avatar || undefined)
+ const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [loading, setLoading] = useState(false)
+ const pickAvatar = async () => {
+ const result = await launchImageLibrary({ mediaType: 'photo', quality: 0.85 })
+ if (!result.assets?.length) return
+ const asset = result.assets[0]
+ if (!asset.uri) return
+
+ setUploadingAvatar(true)
+ try {
+ const ext = asset.fileName?.split('.').pop() ?? 'jpg'
+ const filename = `avatar_${Date.now()}.${ext}`
+ const uploaded = await uploadFile(asset.uri, asset.type ?? 'image/jpeg', filename)
+ setAvatarUri(uploaded.url)
+ } catch (e: any) {
+ Alert.alert('上传失败', e?.message ?? '头像上传失败,请重试')
+ } finally {
+ setUploadingAvatar(false)
+ }
+ }
+
const save = async () => {
const nick = nickname.trim()
if (!nick) { Alert.alert('提示', '昵称不能为空'); return }
setLoading(true)
try {
- await updateProfile({ nickname: nick, gender })
+ await updateProfile({ nickname: nick, gender, avatar: avatarUri ?? '' })
navigation.goBack()
} catch (e: any) {
Alert.alert('失败', e?.message ?? '保存失败,请重试')
@@ -29,9 +52,31 @@ export default function EditProfileScreen() {
}
}
+ const letter = (profile?.nickname || profile?.userId || '?').charAt(0).toUpperCase()
+
return (
+
+
+ {uploadingAvatar ? (
+
+
+
+ ) : avatarUri ? (
+
+ ) : (
+
+ {letter}
+
+ )}
+
+ 📷
+
+
+ 点击更换头像
+
+
昵称
-
+
{loading ? : 保存}
@@ -68,6 +113,26 @@ export default function EditProfileScreen() {
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#f5f5f5' },
inner: { padding: 20 },
+ avatarSection: { alignItems: 'center', marginBottom: 24 },
+ avatarWrapper: { position: 'relative', marginBottom: 8 },
+ avatarImg: { width: 88, height: 88, borderRadius: 44 },
+ avatarCircle: { width: 88, height: 88, borderRadius: 44, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center' },
+ avatarLetter: { color: '#fff', fontSize: 36, fontWeight: '700' },
+ avatarCameraTag: {
+ position: 'absolute',
+ bottom: 2,
+ right: 2,
+ width: 26,
+ height: 26,
+ borderRadius: 13,
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#ddd',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ avatarCameraIcon: { fontSize: 14 },
+ avatarHint: { fontSize: 12, color: '#888' },
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 },
diff --git a/src/screens/profile/ProfileScreen.tsx b/src/screens/profile/ProfileScreen.tsx
index 1587f2e..314ec1f 100644
--- a/src/screens/profile/ProfileScreen.tsx
+++ b/src/screens/profile/ProfileScreen.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, ScrollView } from 'react-native'
+import { View, Text, StyleSheet, TouchableOpacity, SafeAreaView, Alert, ScrollView, Image } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useAuth } from '../../context/AuthContext'
@@ -32,12 +32,24 @@ export default function ProfileScreen() {
const genderLabel = (g?: string) => ({ MALE: '男', FEMALE: '女', UNKNOWN: '未设置' }[g ?? 'UNKNOWN'] ?? '未设置')
const letter = (profile?.nickname || profile?.userId || '?').charAt(0).toUpperCase()
+ const avatarUri = profile?.avatar || undefined
return (
- {letter}
+ navigation.navigate('EditProfile')} style={styles.avatarWrapper}>
+ {avatarUri ? (
+
+ ) : (
+
+ {letter}
+
+ )}
+
+ ✎
+
+
{profile?.nickname ?? '-'}
@{profile?.userId ?? '-'}
@@ -59,8 +71,24 @@ export default function ProfileScreen() {
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 },
+ avatarWrapper: { position: 'relative', marginBottom: 12 },
+ avatarImg: { width: 80, height: 80, borderRadius: 40 },
+ avatarCircle: { width: 80, height: 80, borderRadius: 40, backgroundColor: '#07C160', alignItems: 'center', justifyContent: 'center' },
avatarText: { color: '#fff', fontSize: 32, fontWeight: '700' },
+ avatarEditBadge: {
+ position: 'absolute',
+ bottom: 0,
+ right: 0,
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: '#fff',
+ borderWidth: 1,
+ borderColor: '#e0e0e0',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ avatarEditIcon: { fontSize: 12, color: '#555' },
nickname: { fontSize: 20, fontWeight: '700', color: '#111', marginBottom: 4 },
userId: { fontSize: 13, color: '#888' },
section: { backgroundColor: '#fff', marginBottom: 16 },