feat: real image/audio rendering, avatar upload, pinned convs, proper group management

- MessageBubble: IMAGE renders actual <Image> with aspect ratio clamping (min 80, max screenW-120)
- MessageBubble: VIDEO shows thumbnail with play overlay; AUDIO shows waveform + play/stop
- MessageBubble: per-message sender avatar (letter fallback when no uri)
- ConversationItem: show real avatar when available; pinned indicator; muted dot badge
- ConversationListScreen: sort pinned conversations to top; long-press for pin/mute actions
- ContactsScreen, UserSearchScreen, GroupMembersScreen: real avatar images with fallback
- ProfileScreen: show real avatar image, tap to edit
- EditProfileScreen: avatar upload via uploadFile() to file-service before saving profile
- GroupSettingsScreen: real leaveGroup() call via SDK, removes user from group server-side
- GroupListScreen, GroupMembersScreen: parse memberIds as JSON array (was comma-split)
- SingleChatScreen: remove redundant handleLongPress (now handled inside MessageBubble)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
徐勤民 2026-04-27 11:58:07 +08:00
父节点 536759c14a
当前提交 fd1ebbfdca
共有 11 个文件被更改,包括 434 次插入191 次删除

查看文件

@ -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 <Image source={{ uri }} style={styles.avatar} />
}
return (
<View style={styles.avatar}>
<View style={[styles.avatar, styles.avatarFallback]}>
<Text style={styles.avatarText}>{letter}</Text>
</View>
)
@ -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 (
<TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.7}>
<TouchableOpacity
style={[styles.row, item.isPinned && styles.rowPinned]}
onPress={onPress}
onLongPress={onLongPress}
activeOpacity={0.7}
>
<Avatar name={item.targetName ?? item.targetId} uri={item.targetAvatar} />
<View style={styles.body}>
<View style={styles.topRow}>
<View style={styles.nameRow}>
{item.isPinned && <Text style={styles.pinIcon}>📌</Text>}
<Text style={styles.name} numberOfLines={1}>{item.targetName ?? item.targetId}</Text>
</View>
<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)}
{item.isMuted ? '[静音] ' : ''}{lastMsgPreview(item)}
</Text>
{item.unreadCount > 0 && (
{item.unreadCount > 0 && !item.isMuted && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{item.unreadCount > 99 ? '99+' : item.unreadCount}</Text>
</View>
)}
{item.unreadCount > 0 && item.isMuted && (
<View style={styles.mutedDot} />
)}
</View>
</View>
</TouchableOpacity>
@ -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' },
})

查看文件

