From fd1ebbfdca8d1e74af3032d565500b00a01d6f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E5=8B=A4=E6=B0=91?= Date: Mon, 27 Apr 2026 11:58:07 +0800 Subject: [PATCH] feat: real image/audio rendering, avatar upload, pinned convs, proper group management - MessageBubble: IMAGE renders actual 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 --- src/components/ConversationItem.tsx | 40 ++- src/components/MessageBubble.tsx | 339 +++++++++++------- src/screens/chat/SingleChatScreen.tsx | 11 +- src/screens/contact/ContactsScreen.tsx | 13 +- src/screens/contact/UserSearchScreen.tsx | 13 +- .../conversation/ConversationListScreen.tsx | 26 +- src/screens/group/GroupListScreen.tsx | 7 +- src/screens/group/GroupMembersScreen.tsx | 17 +- src/screens/group/GroupSettingsScreen.tsx | 52 ++- src/screens/profile/EditProfileScreen.tsx | 73 +++- src/screens/profile/ProfileScreen.tsx | 34 +- 11 files changed, 434 insertions(+), 191 deletions(-) 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 },