Files

479 lines
17 KiB
JavaScript

// Import necessary modules
// 导入必要的模块
import {
createAvatarSVG
} from './util.avatar.js';
import {
roomsData,
activeRoomIndex
} from './room.js';
import {
escapeHTML,
textToHTML
} from './util.string.js';
import {
$,
$id,
createElement,
on,
off,
addClass,
removeClass
} from './util.dom.js';
import {
formatFileSize
} from './util.file.js';
import {
t
} from './util.i18n.js';
// Render the chat area
// 渲染聊天区域
export function renderChatArea() {
const chatArea = $id('chat-area');
if (!chatArea) return;
if (activeRoomIndex < 0 || !roomsData[activeRoomIndex]) {
chatArea.innerHTML = '';
return
}
chatArea.innerHTML = '';
roomsData[activeRoomIndex].messages.forEach(m => {
if (m.type === 'me') addMsg(m.text, true, m.msgType || 'text', m.timestamp);
else if (m.type === 'system') addSystemMsg(m.text, true, m.timestamp);
else addOtherMsg(m.text, m.userName, m.avatar, true, m.msgType || 'text', m.timestamp)
})
}
// Add a message to the chat area
// 添加消息到聊天区域
export function addMsg(text, isHistory = false, msgType = 'text', timestamp = null) {
let ts = isHistory ? timestamp : (timestamp || Date.now());
if (!ts) return;
if (!isHistory && activeRoomIndex >= 0) {
roomsData[activeRoomIndex].messages.push({
type: 'me',
text,
msgType,
timestamp: ts
})
} const chatArea = $id('chat-area');
if (!chatArea) return;
let 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 = ''; if (msgType === 'image' || msgType === 'image_private') {
// Handle image messages (can contain both text and images)
if (typeof text === 'object' && text.images && Array.isArray(text.images)) {
// New multi-image format: {text: "", images: ["data:image...", "data:image..."]}
const messageText = text.text ? textToHTML(text.text) : '';
const imageElements = text.images.map(imgData => {
const safeImgSrc = escapeHTML(imgData).replace(/javascript:/gi, '');
return `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}).join('');
if (messageText && imageElements) {
// Mixed content: text + images
contentHtml = `<div class="mixed-content">
<div class="message-text">${messageText}</div>
${imageElements}
</div>`;
} else if (imageElements) {
// Images only
contentHtml = imageElements;
} else {
// Fallback to text only
contentHtml = messageText;
}
} else if (typeof text === 'object' && text.image) {
// Legacy single image format: {text: "", image: "data:image..."}
const safeImgSrc = escapeHTML(text.image).replace(/javascript:/gi, '');
const messageText = text.text ? textToHTML(text.text) : '';
if (messageText) {
// Mixed content: text + image
contentHtml = `<div class="mixed-content">
<div class="message-text">${messageText}</div>
<img src="${safeImgSrc}" alt="image" class="bubble-img">
</div>`;
} else {
// Image only
contentHtml = `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}
} else {
// Legacy format: plain image data URL
const safeImgSrc = escapeHTML(text).replace(/javascript:/gi, '');
contentHtml = `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}
} else if (msgType === 'file' || msgType === 'file_private') {
// Handle file messages
contentHtml = renderFileMessage(text, true);
// Add file-bubble class for special timestamp positioning
className += ' file-bubble';
} else {
contentHtml = textToHTML(text)
}
const div = createElement('div', {
class: className
}, `<span class="bubble-content">${contentHtml}</span><span class="bubble-meta">${time}</span>`);
chatArea.appendChild(div);
chatArea.scrollTop = chatArea.scrollHeight
}
// Add a message from another user to the chat area
// 添加来自其他用户的消息到聊天区域
export function addOtherMsg(msg, userName = '', avatar = '', isHistory = false, msgType = 'text', timestamp = null) {
if (!userName && activeRoomIndex >= 0) {
const rd = roomsData[activeRoomIndex];
// 优先使用文件消息自带的 userName 字段
if (msg && msg.userName) {
userName = msg.userName;
} else if (rd && msg && msg.clientId && rd.userMap[msg.clientId]) {
userName = rd.userMap[msg.clientId].userName || rd.userMap[msg.clientId].username || rd.userMap[msg.clientId].name || t('ui.anonymous', 'Anonymous')
}
}
userName = userName || t('ui.anonymous', 'Anonymous');
let ts = isHistory ? timestamp : (timestamp || Date.now());
if (!ts) return;
const chatArea = $id('chat-area');
if (!chatArea) return;
const bubbleWrap = createElement('div', {
class: 'bubble-other-wrap'
}); let contentHtml = ''; if (msgType === 'image' || msgType === 'image_private') {
// Handle image messages (can contain both text and images)
if (typeof msg === 'object' && msg.images && Array.isArray(msg.images)) {
// New multi-image format: {text: "", images: ["data:image...", "data:image..."]}
const messageText = msg.text ? textToHTML(msg.text) : '';
const imageElements = msg.images.map(imgData => {
const safeImgSrc = escapeHTML(imgData).replace(/javascript:/gi, '');
return `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}).join('');
if (messageText && imageElements) {
// Mixed content: text + images
contentHtml = `<div class="mixed-content">
<div class="message-text">${messageText}</div>
${imageElements}
</div>`;
} else if (imageElements) {
// Images only
contentHtml = imageElements;
} else {
// Fallback to text only
contentHtml = messageText;
}
} else if (typeof msg === 'object' && msg.image) {
// Legacy single image format: {text: "", image: "data:image..."}
const safeImgSrc = escapeHTML(msg.image).replace(/javascript:/gi, '');
const messageText = msg.text ? textToHTML(msg.text) : '';
if (messageText) {
// Mixed content: text + image
contentHtml = `<div class="mixed-content">
<div class="message-text">${messageText}</div>
<img src="${safeImgSrc}" alt="image" class="bubble-img">
</div>`;
} else {
// Image only
contentHtml = `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}
} else {
// Legacy format: plain image data URL
const safeImgSrc = escapeHTML(msg).replace(/javascript:/gi, '');
contentHtml = `<img src="${safeImgSrc}" alt="image" class="bubble-img">`;
}
} else if (msgType === 'file' || msgType === 'file_private') {
// Handle file messages
contentHtml = renderFileMessage(msg, false); } else {
contentHtml = textToHTML(msg)
}
const safeUserName = escapeHTML(userName);
const date = new Date(ts);
const time = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0');
let bubbleClasses = 'bubble other';
if (msgType && msgType.includes('_private')) {
bubbleClasses += ' private-message'
}
if (msgType === 'file' || msgType === 'file_private') {
bubbleClasses += ' file-bubble';
}
bubbleWrap.innerHTML = `<span class="avatar"></span><div class="bubble-other-main"><div class="${bubbleClasses}"><div class="bubble-other-name">${safeUserName}</div><span class="bubble-content">${contentHtml}</span><span class="bubble-meta">${time}</span></div></div>`;
const svg = createAvatarSVG(userName);
const avatarEl = $('.avatar', bubbleWrap);
if (avatarEl) {
const cleanSvg = svg.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
avatarEl.innerHTML = cleanSvg
}
chatArea.appendChild(bubbleWrap);
chatArea.scrollTop = chatArea.scrollHeight
}
// Add a system message to the chat area
// 添加系统消息到聊天区域
export function addSystemMsg(text, isHistory = false, timestamp = null) {
if (!isHistory && activeRoomIndex >= 0) {
const ts = timestamp || Date.now();
roomsData[activeRoomIndex].messages.push({
type: 'system',
text,
timestamp: ts
})
}
const chatArea = $id('chat-area');
if (!chatArea) return;
const safeText = textToHTML(text);
const div = createElement('div', {
class: 'bubble system'
}, `<span class="bubble-content">${safeText}</span>`);
chatArea.appendChild(div);
chatArea.scrollTop = chatArea.scrollHeight
}
// Update the style of the chat input area
// 更新聊天输入区域的样式
export function updateChatInputStyle() {
const rd = roomsData[activeRoomIndex];
const chatInputArea = $('.chat-input-area');
const placeholder = $('.input-field-placeholder');
const inputMessageInput = $('.input-message-input');
if (!chatInputArea || !placeholder || !inputMessageInput) return; if (rd && rd.privateChatTargetId) {
addClass(chatInputArea, 'private-mode');
addClass(inputMessageInput, 'private-mode');
placeholder.textContent = `${t('ui.private_message_to', 'Private Message to')} ${escapeHTML(rd.privateChatTargetName)}`
} else {
removeClass(chatInputArea, 'private-mode');
removeClass(inputMessageInput, 'private-mode');
placeholder.textContent = t('ui.message', 'Message')
}
const html = inputMessageInput.innerHTML.replace(/<br\s*\/?>(\s*)?/gi, '').replace(/&nbsp;/g, '').replace(/\u200B/g, '').trim();
placeholder.style.opacity = (html === '') ? '1' : '0'
}
// Setup image preview functionality
// 设置图片预览功能
export function setupImagePreview() {
on($id('chat-area'), 'click', function(e) {
const target = e.target;
if (target.tagName === 'IMG' && target.closest('.bubble-content')) {
showImageModal(target.src)
}
})
}
// Show the image modal
// 显示图片模态框
export function showImageModal(src) {
const modal = createElement('div', {
class: 'img-modal-bg'
}, `<div class="img-modal-blur"></div><div class="img-modal-content img-modal-content-overflow"><img src="${src}"class="img-modal-img"/><span class="img-modal-close">&times;</span></div>`);
document.body.appendChild(modal);
on($('.img-modal-close', modal), 'click', () => modal.remove());
on(modal, 'click', (e) => {
if (e.target === modal) modal.remove()
});
const img = $('img', modal);
let scale = 1;
let isDragging = false;
let lastX = 0,
lastY = 0;
let offsetX = 0,
offsetY = 0;
img.ondragstart = function(e) {
e.preventDefault()
};
on(img, 'wheel', function(ev) {
ev.preventDefault();
const prevScale = scale;
scale += ev.deltaY < 0 ? 0.1 : -0.1;
scale = Math.max(0.2, Math.min(5, scale));
if (scale === 1) {
offsetX = 0;
offsetY = 0
}
updateTransform()
});
function updateTransform() {
img.style.transform = `translate(${offsetX}px,${offsetY}px)scale(${scale})`;
img.style.cursor = scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'zoom-in'
}
on(img, 'mousedown', function(ev) {
if (scale <= 1) return;
isDragging = true;
lastX = ev.clientX;
lastY = ev.clientY;
img.style.cursor = 'grabbing';
document.body.style.userSelect = 'none'
});
function onMouseMove(ev) {
if (!isDragging) return;
offsetX += ev.clientX - lastX;
offsetY += ev.clientY - lastY;
lastX = ev.clientX;
lastY = ev.clientY;
updateTransform()
}
function onMouseUp() {
if (isDragging) {
isDragging = false;
img.style.cursor = 'grab';
document.body.style.userSelect = ''
}
}
on(window, 'mousemove', onMouseMove);
on(window, 'mouseup', onMouseUp);
on(img, 'dblclick', function() {
scale = 1;
offsetX = 0;
offsetY = 0;
updateTransform()
});
const cleanup = () => {
off(window, 'mousemove', onMouseMove);
off(window, 'mouseup', onMouseUp);
document.body.style.userSelect = ''
};
on(modal, 'remove', cleanup);
on($('.img-modal-close', modal), 'click', cleanup);
updateTransform()
}
// Render file message content
// 渲染文件消息内容
function renderFileMessage(fileData, isSender) {
const {
fileId,
fileName,
originalSize,
totalVolumes,
fileCount,
isArchive
} = fileData;
// For archive files, show file count and total size
let displayName, displayMeta;
if (isArchive && fileCount) {
// 使用 i18n,保持原格式
displayName = `${fileCount}${t('file.files', ' files')}`;
displayMeta = `${t('file.total', 'Total')}: ${formatFileSize(originalSize)}`;
} else {
displayName = fileName;
displayMeta = formatFileSize(originalSize);
}
const safeDisplayName = escapeHTML(displayName);
// Check actual file transfer status
const transfer = window.fileTransfers ? window.fileTransfers.get(fileId) : null;
let statusText = '';
let progressWidth = '0%';
let downloadBtnStyle = 'display: none;';
let showProgress = false;
if (transfer) {
if (transfer.status === 'sending') {
const progress = (transfer.sentVolumes / transfer.totalVolumes) * 100;
progressWidth = `${progress}%`;
statusText = `Sending ${transfer.sentVolumes}/${transfer.totalVolumes}`;
showProgress = true;
} else if (transfer.status === 'receiving') {
const progress = (transfer.receivedVolumes.size / transfer.totalVolumes) * 100;
progressWidth = `${progress}%`;
statusText = `Receiving ${transfer.receivedVolumes.size}/${transfer.totalVolumes}`;
showProgress = true;
} else if (transfer.status === 'completed') {
// 完成时不显示任何状态,只显示下载按钮
downloadBtnStyle = isSender ? 'display: none;' : 'display: flex;';
} } else if (isSender) {
// 发送方历史消息,不显示状态和下载按钮
downloadBtnStyle = 'display: none;';
} else {
// 接收方历史消息,直接显示下载按钮(带动画效果)
downloadBtnStyle = 'display: flex;';
}
// Different icon for archives vs single files
const fileIcon = isArchive ? '📦' : '📄';
return `
<div class="file-message" data-file-id="${fileId}">
<div class="file-main-content">
<div class="file-info">
<div class="file-icon">${fileIcon}</div>
<div class="file-details">
<div class="file-name" title="${safeDisplayName}">${safeDisplayName}</div>
<div class="file-meta">${displayMeta}</div>
</div>
</div>
<button class="file-download-btn show" style="${downloadBtnStyle}" onclick="window.downloadFile('${fileId}')">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g stroke-width="0"></g><g stroke-linecap="round" stroke-linejoin="round"></g><g> <path fill-rule="evenodd" clip-rule="evenodd" d="M8 10C8 7.79086 9.79086 6 12 6C14.2091 6 16 7.79086 16 10V11H17C18.933 11 20.5 12.567 20.5 14.5C20.5 16.433 18.933 18 17 18H16.9C16.3477 18 15.9 18.4477 15.9 19C15.9 19.5523 16.3477 20 16.9 20H17C20.0376 20 22.5 17.5376 22.5 14.5C22.5 11.7793 20.5245 9.51997 17.9296 9.07824C17.4862 6.20213 15.0003 4 12 4C8.99974 4 6.51381 6.20213 6.07036 9.07824C3.47551 9.51997 1.5 11.7793 1.5 14.5C1.5 17.5376 3.96243 20 7 20H7.1C7.65228 20 8.1 19.5523 8.1 19C8.1 18.4477 7.65228 18 7.1 18H7C5.067 18 3.5 16.433 3.5 14.5C3.5 12.567 5.067 11 7 11H8V10ZM13 11C13 10.4477 12.5523 10 12 10C11.4477 10 11 10.4477 11 11V16.5858L9.70711 15.2929C9.31658 14.9024 8.68342 14.9024 8.29289 15.2929C7.90237 15.6834 7.90237 16.3166 8.29289 16.7071L11.2929 19.7071C11.6834 20.0976 12.3166 20.0976 12.7071 19.7071L15.7071 16.7071C16.0976 16.3166 16.0976 15.6834 15.7071 15.2929C15.3166 14.9024 14.6834 14.9024 14.2929 15.2929L13 16.5858V11Z" fill="currentColor"></path> </g></svg>
</button>
</div>
${showProgress ? `<div class="file-progress-container">
<div class="file-progress-bar">
<div class="file-progress" style="width: ${progressWidth}"></div>
</div>
<div class="file-status">${statusText}</div>
</div>` : ''}
</div>
`;
}
// Automatically adjust the height of the input area
// 自动调整输入区域的高度
export function autoGrowInput() {
const input = $('.input-message-input');
if (!input) return;
input.style.height = 'auto';
input.style.height = input.scrollHeight + 'px'
}
// Handle pasting text as plain text
// 处理粘贴为纯文本
function handlePasteAsPlainText(element) {
if (!element) return;
on(element, 'paste', function(e) {
e.preventDefault();
let text = '';
if (e.clipboardData || window.clipboardData) {
text = (e.clipboardData || window.clipboardData).getData('text/plain')
}
if (document.queryCommandSupported('insertText')) {
document.execCommand('insertText', false, text)
} else {
const selection = window.getSelection();
if (selection.rangeCount) {
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(text);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range)
}
}
})
}
// Setup input placeholder functionality
// 设置输入框占位符功能
export function setupInputPlaceholder() {
const input = $('.input-message-input');
const placeholder = $('.input-field-placeholder');
if (!input || !placeholder) return;
function checkEmpty() {
const html = input.innerHTML.replace(/<br\s*\/?>(\s*)?/gi, '').replace(/&nbsp;/g, '').replace(/\u200B/g, '').trim();
if (html === '') {
placeholder.style.opacity = '1'
} else {
placeholder.style.opacity = '0'
}
autoGrowInput()
}
on(input, 'input', checkEmpty);
on(input, 'blur', checkEmpty);
on(input, 'focus', checkEmpty);
handlePasteAsPlainText(input);
checkEmpty();
autoGrowInput();
updateChatInputStyle()
}