feat(push): integrate Honor and Xiaomi push plugins with related updates
This commit is contained in:
@@ -476,23 +476,102 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN
|
||||
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 MIME 类型返回文件图标名与主题色
|
||||
*/
|
||||
function getFileVisual(mime?: string): { icon: string; color: string } {
|
||||
if (!mime) return { icon: 'file-document-outline', color: '#5C6BC0' };
|
||||
const m = mime.toLowerCase();
|
||||
if (m === 'application/pdf') return { icon: 'file-pdf-box', color: '#E53935' };
|
||||
if (m.includes('word') || m === 'application/msword') return { icon: 'file-word-box', color: '#1E88E5' };
|
||||
if (m.includes('excel') || m === 'application/vnd.ms-excel' || m === 'text/csv') return { icon: 'file-excel-box', color: '#43A047' };
|
||||
if (m.includes('powerpoint') || m === 'application/vnd.ms-powerpoint') return { icon: 'file-powerpoint-box', color: '#FB8C00' };
|
||||
if (m === 'application/zip' || m.includes('compressed') || m.includes('rar') || m.includes('7z') || m.includes('gzip') || m.includes('tar')) {
|
||||
return { icon: 'folder-zip-outline', color: '#8D6E63' };
|
||||
}
|
||||
if (m.startsWith('audio/')) return { icon: 'file-music-outline', color: '#8E24AA' };
|
||||
if (m.startsWith('video/')) return { icon: 'file-video-outline', color: '#00897B' };
|
||||
if (m.startsWith('image/')) return { icon: 'file-image-outline', color: '#FF6B35' };
|
||||
if (m === 'application/json' || m === 'text/xml' || m === 'application/xml') return { icon: 'code-json', color: '#455A64' };
|
||||
if (m === 'text/plain' || m === 'text/markdown') return { icon: 'file-document-outline', color: '#607D8B' };
|
||||
return { icon: 'file-document-outline', color: '#5C6BC0' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开/下载文件:移动端用浏览器在新页面打开 URL(系统会提示下载/预览),
|
||||
* 此处不再依赖 expo-file-system(SDK 56 已废弃 downloadAsync)。
|
||||
*/
|
||||
async function openRemoteFile(url: string, name: string) {
|
||||
if (!url) return;
|
||||
try {
|
||||
await Linking.openURL(url);
|
||||
} catch (error) {
|
||||
console.warn('打开文件失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染文件 Segment
|
||||
* 当 data.expired 为 true 时显示"文件已过期"失效态,禁用点击。
|
||||
*/
|
||||
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
||||
const styles = useSegmentStyles();
|
||||
const themeColors = useAppColors();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const fileSize = data.size ? formatFileSize(data.size) : '';
|
||||
const visual = getFileVisual(data.mime_type);
|
||||
const isExpired = !!data.expired;
|
||||
|
||||
const handlePress = async () => {
|
||||
if (isExpired || !data.url || downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
await openRemoteFile(data.url, data.name || 'file');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 失效态:灰态卡片 + 时钟图标 + "文件已过期",禁用点击
|
||||
if (isExpired) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileContainer, styles.fileExpired, isMe ? styles.fileMe : styles.fileOther]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<View style={[styles.fileIcon, { backgroundColor: 'rgba(150,150,150,0.15)' }]}>
|
||||
<MaterialCommunityIcons
|
||||
name="clock-alert-outline"
|
||||
size={26}
|
||||
color={themeColors.chat.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.fileInfo}>
|
||||
<Text
|
||||
style={[styles.fileName, styles.fileExpiredText]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{data.name}
|
||||
</Text>
|
||||
<Text style={[styles.fileSize, styles.fileExpiredText]}>
|
||||
文件已过期
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.fileIcon}>
|
||||
<View style={[styles.fileIcon, { backgroundColor: `${visual.color}22` }]}>
|
||||
<MaterialCommunityIcons
|
||||
name="file-document"
|
||||
size={28}
|
||||
color={isMe ? themeColors.primary.contrast : themeColors.primary.main}
|
||||
name={visual.icon as any}
|
||||
size={26}
|
||||
color={isMe ? themeColors.primary.contrast : visual.color}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.fileInfo}>
|
||||
@@ -502,8 +581,15 @@ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ d
|
||||
>
|
||||
{data.name}
|
||||
</Text>
|
||||
{fileSize ? <Text style={styles.fileSize}>{fileSize}</Text> : null}
|
||||
<Text style={styles.fileSize}>
|
||||
{downloading ? '下载中…' : (fileSize || (data.mime_type || '文件'))}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name={downloading ? 'progress-download' : 'download-outline'}
|
||||
size={20}
|
||||
color={isMe ? themeColors.primary.contrast : themeColors.chat.textTertiary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
@@ -1021,6 +1107,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 文件过期失效态
|
||||
fileExpired: {
|
||||
opacity: 0.65,
|
||||
},
|
||||
fileExpiredText: {
|
||||
color: colors.chat.textTertiary,
|
||||
},
|
||||
|
||||
// 链接 - QQ风格:卡片式设计
|
||||
linkContainer: {
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -171,20 +171,13 @@ export const MORE_ACTIONS: MoreAction[] = [
|
||||
color: '#26A69A',
|
||||
gradientColors: ['#4DB6AC', '#00897B'],
|
||||
},
|
||||
{
|
||||
id: 'file',
|
||||
icon: 'file-document',
|
||||
name: '文件',
|
||||
{
|
||||
id: 'file',
|
||||
icon: 'file-document',
|
||||
name: '文件',
|
||||
color: '#5C6BC0',
|
||||
gradientColors: ['#7986CB', '#3F51B5'],
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
icon: 'map-marker',
|
||||
name: '位置',
|
||||
color: '#EC407A',
|
||||
gradientColors: ['#F06292', '#D81B60'],
|
||||
},
|
||||
];
|
||||
|
||||
// 消息撤回时间限制(毫秒)
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
import { formatChatTime } from '@/utils/formatTime';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { Linking } from 'react-native';
|
||||
import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, FileSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto';
|
||||
import { messageService } from '@/services/message';
|
||||
import { uploadService } from '@/services/upload';
|
||||
import { ApiError } from '@/services/core';
|
||||
@@ -1049,6 +1051,38 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
return segments;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 构建文件消息的 segments 数组
|
||||
*/
|
||||
const buildFileSegments = useCallback((
|
||||
fileUrl: string,
|
||||
name: string,
|
||||
size?: number,
|
||||
mimeType?: string,
|
||||
replyToMessage?: GroupMessage | null,
|
||||
): MessageSegment[] => {
|
||||
const segments: MessageSegment[] = [];
|
||||
|
||||
if (replyToMessage) {
|
||||
segments.push({
|
||||
type: 'reply',
|
||||
data: { id: replyToMessage.id, seq: replyToMessage.seq } as ReplySegmentData
|
||||
});
|
||||
}
|
||||
|
||||
segments.push({
|
||||
type: 'file',
|
||||
data: {
|
||||
url: fileUrl,
|
||||
name,
|
||||
size,
|
||||
mime_type: mimeType,
|
||||
} as FileSegmentData
|
||||
});
|
||||
|
||||
return segments;
|
||||
}, []);
|
||||
|
||||
// 【新架构】发送消息(支持纯文字、纯多图、图文同条)
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmedText = inputText.trim();
|
||||
@@ -1253,6 +1287,89 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
setActivePanel('none');
|
||||
}, [pendingAttachments.length]);
|
||||
|
||||
// 选择并发送文件(单选,立即上传后发送)
|
||||
const handlePickFile = useCallback(async () => {
|
||||
if (!conversationId) {
|
||||
setActivePanel('none');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupChat && isMuted) {
|
||||
Alert.alert('无法发送', muteAll ? '当前群组已开启全员禁言' : '你已被管理员禁言');
|
||||
setActivePanel('none');
|
||||
return;
|
||||
}
|
||||
|
||||
setActivePanel('none');
|
||||
|
||||
try {
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
multiple: false,
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
if (result.canceled || !result.assets || result.assets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
setUploadingAttachments(true);
|
||||
|
||||
const uploaded = await uploadService.uploadFile(
|
||||
{
|
||||
uri: asset.uri,
|
||||
name: asset.name,
|
||||
type: asset.mimeType || undefined,
|
||||
},
|
||||
'chat'
|
||||
);
|
||||
|
||||
if (!uploaded?.url) {
|
||||
Alert.alert('上传失败', getSendErrorMessage(null, '文件上传失败,请重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = buildFileSegments(
|
||||
uploaded.url,
|
||||
uploaded.name || asset.name,
|
||||
uploaded.size ?? asset.size,
|
||||
uploaded.mime_type ?? asset.mimeType,
|
||||
replyingTo,
|
||||
);
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
if (isGroupChat && effectiveGroupId) {
|
||||
await messageService.sendMessageByAction('group', conversationId, segments);
|
||||
} else {
|
||||
await sendMessageViaManager(segments);
|
||||
}
|
||||
setReplyingTo(null);
|
||||
setTimeout(() => scrollToLatest(false, false, 'send-file'), 100);
|
||||
} catch (error) {
|
||||
console.error('发送文件消息失败:', error);
|
||||
Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择文件失败:', error);
|
||||
Alert.alert('错误', getSendErrorMessage(error, '选择文件失败'));
|
||||
} finally {
|
||||
setUploadingAttachments(false);
|
||||
}
|
||||
}, [
|
||||
conversationId,
|
||||
isGroupChat,
|
||||
isMuted,
|
||||
muteAll,
|
||||
effectiveGroupId,
|
||||
replyingTo,
|
||||
buildFileSegments,
|
||||
sendMessageViaManager,
|
||||
scrollToLatest,
|
||||
getSendErrorMessage,
|
||||
]);
|
||||
|
||||
// 处理更多功能
|
||||
const handleMoreAction = useCallback((actionId: string) => {
|
||||
switch (actionId) {
|
||||
@@ -1281,17 +1398,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
||||
handleTakePhoto();
|
||||
break;
|
||||
case 'file':
|
||||
Alert.alert('提示', '文件功能即将上线');
|
||||
setActivePanel('none');
|
||||
break;
|
||||
case 'location':
|
||||
Alert.alert('提示', '位置功能即将上线');
|
||||
setActivePanel('none');
|
||||
handlePickFile();
|
||||
break;
|
||||
default:
|
||||
setActivePanel('none');
|
||||
}
|
||||
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
|
||||
}, [handlePickImage, handleTakePhoto, handlePickFile, isGroupChat, otherUser, conversationId]);
|
||||
|
||||
// 插入表情
|
||||
const handleInsertEmoji = useCallback((emoji: string) => {
|
||||
|
||||
Reference in New Issue
Block a user