@ -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<string, unknown> {
try {
return JSON.parse(content) as Record<string, unknown>
@ -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 <Image source={{uri}} style={styles.avatarImg} />
}
return (
<View style={styles.avatarFallback}>
<Text style={styles.avatarLetter}>{letter}</Text>
</View>
)
}
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 (
<Pressable style={styles.audioBubble} onPress={togglePlay}>
<Text style={styles.audioIcon}>{playing ? '⏹' : '▶'}</Text>
<View style={styles.audioInfo}>
<View style={styles.audioWave}>
{[...Array(12)].map((_, i) => (
<View
key={i}
style={[
styles.audioBar,
{height: 4 + ((i * 3) % 12), opacity: playing ? 1 : 0.5},
]}
/>
))}
</View>
<Text style={styles.audioDuration}>
{duration > 0 ? formatSeconds(duration) : '语音'}
{size > 0 ? ` · ${formatBytes(size)}` : ''}
</Text>
</View>
</Pressable>
)
}
function MessageContent({message}: {message: ImMessage}) {
const {msgType, content, status} = message
@ -57,52 +143,70 @@ 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 (
<Image
source={{uri: displayUri}}
style={{width: displayW, height: displayH, borderRadius: 6}}
resizeMode="cover"
/>
)
}
return (
<View style={styles.mediaRow}>
<Text style={styles.mediaIcon}>🖼</Text>
<View style={styles.mediaInfo}>
<Text style={styles.mediaLabel}> {w}×{h}</Text>
{typeof data.url === 'string' && (
<Text style={styles.mediaUrl} numberOfLines={1}>{data.url as string}</Text>
)}
</View>
<Text style={styles.mediaLabel}></Text>
</View>
)
}
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 (
<View style={styles.mediaRow}>
<Text style={styles.mediaIcon}>🎬</Text>
<View style={styles.mediaInfo}>
<Text style={styles.mediaLabel}> {duration}{size ? ` · ${size}` : ''}</Text>
{typeof data.url === 'string' && (
<Text style={styles.mediaUrl} numberOfLines={1}>{data.url as string}</Text>
)}
<View style={{width: displayW, height: displayH}}>
<Image
source={{uri: thumbnailUrl}}
style={{width: displayW, height: displayH, borderRadius: 6}}
resizeMode="cover"
/>
<View style={styles.videoOverlay}>
<Text style={styles.videoPlayIcon}></Text>
{duration ? <Text style={styles.videoDuration}>{duration}</Text> : null}
</View>
</View>
)
}
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 (
<View style={styles.mediaRow}>
<Text style={styles.mediaIcon}>🎵</Text>
<Text style={styles.mediaIcon}>🎬</Text>
<View style={styles.mediaInfo}>
<Text style={styles.mediaLabel}> {duration}{size ? ` · ${size}` : ''}</Text>
<Text style={styles.mediaLabel}>{duration ? ` · ${duration}` : ''}</Text>
{size ? <Text style={styles.mediaDesc}>{size}</Text> : null}
</View>
</View>
)
}
case 'AUDIO':
return <AudioBubble message={message} />
case 'FILE': {
const data = parseContent(content)
const name = typeof data.name === 'string' ? data.name : '文件'
@ -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 (
<View style={[styles.row, isOwn ? styles.rowOwn : styles.rowPeer]}>
{!isOwn && (
<AvatarImage uri={avatarUri} letter={senderLetter} />
)}
<Pressable
onLongPress={handleLongPress}
style={[styles.bubble, isOwn ? styles.bubbleOwn : styles.bubblePeer, isRevoked && styles.bubbleRevoked]}>
<Text style={styles.metaLine}>
{senderLabel} · {message.msgType}
{senderLabel}
{message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''}
</Text>
<MessageContent message={message} />
</Pressable>
{isOwn && (
<AvatarImage uri={avatarUri} letter={senderLetter} />
)}
</View>
)
}
@ -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'},
})

查看文件

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

查看文件

