diff --git a/client/css/style.css b/client/css/style.css
index 1e7b8f5..358678b 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -897,3 +897,132 @@ body {
border-radius: 4px;
}
+/* File message styles */
+/* 文件消息样式 */
+.file-message {
+ max-width: 300px;
+ background: #f8f9fa;
+ border: 1px solid #e0e0e0;
+ border-radius: 12px;
+ padding: 12px;
+ margin: 4px 0;
+ font-family: inherit;
+}
+
+.file-info {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.file-icon {
+ font-size: 24px;
+ margin-right: 10px;
+ flex-shrink: 0;
+}
+
+.file-details {
+ flex: 1;
+ min-width: 0;
+}
+
+.file-name {
+ font-weight: 500;
+ font-size: 14px;
+ color: #333;
+ margin-bottom: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.file-meta {
+ font-size: 12px;
+ color: #666;
+ display: flex;
+ gap: 8px;
+}
+
+.file-size {
+ font-weight: 500;
+}
+
+.file-volumes {
+ color: #888;
+}
+
+.file-progress-container {
+ margin-bottom: 8px;
+}
+
+.file-progress-bar {
+ width: 100%;
+ height: 6px;
+ background: #e0e0e0;
+ border-radius: 3px;
+ overflow: hidden;
+ margin-bottom: 4px;
+}
+
+.file-progress {
+ height: 100%;
+ background: linear-gradient(90deg, #30a8f7, #4fc3f7);
+ border-radius: 3px;
+ transition: width 0.3s ease;
+ width: 0%;
+}
+
+.file-status {
+ font-size: 11px;
+ color: #666;
+ text-align: center;
+}
+
+.file-download-btn {
+ width: 100%;
+ padding: 8px 12px;
+ background: #30a8f7;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s ease, transform 0.1s ease;
+ display: none;
+}
+
+.file-download-btn:hover {
+ background: #2196f3;
+ transform: translateY(-1px);
+}
+
+.file-download-btn:active {
+ transform: translateY(0);
+}
+
+.file-download-btn:disabled {
+ background: #ccc;
+ cursor: not-allowed;
+ transform: none;
+}
+
+/* File message in bubbles */
+.bubble .file-message {
+ background: rgba(255, 255, 255, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+}
+
+.bubble.me .file-message {
+ background: rgba(255, 255, 255, 0.95);
+}
+
+.bubble.other .file-message {
+ background: #fff;
+}
+
+.bubble.private-message .file-message {
+ background: rgba(255, 248, 220, 0.9);
+ border-color: rgba(255, 193, 7, 0.3);
+}
+
diff --git a/client/js/NodeCrypt.js b/client/js/NodeCrypt.js
index 900e394..486d556 100644
--- a/client/js/NodeCrypt.js
+++ b/client/js/NodeCrypt.js
@@ -332,11 +332,10 @@ class NodeCrypt {
}
}
return
- }
- if (!this.channel[serverDecrypted.c].username) {
+ } if (!this.channel[serverDecrypted.c].username) {
return
}
- if (clientDecrypted.a === 'm' && this.isString(clientDecrypted.t) && this.isString(clientDecrypted.d)) {
+ if (clientDecrypted.a === 'm' && this.isString(clientDecrypted.t) && (this.isString(clientDecrypted.d) || this.isObject(clientDecrypted.d))) {
if (this.callbacks.onClientMessage) {
try {
this.callbacks.onClientMessage({
diff --git a/client/js/chat.js b/client/js/chat.js
index 632b1d8..bb1a2ea 100644
--- a/client/js/chat.js
+++ b/client/js/chat.js
@@ -19,6 +19,9 @@ import {
addClass,
removeClass
} from './util.dom.js';
+import {
+ formatFileSize
+} from './util.file.js';
// Render the chat area
// 渲染聊天区域
@@ -54,11 +57,13 @@ export function addMsg(text, isHistory = false, msgType = 'text', timestamp = nu
if (!chatArea) return;
const className = 'bubble me' + (msgType.includes('_private') ? ' private-message' : '');
const date = new Date(ts);
- const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
- let contentHtml = '';
+ const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0'); let contentHtml = '';
if (msgType === 'image' || msgType === 'image_private') {
const safeImgSrc = escapeHTML(text).replace(/javascript:/gi, '');
contentHtml = `
`
+ } else if (msgType === 'file' || msgType === 'file_private') {
+ // Handle file messages
+ contentHtml = renderFileMessage(text, true);
} else {
contentHtml = textToHTML(text)
}
@@ -95,11 +100,13 @@ export function addOtherMsg(msg, userName = '', avatar = '', isHistory = false,
if (!chatArea) return;
const bubbleWrap = createElement('div', {
class: 'bubble-other-wrap'
- });
- let contentHtml = '';
+ }); let contentHtml = '';
if (msgType === 'image' || msgType === 'image_private') {
const safeImgSrc = escapeHTML(msg).replace(/javascript:/gi, '');
contentHtml = `
`
+ } else if (msgType === 'file' || msgType === 'file_private') {
+ // Handle file messages
+ contentHtml = renderFileMessage(msg, false);
} else {
contentHtml = textToHTML(msg)
}
@@ -254,6 +261,133 @@ export function showImageModal(src) {
updateTransform()
}
+// Render file message content
+// 渲染文件消息内容
+function renderFileMessage(fileData, isSender) {
+ const {
+ fileId,
+ fileName,
+ originalSize,
+ totalVolumes
+ } = fileData;
+ const safeFileName = escapeHTML(fileName);
+ const formattedSize = formatFileSize(originalSize);
+
+ // Determine file icon based on extension
+ // 根据扩展名确定文件图标
+ const fileExtension = fileName.split('.').pop().toLowerCase();
+ let fileIcon = '📄'; // Default document icon 默认文档图标
+
+ const iconMap = {
+ // Images
+ 'jpg': '🖼️',
+ 'jpeg': '🖼️',
+ 'png': '🖼️',
+ 'gif': '🖼️',
+ 'bmp': '🖼️',
+ 'svg': '🖼️',
+ 'webp': '🖼️',
+ // Videos
+ 'mp4': '🎬',
+ 'avi': '🎬',
+ 'mov': '🎬',
+ 'mkv': '🎬',
+ 'wmv': '🎬',
+ 'flv': '🎬',
+ 'webm': '🎬',
+ // Audio
+ 'mp3': '🎵',
+ 'wav': '🎵',
+ 'flac': '🎵',
+ 'aac': '🎵',
+ 'ogg': '🎵',
+ 'm4a': '🎵',
+ // Documents
+ 'pdf': '📕',
+ 'doc': '📄',
+ 'docx': '📄',
+ 'txt': '📄',
+ 'rtf': '📄',
+ 'xls': '📊',
+ 'xlsx': '📊',
+ 'csv': '📊',
+ 'ppt': '📈',
+ 'pptx': '📈',
+ // Archives
+ 'zip': '🗜️',
+ 'rar': '🗜️',
+ '7z': '🗜️',
+ 'tar': '🗜️',
+ 'gz': '🗜️',
+ // Code
+ 'js': '💻',
+ 'html': '💻',
+ 'css': '💻',
+ 'py': '💻',
+ 'java': '💻',
+ 'cpp': '💻',
+ 'c': '💻',
+ 'php': '💻',
+ 'rb': '💻',
+ 'go': '💻',
+ 'ts': '💻',
+ 'json': '💻',
+ 'xml': '💻'
+ };
+ if (iconMap[fileExtension]) {
+ fileIcon = iconMap[fileExtension];
+ }
+
+ // Check actual file transfer status
+ const transfer = window.fileTransfers ? window.fileTransfers.get(fileId) : null;
+ let statusText = 'Waiting...';
+ let progressWidth = '0%';
+ let downloadBtnStyle = 'display: none;';
+
+ if (transfer) {
+ if (transfer.status === 'sending') {
+ const progress = (transfer.sentVolumes / transfer.totalVolumes) * 100;
+ progressWidth = `${progress}%`;
+ statusText = `Sending ${transfer.sentVolumes}/${transfer.totalVolumes}`;
+ } else if (transfer.status === 'receiving') {
+ const progress = (transfer.receivedVolumes.size / transfer.totalVolumes) * 100;
+ progressWidth = `${progress}%`;
+ statusText = `Receiving ${transfer.receivedVolumes.size}/${transfer.totalVolumes}`;
+ } else if (transfer.status === 'completed') {
+ progressWidth = '100%';
+ statusText = '✓ Completed';
+ downloadBtnStyle = isSender ? 'display: none;' : 'display: inline-block;';
+ }
+ } else if (isSender) {
+ statusText = 'Sent';
+ progressWidth = '100%';
+ }
+
+ return `
+
+
+
${fileIcon}
+
+
${safeFileName}
+
+ ${formattedSize}
+ ${totalVolumes} volumes
+
+
+
+
+
+
+ `;
+}
+
// Automatically adjust the height of the input area
// 自动调整输入区域的高度
export function autoGrowInput() {
diff --git a/client/js/main.js b/client/js/main.js
index 1d5c0a2..ca7e412 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -2,11 +2,13 @@
// Import the NodeCrypt module (used for encryption)
import './NodeCrypt.js';
-// 从 util.image.js 中导入设置图片发送的函数
-// Import setupImageSend function from util.image.js
+// 从 util.file.js 中导入设置文件发送的函数
+// Import setupFileSend function from util.file.js
import {
- setupImageSend
-} from './util.image.js';
+ setupFileSend,
+ handleFileMessage,
+ downloadFile
+} from './util.file.js';
// 从 util.emoji.js 中导入设置表情选择器的函数
// Import setupEmojiPicker function from util.emoji.js
@@ -75,6 +77,8 @@ window.addSystemMsg = addSystemMsg;
window.joinRoom = joinRoom;
window.notifyMessage = notifyMessage;
window.setupEmojiPicker = setupEmojiPicker;
+window.handleFileMessage = handleFileMessage;
+window.downloadFile = downloadFile;
// 当 DOM 内容加载完成后执行初始化逻辑
// Run initialization logic when the DOM content is fully loaded
@@ -176,25 +180,24 @@ window.addEventListener('DOMContentLoaded', () => {
}
});
}
-
- // 设置发送图片功能
- // Setup image sending functionality
- setupImageSend({
+ // 设置发送文件功能
+ // Setup file sending functionality
+ setupFileSend({
inputSelector: '.input-message-input', // 消息输入框选择器 / Message input selector
attachBtnSelector: '.chat-attach-btn', // 附件按钮选择器 / Attach button selector
fileInputSelector: '.new-message-wrapper input[type="file"]', // 文件输入框选择器 / File input selector
- onSend: (dataUrl) => {
+ onSend: (message) => {
const rd = roomsData[activeRoomIndex];
if (rd && rd.chat) {
if (rd.privateChatTargetId) {
- // 私聊图片加密并发送
- // Encrypt and send private image
+ // 私聊文件加密并发送
+ // Encrypt and send private file message
const targetClient = rd.chat.channel[rd.privateChatTargetId];
if (targetClient && targetClient.shared) {
const clientMessagePayload = {
a: 'm',
- t: 'image_private',
- d: dataUrl
+ t: message.type + '_private',
+ d: message
};
const encryptedClientMessage = rd.chat.encryptClientMessage(clientMessagePayload, targetClient.shared);
const serverRelayPayload = {
@@ -204,15 +207,23 @@ window.addEventListener('DOMContentLoaded', () => {
};
const encryptedMessageForServer = rd.chat.encryptServerMessage(serverRelayPayload, rd.chat.serverShared);
rd.chat.sendMessage(encryptedMessageForServer);
- addMsg(dataUrl, false, 'image_private');
+
+ // 添加到自己的聊天记录
+ if (message.type === 'file_start') {
+ addMsg(message, false, 'file_private');
+ }
} else {
- addSystemMsg(`Cannot send private image to ${rd.privateChatTargetName}.User might not be fully connected.`)
+ addSystemMsg(`Cannot send private file to ${rd.privateChatTargetName}. User might not be fully connected.`)
}
} else {
- // 公共频道发送图片
- // Send image to public channel
- rd.chat.sendChannelMessage('image', dataUrl);
- addMsg(dataUrl, false, 'image');
+ // 公共频道文件发送
+ // Send file to public channel
+ rd.chat.sendChannelMessage(message.type, message);
+
+ // 添加到自己的聊天记录
+ if (message.type === 'file_start') {
+ addMsg(message, false, 'file');
+ }
}
}
}
diff --git a/client/js/room.js b/client/js/room.js
index ac7ecd2..3926ae5 100644
--- a/client/js/room.js
+++ b/client/js/room.js
@@ -242,10 +242,52 @@ export function handleClientMessage(idx, msg) {
return
}
}
+
let msgType = msg.type || 'text';
+
+ // Handle file messages
+ if (msgType.startsWith('file_')) {
+ // Import handleFileMessage function if needed
+ if (window.handleFileMessage) {
+ window.handleFileMessage(msg.data, msgType.includes('_private'));
+ }
+
+ // Only add file_start messages to chat history
+ if (msgType === 'file_start' || msgType === 'file_start_private') {
+ let realUserName = msg.userName;
+ if (!realUserName && msg.clientId && newRd.userMap[msg.clientId]) {
+ realUserName = newRd.userMap[msg.clientId].userName || newRd.userMap[msg.clientId].username || newRd.userMap[msg.clientId].name
+ }
+
+ roomsData[idx].messages.push({
+ type: 'other',
+ text: msg.data,
+ userName: realUserName,
+ avatar: realUserName,
+ msgType: msgType.includes('_private') ? 'file_private' : 'file',
+ timestamp: Date.now()
+ });
+
+ const notificationMsgType = msgType.includes('_private') ? 'private file' : 'file';
+ if (window.notifyMessage) {
+ window.notifyMessage(newRd.roomName, notificationMsgType, `${msg.data.fileName}`, realUserName)
+ }
+
+ if (activeRoomIndex !== idx) {
+ roomsData[idx].unreadCount = (roomsData[idx].unreadCount || 0) + 1;
+ renderRooms(activeRoomIndex)
+ } else {
+ renderChatArea()
+ }
+ }
+ return;
+ }
+
+ // Handle legacy image detection
if (!msgType.includes('_private') && msg.data && msg.data.startsWith('data:image/')) {
msgType = 'image'
}
+
let realUserName = msg.userName;
if (!realUserName && msg.clientId && newRd.userMap[msg.clientId]) {
realUserName = newRd.userMap[msg.clientId].userName || newRd.userMap[msg.clientId].username || newRd.userMap[msg.clientId].name
diff --git a/client/js/util.file.js b/client/js/util.file.js
new file mode 100644
index 0000000..eaabc63
--- /dev/null
+++ b/client/js/util.file.js
@@ -0,0 +1,351 @@
+// Import necessary modules
+// 导入必要的模块
+import {
+ $,
+ $id,
+ createElement,
+ on,
+ addClass,
+ removeClass
+} from './util.dom.js';
+import { deflate } from 'fflate';
+
+// File transfer state management
+// 文件传输状态管理
+window.fileTransfers = new Map();
+
+// Generate unique file ID
+// 生成唯一文件ID
+function generateFileId() {
+ return 'file_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
+}
+
+// Compress file into volumes
+// 将文件压缩为分卷
+async function compressFileToVolumes(file, volumeSize = 128 * 1024) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = function(e) {
+ const arrayBuffer = new Uint8Array(e.target.result);
+
+ // Compress the file data
+ deflate(arrayBuffer, { level: 6 }, (err, compressed) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ // Split into volumes
+ const volumes = [];
+ for (let i = 0; i < compressed.length; i += volumeSize) {
+ const volume = compressed.slice(i, i + volumeSize);
+ // Convert to base64 without using spread operator to avoid stack overflow
+ let binaryString = '';
+ for (let j = 0; j < volume.length; j++) {
+ binaryString += String.fromCharCode(volume[j]);
+ }
+ volumes.push(btoa(binaryString));
+ }
+
+ resolve({
+ volumes,
+ originalSize: file.size,
+ compressedSize: compressed.length
+ });
+ });
+ };
+ reader.onerror = () => reject(reader.error);
+ reader.readAsArrayBuffer(file);
+ });
+}
+
+// Decompress volumes back to file
+// 将分卷解压回文件
+async function decompressVolumesToFile(volumes, fileName) { try {
+ // Combine all volumes
+ const combinedData = volumes.map(volume => {
+ const binaryString = atob(volume);
+ const bytes = new Uint8Array(binaryString.length);
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i);
+ }
+ return bytes;
+ });
+
+ const totalLength = combinedData.reduce((sum, arr) => sum + arr.length, 0);
+ const compressed = new Uint8Array(totalLength);
+ let offset = 0;
+
+ for (const data of combinedData) {
+ compressed.set(data, offset);
+ offset += data.length;
+ }
+
+ // Decompress
+ return new Promise((resolve, reject) => {
+ import('fflate').then(({ inflate }) => {
+ inflate(compressed, (err, decompressed) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ // Create blob and download
+ const blob = new Blob([decompressed]);
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ resolve();
+ });
+ });
+ });
+ } catch (error) {
+ console.error('Decompression error:', error);
+ throw error;
+ }
+}
+
+// Setup file sending functionality
+// 设置文件发送功能
+export function setupFileSend({
+ inputSelector,
+ attachBtnSelector,
+ fileInputSelector,
+ onSend
+}) {
+ const attachBtn = document.querySelector(attachBtnSelector);
+ const fileInput = document.querySelector(fileInputSelector);
+
+ if (attachBtn && fileInput) {
+ // 移除文件类型限制,接受所有文件
+ // Remove file type restriction, accept all files
+ fileInput.accept = '*/*';
+
+ // 点击附件按钮触发文件选择
+ // Click attach button to trigger file selection
+ attachBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ fileInput.click();
+ });
+
+ // 处理文件选择
+ // Handle file selection
+ fileInput.addEventListener('change', async (e) => {
+ const files = e.target.files;
+ if (files && files.length > 0) {
+ const file = files[0];
+ const fileId = generateFileId();
+
+ try {
+ // 显示压缩进度
+ window.addSystemMsg(`Compressing ${file.name}...`);
+
+ // 压缩文件为分卷
+ const { volumes, originalSize, compressedSize } = await compressFileToVolumes(file);
+
+ // 创建文件传输状态
+ const fileTransfer = {
+ fileId,
+ fileName: file.name,
+ originalSize,
+ compressedSize,
+ totalVolumes: volumes.length,
+ sentVolumes: 0,
+ status: 'sending'
+ };
+
+ window.fileTransfers.set(fileId, fileTransfer);
+
+ // 发送文件开始消息
+ onSend({
+ type: 'file_start',
+ fileId,
+ fileName: file.name,
+ originalSize,
+ compressedSize,
+ totalVolumes: volumes.length
+ });
+
+ // 逐个发送分卷
+ for (let i = 0; i < volumes.length; i++) {
+ onSend({
+ type: 'file_volume',
+ fileId,
+ volumeIndex: i,
+ volumeData: volumes[i],
+ isLast: i === volumes.length - 1
+ });
+
+ // 更新发送进度
+ fileTransfer.sentVolumes = i + 1;
+ updateFileProgress(fileId);
+
+ // 添加小延迟避免消息过快
+ await new Promise(resolve => setTimeout(resolve, 50));
+ }
+
+ // 发送完成消息
+ onSend({
+ type: 'file_complete',
+ fileId
+ });
+
+ fileTransfer.status = 'completed';
+ updateFileProgress(fileId);
+
+ } catch (error) {
+ console.error('File compression error:', error);
+ window.addSystemMsg(`Failed to compress ${file.name}: ${error.message}`);
+ }
+ }
+
+ // 清空文件输入
+ e.target.value = '';
+ });
+ }
+}
+
+// Update file progress in chat
+// 更新聊天中的文件进度
+function updateFileProgress(fileId) {
+ const transfer = window.fileTransfers.get(fileId);
+ if (!transfer) return;
+
+ const elements = document.querySelectorAll(`[data-file-id="${fileId}"]`);
+ elements.forEach(element => {
+ const progressBar = element.querySelector('.file-progress');
+ const statusText = element.querySelector('.file-status');
+ const downloadBtn = element.querySelector('.file-download-btn');
+
+ if (transfer.status === 'sending') {
+ const progress = (transfer.sentVolumes / transfer.totalVolumes) * 100;
+ if (progressBar) progressBar.style.width = `${progress}%`;
+ if (statusText) statusText.textContent = `Sending ${transfer.sentVolumes}/${transfer.totalVolumes}`;
+ } else if (transfer.status === 'receiving') {
+ const progress = (transfer.receivedVolumes.size / transfer.totalVolumes) * 100;
+ if (progressBar) progressBar.style.width = `${progress}%`;
+ if (statusText) statusText.textContent = `Receiving ${transfer.receivedVolumes.size}/${transfer.totalVolumes}`;
+ } else if (transfer.status === 'completed') {
+ if (progressBar) progressBar.style.width = '100%';
+ if (statusText) statusText.textContent = '✓ Completed';
+ if (downloadBtn) {
+ downloadBtn.style.display = 'inline-block';
+ downloadBtn.disabled = false;
+ }
+ }
+ });
+}
+
+// Handle incoming file messages
+// 处理接收到的文件消息
+export function handleFileMessage(message, isPrivate = false) {
+ const { type, fileId } = message;
+
+ switch (type) {
+ case 'file_start':
+ handleFileStart(message, isPrivate);
+ break;
+ case 'file_volume':
+ handleFileVolume(message);
+ break;
+ case 'file_complete':
+ handleFileComplete(message);
+ break;
+ }
+}
+
+// Handle file start message
+// 处理文件开始消息
+function handleFileStart(message, isPrivate) {
+ const { fileId, fileName, originalSize, compressedSize, totalVolumes } = message;
+
+ const fileTransfer = {
+ fileId,
+ fileName,
+ originalSize,
+ compressedSize,
+ totalVolumes,
+ receivedVolumes: new Set(),
+ volumeData: new Array(totalVolumes),
+ status: 'receiving'
+ };
+
+ window.fileTransfers.set(fileId, fileTransfer);
+
+ // 添加文件消息到聊天
+ if (window.addOtherMsg) {
+ window.addOtherMsg({
+ type: 'file',
+ fileId,
+ fileName,
+ originalSize,
+ totalVolumes
+ }, '', '', false, isPrivate ? 'file_private' : 'file');
+ }
+}
+
+// Handle file volume message
+// 处理文件分卷消息
+function handleFileVolume(message) {
+ const { fileId, volumeIndex, volumeData } = message;
+ const transfer = window.fileTransfers.get(fileId);
+
+ if (!transfer) return;
+
+ transfer.receivedVolumes.add(volumeIndex);
+ transfer.volumeData[volumeIndex] = volumeData;
+
+ updateFileProgress(fileId);
+}
+
+// Handle file complete message
+// 处理文件完成消息
+function handleFileComplete(message) {
+ const { fileId } = message;
+ const transfer = window.fileTransfers.get(fileId);
+
+ if (!transfer) return;
+
+ // 检查是否所有分卷都已接收
+ if (transfer.receivedVolumes.size === transfer.totalVolumes) {
+ transfer.status = 'completed';
+ updateFileProgress(fileId);
+ }
+}
+
+// Download file from volumes
+// 从分卷下载文件
+export async function downloadFile(fileId) {
+ const transfer = window.fileTransfers.get(fileId);
+ if (!transfer || transfer.status !== 'completed') return;
+
+ try {
+ await decompressVolumesToFile(transfer.volumeData, transfer.fileName);
+ window.addSystemMsg(`Downloaded ${transfer.fileName}`);
+ } catch (error) {
+ console.error('Download error:', error);
+ window.addSystemMsg(`Failed to download ${transfer.fileName}: ${error.message}`);
+ }
+}
+
+// Format file size
+// 格式化文件大小
+export function formatFileSize(bytes) {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+}
+
+// Legacy image send function for backward compatibility
+// 向后兼容的图片发送函数
+export function setupImageSend(config) {
+ setupFileSend(config);
+}
\ No newline at end of file
diff --git a/client/js/util.image.js b/client/js/util.image.js
deleted file mode 100644
index 5091ec6..0000000
--- a/client/js/util.image.js
+++ /dev/null
@@ -1,117 +0,0 @@
-// Import DOM helpers
-// 导入 DOM 辅助函数
-import {
- $,
- on,
- createElement
-} from './util.dom.js';
-// Process and compress image file
-// 处理并压缩图片文件
-export async function processImage(file, callback) {
- // Create image element for loading file
- // 创建图片元素用于加载文件
- const img = new Image();
- img.onload = function() {
- const maxW = 1280, // Max width
- maxH = 1280; // 最大宽度/高度
- let w = img.naturalWidth,
- h = img.naturalHeight;
- // Resize if too large
- // 如果图片过大则缩放
- if (w > maxW || h > maxH) {
- const scale = Math.min(maxW / w, maxH / h);
- w = Math.round(w * scale);
- h = Math.round(h * scale)
- }
- // Create canvas for drawing image
- // 创建画布用于绘制图片
- const canvas = createElement('canvas');
- canvas.width = w;
- canvas.height = h;
- const ctx = canvas.getContext('2d');
- ctx.drawImage(img, 0, 0, w, h);
- let dataUrl;
- // Export as webp
- // 导出为 webp
- dataUrl = canvas.toDataURL('image/webp', 0.80);
- callback(dataUrl)
- };
- // Read file as data URL
- // 以 dataURL 方式读取文件
- const reader = new FileReader();
- reader.onload = function(e) {
- img.src = e.target.result
- };
- reader.readAsDataURL(file)
-}
-// Translate message key
-// 翻译消息 key
-function t(key) {
- const messages = {
- tooLarge: 'Image is too large (over 5MB)', // 图片过大(超过5MB)
- };
- return messages[key] || key
-}
-// Setup image send logic for UI
-// 设置图片发送相关 UI 逻辑
-export function setupImageSend({
- inputSelector,
- attachBtnSelector,
- fileInputSelector,
- onSend
-}) {
- const input = $(inputSelector);
- const attachBtn = $(attachBtnSelector);
- const fileInput = $(fileInputSelector);
- if (fileInput) fileInput.setAttribute('accept', 'image/*');
- if (attachBtn && fileInput) {
- // Click attach triggers file input
- // 点击附件按钮触发文件选择
- on(attachBtn, 'click', () => fileInput.click());
- // Handle file input change
- // 处理文件选择变化
- on(fileInput, 'change', async function() {
- if (!fileInput.files || !fileInput.files.length) return;
- const file = fileInput.files[0];
- // Only allow image files
- // 只允许图片文件
- if (!file.type.startsWith('image/')) return;
- // Check file size
- // 检查文件大小
- if (file.size > 5 * 1024 * 1024) {
- alert(t('tooLarge'));
- return
- }
- processImage(file, (dataUrl) => {
- if (typeof onSend === 'function') onSend(dataUrl)
- });
- fileInput.value = ''
- })
- }
- if (input) {
- // Paste image from clipboard
- // 从剪贴板粘贴图片
- on(input, 'paste', function(e) {
- if (!e.clipboardData) return;
- for (const item of e.clipboardData.items) {
- // Only handle image type
- // 只处理图片类型
- if (item.type.startsWith('image/')) {
- const file = item.getAsFile();
- if (!file) continue;
- // Check file size
- // 检查文件大小
- if (file.size > 5 * 1024 * 1024) {
- alert(t('tooLarge'));
- continue
- }
- processImage(file, (dataUrl) => {
- if (typeof onSend === 'function') onSend(dataUrl)
- });
- e.preventDefault();
- break
- }
- }
- })
- }
-}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 52e6a6d..f4f8334 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"dompurify": "^3.2.6",
"elliptic": "^6.6.1",
"emoji-picker-element": "^1.26.3",
+ "fflate": "^0.8.2",
"js-chacha20": "^1.1.0",
"js-sha256": "^0.11.0"
},
@@ -2083,6 +2084,12 @@
}
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz",
diff --git a/package.json b/package.json
index e1709d2..5c557d3 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"dompurify": "^3.2.6",
"elliptic": "^6.6.1",
"emoji-picker-element": "^1.26.3",
+ "fflate": "^0.8.2",
"js-chacha20": "^1.1.0",
"js-sha256": "^0.11.0"
}