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>
这个提交包含在:
父节点
536759c14a
当前提交
fd1ebbfdca
@ -1,17 +1,21 @@
|
|||||||
import React from 'react'
|
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 type { ConversationData } from '@xuqm/rn-sdk'
|
||||||
import { formatTime } from '../utils/format'
|
import { formatTime } from '../utils/format'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: ConversationData & { targetName?: string; targetAvatar?: string }
|
item: ConversationData & { targetName?: string; targetAvatar?: string }
|
||||||
onPress(): void
|
onPress(): void
|
||||||
|
onLongPress?(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
function Avatar({ name, uri }: { name: string; uri?: string }) {
|
function Avatar({ name, uri }: { name: string; uri?: string }) {
|
||||||
const letter = (name || '?').charAt(0).toUpperCase()
|
const letter = (name || '?').charAt(0).toUpperCase()
|
||||||
|
if (uri) {
|
||||||
|
return <Image source={{ uri }} style={styles.avatar} />
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<View style={styles.avatar}>
|
<View style={[styles.avatar, styles.avatarFallback]}>
|
||||||
<Text style={styles.avatarText}>{letter}</Text>
|
<Text style={styles.avatarText}>{letter}</Text>
|
||||||
</View>
|
</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 (
|
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} />
|
<Avatar name={item.targetName ?? item.targetId} uri={item.targetAvatar} />
|
||||||
<View style={styles.body}>
|
<View style={styles.body}>
|
||||||
<View style={styles.topRow}>
|
<View style={styles.topRow}>
|
||||||
<Text style={styles.name} numberOfLines={1}>{item.targetName ?? item.targetId}</Text>
|
<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>
|
<Text style={styles.time}>{item.lastMsgTime ? formatTime(item.lastMsgTime) : ''}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.bottomRow}>
|
<View style={styles.bottomRow}>
|
||||||
<Text style={[styles.preview, item.isMuted && styles.muted]} numberOfLines={1}>
|
<Text style={[styles.preview, item.isMuted && styles.muted]} numberOfLines={1}>
|
||||||
{item.isMuted ? '[已静音] ' : ''}{lastMsgPreview(item)}
|
{item.isMuted ? '[静音] ' : ''}{lastMsgPreview(item)}
|
||||||
</Text>
|
</Text>
|
||||||
{item.unreadCount > 0 && (
|
{item.unreadCount > 0 && !item.isMuted && (
|
||||||
<View style={styles.badge}>
|
<View style={styles.badge}>
|
||||||
<Text style={styles.badgeText}>{item.unreadCount > 99 ? '99+' : item.unreadCount}</Text>
|
<Text style={styles.badgeText}>{item.unreadCount > 99 ? '99+' : item.unreadCount}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{item.unreadCount > 0 && item.isMuted && (
|
||||||
|
<View style={styles.mutedDot} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@ -53,15 +68,20 @@ export default function ConversationItem({ item, onPress }: Props) {
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: { flexDirection: 'row', padding: 12, alignItems: 'center', backgroundColor: '#fff' },
|
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' },
|
avatarText: { color: '#fff', fontSize: 20, fontWeight: '600' },
|
||||||
body: { flex: 1 },
|
body: { flex: 1 },
|
||||||
topRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 },
|
topRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
|
||||||
name: { flex: 1, fontSize: 16, fontWeight: '500', color: '#111', marginRight: 8 },
|
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' },
|
time: { fontSize: 12, color: '#999' },
|
||||||
bottomRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
bottomRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||||
preview: { flex: 1, fontSize: 14, color: '#888', marginRight: 8 },
|
preview: { flex: 1, fontSize: 14, color: '#888', marginRight: 8 },
|
||||||
muted: { color: '#bbb' },
|
muted: { color: '#bbb' },
|
||||||
badge: { backgroundColor: '#ff3b30', borderRadius: 10, minWidth: 20, height: 20, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 4 },
|
badge: { backgroundColor: '#ff3b30', borderRadius: 10, minWidth: 20, height: 20, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 4 },
|
||||||
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
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 React, {useRef, useState} from 'react'
|
||||||
import {Alert, Pressable, StyleSheet, Text, View} from 'react-native'
|
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'
|
import type {ImMessage} from '@xuqm/rn-sdk'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -9,6 +10,13 @@ interface Props {
|
|||||||
onRevoke?: (messageId: string) => void
|
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> {
|
function parseContent(content: string): Record<string, unknown> {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(content) as Record<string, unknown>
|
return JSON.parse(content) as Record<string, unknown>
|
||||||
@ -29,6 +37,84 @@ function formatSeconds(s: number): string {
|
|||||||
return `${m}:${String(sec).padStart(2, '0')}`
|
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}) {
|
function MessageContent({message}: {message: ImMessage}) {
|
||||||
const {msgType, content, status} = message
|
const {msgType, content, status} = message
|
||||||
|
|
||||||
@ -57,51 +143,69 @@ function MessageContent({message}: {message: ImMessage}) {
|
|||||||
|
|
||||||
case 'IMAGE': {
|
case 'IMAGE': {
|
||||||
const data = parseContent(content)
|
const data = parseContent(content)
|
||||||
const w = typeof data.width === 'number' ? data.width : '?'
|
const url = typeof data.url === 'string' ? data.url : null
|
||||||
const h = typeof data.height === 'number' ? data.height : '?'
|
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 (
|
return (
|
||||||
<View style={styles.mediaRow}>
|
<View style={styles.mediaRow}>
|
||||||
<Text style={styles.mediaIcon}>🖼️</Text>
|
<Text style={styles.mediaIcon}>🖼️</Text>
|
||||||
<View style={styles.mediaInfo}>
|
<Text style={styles.mediaLabel}>图片</Text>
|
||||||
<Text style={styles.mediaLabel}>图片 {w}×{h}</Text>
|
|
||||||
{typeof data.url === 'string' && (
|
|
||||||
<Text style={styles.mediaUrl} numberOfLines={1}>{data.url as string}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'VIDEO': {
|
case 'VIDEO': {
|
||||||
const data = parseContent(content)
|
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) : ''
|
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={{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.mediaRow}>
|
<View style={styles.mediaRow}>
|
||||||
<Text style={styles.mediaIcon}>🎬</Text>
|
<Text style={styles.mediaIcon}>🎬</Text>
|
||||||
<View style={styles.mediaInfo}>
|
<View style={styles.mediaInfo}>
|
||||||
<Text style={styles.mediaLabel}>视频 {duration}{size ? ` · ${size}` : ''}</Text>
|
<Text style={styles.mediaLabel}>视频{duration ? ` · ${duration}` : ''}</Text>
|
||||||
{typeof data.url === 'string' && (
|
{size ? <Text style={styles.mediaDesc}>{size}</Text> : null}
|
||||||
<Text style={styles.mediaUrl} numberOfLines={1}>{data.url as string}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'AUDIO': {
|
case 'AUDIO':
|
||||||
const data = parseContent(content)
|
return <AudioBubble message={message} />
|
||||||
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>
|
|
||||||
<View style={styles.mediaInfo}>
|
|
||||||
<Text style={styles.mediaLabel}>语音消息 {duration}{size ? ` · ${size}` : ''}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'FILE': {
|
case 'FILE': {
|
||||||
const data = parseContent(content)
|
const data = parseContent(content)
|
||||||
@ -209,28 +313,32 @@ export function MessageBubble({message, isOwn, peerNickname, onRevoke}: Props) {
|
|||||||
if (!canRevoke) return
|
if (!canRevoke) return
|
||||||
Alert.alert('操作', '撤回这条消息?', [
|
Alert.alert('操作', '撤回这条消息?', [
|
||||||
{text: '取消', style: 'cancel'},
|
{text: '取消', style: 'cancel'},
|
||||||
{
|
{text: '撤回', style: 'destructive', onPress: () => onRevoke?.(message.id)},
|
||||||
text: '撤回',
|
|
||||||
style: 'destructive',
|
|
||||||
onPress: () => onRevoke?.(message.id),
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderLabel = isOwn ? '我' : peerNickname
|
const senderLabel = isOwn ? '我' : peerNickname
|
||||||
const isRevoked = message.status === 'REVOKED' || message.msgType === 'REVOKED'
|
const isRevoked = message.status === 'REVOKED' || message.msgType === 'REVOKED'
|
||||||
|
const senderLetter = (senderLabel || '?').charAt(0).toUpperCase()
|
||||||
|
const avatarUri = (message as any).senderAvatar as string | undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.row, isOwn ? styles.rowOwn : styles.rowPeer]}>
|
<View style={[styles.row, isOwn ? styles.rowOwn : styles.rowPeer]}>
|
||||||
|
{!isOwn && (
|
||||||
|
<AvatarImage uri={avatarUri} letter={senderLetter} />
|
||||||
|
)}
|
||||||
<Pressable
|
<Pressable
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
style={[styles.bubble, isOwn ? styles.bubbleOwn : styles.bubblePeer, isRevoked && styles.bubbleRevoked]}>
|
style={[styles.bubble, isOwn ? styles.bubbleOwn : styles.bubblePeer, isRevoked && styles.bubbleRevoked]}>
|
||||||
<Text style={styles.metaLine}>
|
<Text style={styles.metaLine}>
|
||||||
{senderLabel} · {message.msgType}
|
{senderLabel}
|
||||||
{message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''}
|
{message.status === 'READ' ? ' · 已读' : message.status === 'DELIVERED' ? ' · 已送达' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<MessageContent message={message} />
|
<MessageContent message={message} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{isOwn && (
|
||||||
|
<AvatarImage uri={avatarUri} letter={senderLetter} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -238,119 +346,74 @@ export function MessageBubble({message, isOwn, peerNickname, onRevoke}: Props) {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
row: {
|
row: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginBottom: 4,
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 8,
|
||||||
},
|
},
|
||||||
rowOwn: {
|
rowOwn: {justifyContent: 'flex-end'},
|
||||||
justifyContent: 'flex-end',
|
rowPeer: {justifyContent: 'flex-start'},
|
||||||
},
|
avatarImg: {width: 36, height: 36, borderRadius: 18},
|
||||||
rowPeer: {
|
avatarFallback: {
|
||||||
justifyContent: 'flex-start',
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#07C160',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
avatarLetter: {color: '#fff', fontSize: 15, fontWeight: '700'},
|
||||||
bubble: {
|
bubble: {
|
||||||
maxWidth: '85%',
|
maxWidth: '75%',
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
},
|
},
|
||||||
bubbleOwn: {
|
bubbleOwn: {backgroundColor: '#cae7d8'},
|
||||||
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: {
|
videoPlayIcon: {fontSize: 36, color: '#fff', textShadowColor: 'rgba(0,0,0,0.5)', textShadowRadius: 4, textShadowOffset: {width: 0, height: 1}},
|
||||||
backgroundColor: '#ffffff',
|
videoDuration: {fontSize: 12, color: '#fff', fontWeight: '600', textShadowColor: 'rgba(0,0,0,0.5)', textShadowRadius: 4, textShadowOffset: {width: 0, height: 1}},
|
||||||
borderWidth: 1,
|
audioBubble: {
|
||||||
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',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 10,
|
||||||
},
|
paddingVertical: 4,
|
||||||
mediaIcon: {
|
minWidth: 120,
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
|
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 React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
View, FlatList, StyleSheet, Alert, SafeAreaView,
|
||||||
KeyboardAvoidingView, Platform, TouchableOpacity, Text,
|
KeyboardAvoidingView, Platform,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
import { ImSDK } from '@xuqm/rn-sdk'
|
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 () => {
|
const loadMore = async () => {
|
||||||
if (loadingMore) return
|
if (loadingMore) return
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useCallback } from 'react'
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
ActivityIndicator,
|
ActivityIndicator, Image,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
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()
|
const letter = (user.nickname || user.userId).charAt(0).toUpperCase()
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity style={styles.row} onPress={onPress} activeOpacity={0.7}>
|
<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}>
|
<View style={styles.body}>
|
||||||
<Text style={styles.name}>{user.nickname}</Text>
|
<Text style={styles.name}>{user.nickname}</Text>
|
||||||
<Text style={styles.uid}>@{user.userId}</Text>
|
<Text style={styles.uid}>@{user.userId}</Text>
|
||||||
@ -109,7 +115,8 @@ const styles = StyleSheet.create({
|
|||||||
headerBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
headerBtnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||||
loadingIndicator: { marginVertical: 8 },
|
loadingIndicator: { marginVertical: 8 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
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' },
|
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
||||||
body: { flex: 1 },
|
body: { flex: 1 },
|
||||||
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react'
|
import React, { useState, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
|
View, Text, TextInput, FlatList, StyleSheet, TouchableOpacity,
|
||||||
SafeAreaView, ActivityIndicator, Alert,
|
SafeAreaView, ActivityIndicator, Alert, Image,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation } from '@react-navigation/native'
|
import { useNavigation } from '@react-navigation/native'
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
@ -74,7 +74,13 @@ export default function UserSearchScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<TouchableOpacity style={styles.rowBody} onPress={() => openChat(item)}>
|
<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>
|
<View>
|
||||||
<Text style={styles.name}>{item.nickname}</Text>
|
<Text style={styles.name}>{item.nickname}</Text>
|
||||||
<Text style={styles.uid}>@{item.userId}</Text>
|
<Text style={styles.uid}>@{item.userId}</Text>
|
||||||
@ -104,7 +110,8 @@ const styles = StyleSheet.create({
|
|||||||
spinner: { marginLeft: 8 },
|
spinner: { marginLeft: 8 },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
||||||
rowBody: { flex: 1, flexDirection: 'row', alignItems: 'center' },
|
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' },
|
avatarText: { color: '#fff', fontSize: 18, fontWeight: '600' },
|
||||||
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
||||||
uid: { fontSize: 13, color: '#888' },
|
uid: { fontSize: 13, color: '#888' },
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
View, FlatList, StyleSheet, Text, TouchableOpacity, SafeAreaView,
|
View, FlatList, StyleSheet, Text, TouchableOpacity, SafeAreaView, Alert,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation } from '@react-navigation/native'
|
import { useNavigation } from '@react-navigation/native'
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
@ -39,6 +39,10 @@ export default function ConversationListScreen() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = ImSDK.subscribeConversations(async (convs) => {
|
const unsub = ImSDK.subscribeConversations(async (convs) => {
|
||||||
const enriched = await Promise.all(convs.map(enrichConv))
|
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)
|
setConversations(enriched)
|
||||||
})
|
})
|
||||||
return unsub
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.root}>
|
<SafeAreaView style={styles.root}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
@ -65,7 +85,9 @@ export default function ConversationListScreen() {
|
|||||||
<FlatList
|
<FlatList
|
||||||
data={conversations}
|
data={conversations}
|
||||||
keyExtractor={item => item.targetId + item.chatType}
|
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} />}
|
ItemSeparatorComponent={() => <View style={styles.sep} />}
|
||||||
ListEmptyComponent={<View style={styles.empty}><Text style={styles.emptyText}>暂无消息</Text></View>}
|
ListEmptyComponent={<View style={styles.empty}><Text style={styles.emptyText}>暂无消息</Text></View>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,6 +12,11 @@ import { useAuth } from '../../context/AuthContext'
|
|||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParams>
|
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 }) {
|
function GroupRow({ group, onPress }: { group: ImGroup; onPress(): void }) {
|
||||||
const letter = (group.name || 'G').charAt(0).toUpperCase()
|
const letter = (group.name || 'G').charAt(0).toUpperCase()
|
||||||
return (
|
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.avatar}><Text style={styles.avatarText}>{letter}</Text></View>
|
||||||
<View style={styles.body}>
|
<View style={styles.body}>
|
||||||
<Text style={styles.name}>{group.name}</Text>
|
<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>
|
</View>
|
||||||
<Text style={styles.arrow}>›</Text>
|
<Text style={styles.arrow}>›</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert,
|
View, Text, FlatList, StyleSheet, TouchableOpacity, SafeAreaView, ActivityIndicator, Alert, Image,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
import { useNavigation, useFocusEffect } from '@react-navigation/native'
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
@ -22,7 +22,9 @@ export default function GroupMembersScreen({ route }: Props) {
|
|||||||
const groups = await ImSDK.listGroups()
|
const groups = await ImSDK.listGroups()
|
||||||
const group = groups.find(g => g.id === groupId)
|
const group = groups.find(g => g.id === groupId)
|
||||||
if (!group) return
|
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(
|
const profiles = await Promise.all(
|
||||||
ids.map(async id => {
|
ids.map(async id => {
|
||||||
const res = await demoApi.searchUsers(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()
|
const letter = (item.nickname || item.userId).charAt(0).toUpperCase()
|
||||||
return (
|
return (
|
||||||
<View style={styles.row}>
|
<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}>
|
<View style={styles.body}>
|
||||||
<Text style={styles.name}>{item.nickname}</Text>
|
<Text style={styles.name}>{item.nickname}</Text>
|
||||||
<Text style={styles.uid}>@{item.userId}</Text>
|
<Text style={styles.uid}>@{item.userId}</Text>
|
||||||
@ -72,7 +80,8 @@ const styles = StyleSheet.create({
|
|||||||
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
count: { padding: 12, fontSize: 13, color: '#888', backgroundColor: '#f5f5f5' },
|
count: { padding: 12, fontSize: 13, color: '#888', backgroundColor: '#f5f5f5' },
|
||||||
row: { flexDirection: 'row', alignItems: 'center', padding: 12, backgroundColor: '#fff' },
|
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' },
|
avatarText: { color: '#fff', fontSize: 17, fontWeight: '600' },
|
||||||
body: { flex: 1 },
|
body: { flex: 1 },
|
||||||
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
name: { fontSize: 16, fontWeight: '500', color: '#111' },
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
import React, { useState } from 'react'
|
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 { useNavigation } from '@react-navigation/native'
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
|
||||||
|
import { ImSDK } from '@xuqm/rn-sdk'
|
||||||
import type { RootStackParams } from '../../navigation/types'
|
import type { RootStackParams } from '../../navigation/types'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<RootStackParams, 'GroupSettings'>
|
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 { groupId, groupName, isAdmin } = route.params
|
||||||
const nav = useNavigation()
|
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 (
|
return (
|
||||||
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress} activeOpacity={onPress ? 0.7 : 1}>
|
<TouchableOpacity style={styles.row} onPress={onPress} disabled={!onPress} activeOpacity={onPress ? 0.7 : 1}>
|
||||||
<Text style={styles.rowLabel}>{label}</Text>
|
<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 (
|
return (
|
||||||
<SafeAreaView style={styles.root}>
|
<SafeAreaView style={styles.root}>
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
{row('群名称', groupName)}
|
<Row label="群名称" value={groupName} />
|
||||||
<View style={styles.sep} />
|
<View style={styles.sep} />
|
||||||
{row('群成员', undefined, openMembers)}
|
<Row label="群成员" onPress={openMembers} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.sep} />
|
<View style={styles.sep} />
|
||||||
{row('群管理', undefined, () => Alert.alert('提示', '管理功能开发中'))}
|
<Row label="添加成员" onPress={() => (nav as any).navigate('UserSearch')} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.leaveBtn}
|
style={[styles.leaveBtn, leaving && styles.leaveBtnDisabled]}
|
||||||
onPress={() => Alert.alert('退出群聊', '确定要退出吗?', [
|
onPress={handleLeave}
|
||||||
{ text: '取消', style: 'cancel' },
|
disabled={leaving}
|
||||||
{ text: '退出', style: 'destructive', onPress: () => nav.goBack() },
|
|
||||||
])}
|
|
||||||
>
|
>
|
||||||
<Text style={styles.leaveBtnText}>退出群聊</Text>
|
{leaving
|
||||||
|
? <ActivityIndicator color="#ff3b30" />
|
||||||
|
: <Text style={styles.leaveBtnText}>退出群聊</Text>
|
||||||
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
@ -61,5 +86,6 @@ const styles = StyleSheet.create({
|
|||||||
arrow: { color: '#ccc', fontSize: 20 },
|
arrow: { color: '#ccc', fontSize: 20 },
|
||||||
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 16 },
|
sep: { height: StyleSheet.hairlineWidth, backgroundColor: '#e0e0e0', marginLeft: 16 },
|
||||||
leaveBtn: { margin: 16, padding: 16, backgroundColor: '#fff', borderRadius: 8, alignItems: 'center' },
|
leaveBtn: { margin: 16, padding: 16, backgroundColor: '#fff', borderRadius: 8, alignItems: 'center' },
|
||||||
|
leaveBtnDisabled: { opacity: 0.6 },
|
||||||
leaveBtnText: { color: '#ff3b30', fontSize: 16, fontWeight: '600' },
|
leaveBtnText: { color: '#ff3b30', fontSize: 16, fontWeight: '600' },
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
|
View, Text, TextInput, StyleSheet, TouchableOpacity, SafeAreaView,
|
||||||
ActivityIndicator, Alert, ScrollView,
|
ActivityIndicator, Alert, ScrollView, Image,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import { launchImageLibrary } from 'react-native-image-picker'
|
||||||
|
import { uploadFile } from '@xuqm/rn-sdk'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
import { useNavigation } from '@react-navigation/native'
|
import { useNavigation } from '@react-navigation/native'
|
||||||
|
|
||||||
@ -12,15 +14,36 @@ export default function EditProfileScreen() {
|
|||||||
const navigation = useNavigation()
|
const navigation = useNavigation()
|
||||||
const { profile, updateProfile } = useAuth()
|
const { profile, updateProfile } = useAuth()
|
||||||
const [nickname, setNickname] = useState(profile?.nickname ?? '')
|
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 [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 save = async () => {
|
||||||
const nick = nickname.trim()
|
const nick = nickname.trim()
|
||||||
if (!nick) { Alert.alert('提示', '昵称不能为空'); return }
|
if (!nick) { Alert.alert('提示', '昵称不能为空'); return }
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await updateProfile({ nickname: nick, gender })
|
await updateProfile({ nickname: nick, gender, avatar: avatarUri ?? '' })
|
||||||
navigation.goBack()
|
navigation.goBack()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Alert.alert('失败', e?.message ?? '保存失败,请重试')
|
Alert.alert('失败', e?.message ?? '保存失败,请重试')
|
||||||
@ -29,9 +52,31 @@ export default function EditProfileScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const letter = (profile?.nickname || profile?.userId || '?').charAt(0).toUpperCase()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root}>
|
<SafeAreaView style={styles.root}>
|
||||||
<ScrollView contentContainerStyle={styles.inner}>
|
<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>
|
<Text style={styles.label}>昵称</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
@ -57,7 +102,7 @@ export default function EditProfileScreen() {
|
|||||||
})}
|
})}
|
||||||
</View>
|
</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>}
|
{loading ? <ActivityIndicator color="#fff" /> : <Text style={styles.saveBtnText}>保存</Text>}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@ -68,6 +113,26 @@ export default function EditProfileScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
inner: { padding: 20 },
|
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 },
|
label: { fontSize: 13, color: '#888', marginBottom: 8, marginTop: 16 },
|
||||||
input: { backgroundColor: '#fff', borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16 },
|
input: { backgroundColor: '#fff', borderWidth: 1, borderColor: '#e0e0e0', borderRadius: 8, padding: 12, fontSize: 16 },
|
||||||
genderRow: { flexDirection: 'row', gap: 12 },
|
genderRow: { flexDirection: 'row', gap: 12 },
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
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 { useNavigation } from '@react-navigation/native'
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
@ -32,12 +32,24 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
const genderLabel = (g?: string) => ({ MALE: '男', FEMALE: '女', UNKNOWN: '未设置' }[g ?? 'UNKNOWN'] ?? '未设置')
|
const genderLabel = (g?: string) => ({ MALE: '男', FEMALE: '女', UNKNOWN: '未设置' }[g ?? 'UNKNOWN'] ?? '未设置')
|
||||||
const letter = (profile?.nickname || profile?.userId || '?').charAt(0).toUpperCase()
|
const letter = (profile?.nickname || profile?.userId || '?').charAt(0).toUpperCase()
|
||||||
|
const avatarUri = profile?.avatar || undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root}>
|
<SafeAreaView style={styles.root}>
|
||||||
<ScrollView>
|
<ScrollView>
|
||||||
<View style={styles.avatarSection}>
|
<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.nickname}>{profile?.nickname ?? '-'}</Text>
|
||||||
<Text style={styles.userId}>@{profile?.userId ?? '-'}</Text>
|
<Text style={styles.userId}>@{profile?.userId ?? '-'}</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -59,8 +71,24 @@ export default function ProfileScreen() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
root: { flex: 1, backgroundColor: '#f5f5f5' },
|
||||||
avatarSection: { alignItems: 'center', paddingTop: 40, paddingBottom: 24, backgroundColor: '#fff', marginBottom: 16 },
|
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' },
|
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 },
|
nickname: { fontSize: 20, fontWeight: '700', color: '#111', marginBottom: 4 },
|
||||||
userId: { fontSize: 13, color: '#888' },
|
userId: { fontSize: 13, color: '#888' },
|
||||||
section: { backgroundColor: '#fff', marginBottom: 16 },
|
section: { backgroundColor: '#fff', marginBottom: 16 },
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户