@ -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 (
<TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.7}>
<View style={styles.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
{user.avatar ? (
<Image source={{ uri: user.avatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarFallback]}>
<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>
@ -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' },

查看文件

@ -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 (
<View style={styles.row}>
<TouchableOpacity style={styles.rowBody} onPress={() => openChat(item)}>
<View style={styles.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
{item.avatar ? (
<Image source={{ uri: item.avatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarFallback]}>
<Text style={styles.avatarText}>{letter}</Text>
</View>
)}
<View>
<Text style={styles.name}>{item.nickname}</Text>
<Text style={styles.uid}>@{item.userId}</Text>
@ -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' },

查看文件

@ -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 (
<SafeAreaView style={styles.root}>
<View style={styles.header}>
@ -65,7 +85,9 @@ export default function ConversationListScreen() {
<FlatList
data={conversations}
keyExtractor={item => item.targetId + item.chatType}
renderItem={({ item }) => <ConversationItem item={item} onPress={() => openConv(item)} />}
renderItem={({ item }) => (
<ConversationItem item={item} onPress={() => openConv(item)} onLongPress={() => handleLongPress(item)} />
)}
ItemSeparatorComponent={() => <View style={styles.sep} />}
ListEmptyComponent={<View style={styles.empty}><Text style={styles.emptyText}></Text></View>}
/>

查看文件

@ -12,6 +12,11 @@ import { useAuth } from '../../context/AuthContext'
type Nav = NativeStackNavigationProp<RootStackParams>
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 }) {
<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>
<Text style={styles.memberCount}>{parseMemberIds(group.memberIds).length} </Text>
</View>
<Text style={styles.arrow}></Text>
</TouchableOpacity>

查看文件

@ -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 (
<View style={styles.row}>
<View style={styles.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
{item.avatar ? (
<Image source={{ uri: item.avatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarFallback]}>
<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>
@ -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' },

查看文件

@ -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<RootStackParams, 'GroupSettings'>
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 (
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress} activeOpacity={onPress ? 0.7 : 1}>
<Text style={styles.rowLabel}>{label}</Text>
@ -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 (
<SafeAreaView style={styles.root}>
<View style={styles.section}>
{row('群名称', groupName)}
<Row label="群名称" value={groupName} />
<View style={styles.sep} />
{row('群成员', undefined, openMembers)}
<Row label="群成员" onPress={openMembers} />
{isAdmin && (
<>
<View style={styles.sep} />
{row('群管理', undefined, () => Alert.alert('提示', '管理功能开发中'))}
<Row label="添加成员" onPress={() => (nav as any).navigate('UserSearch')} />
</>
)}
</View>
<TouchableOpacity
style={styles.leaveBtn}
onPress={() => Alert.alert('退出群聊', '确定要退出吗?', [
{ text: '取消', style: 'cancel' },
{ text: '退出', style: 'destructive', onPress: () => nav.goBack() },
])}
style={[styles.leaveBtn, leaving && styles.leaveBtnDisabled]}
onPress={handleLeave}
disabled={leaving}
>
<Text style={styles.leaveBtnText}>退</Text>
{leaving
? <ActivityIndicator color="#ff3b30" />
: <Text style={styles.leaveBtnText}>退</Text>
}
</TouchableOpacity>
</SafeAreaView>
)
@ -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' },
})

查看文件

@ -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<Gender>(profile?.gender ?? 'UNKNOWN')
const [gender, setGender] = useState<Gender>((profile?.gender as Gender) ?? 'UNKNOWN')
const [avatarUri, setAvatarUri] = useState<string | undefined>(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 (
<SafeAreaView style={styles.root}>
<ScrollView contentContainerStyle={styles.inner}>
<View style={styles.avatarSection}>
<TouchableOpacity onPress={pickAvatar} disabled={uploadingAvatar} style={styles.avatarWrapper}>
{uploadingAvatar ? (
<View style={styles.avatarCircle}>
<ActivityIndicator color="#fff" />
</View>
) : avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImg} />
) : (
<View style={styles.avatarCircle}>
<Text style={styles.avatarLetter}>{letter}</Text>
</View>
)}
<View style={styles.avatarCameraTag}>
<Text style={styles.avatarCameraIcon}>📷</Text>
</View>
</TouchableOpacity>
<Text style={styles.avatarHint}></Text>
</View>
<Text style={styles.label}></Text>
<TextInput
style={styles.input}
@ -57,7 +102,7 @@ export default function EditProfileScreen() {
})}
</View>
<TouchableOpacity style={styles.saveBtn} onPress={save} disabled={loading}>
<TouchableOpacity style={styles.saveBtn} onPress={save} disabled={loading || uploadingAvatar}>
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveBtnText}></Text>}
</TouchableOpacity>
</ScrollView>
@ -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 },

查看文件

@ -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 (
<SafeAreaView style={styles.root}>
<ScrollView>
<View style={styles.avatarSection}>
<View style={styles.avatarCircle}><Text style={styles.avatarText}>{letter}</Text></View>
<TouchableOpacity onPress={() => navigation.navigate('EditProfile')} style={styles.avatarWrapper}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImg} />
) : (
<View style={styles.avatarCircle}>
<Text style={styles.avatarText}>{letter}</Text>
</View>
)}
<View style={styles.avatarEditBadge}>
<Text style={styles.avatarEditIcon}></Text>
</View>
</TouchableOpacity>
<Text style={styles.nickname}>{profile?.nickname ?? '-'}</Text>
<Text style={styles.userId}>@{profile?.userId ?? '-'}</Text>
</View>
@ -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 },