From df977c089eb0fc9148a0e7c56d5ed4c2750c5ad2 Mon Sep 17 00:00:00 2001 From: defnax <9952056+defnax@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:40:21 +0200 Subject: [PATCH] Added attach file, image & emoji support to mail composer --- webui-src/app/mail/mail_compose.js | 310 ++++++++++++++++++++++++++--- 1 file changed, 286 insertions(+), 24 deletions(-) diff --git a/webui-src/app/mail/mail_compose.js b/webui-src/app/mail/mail_compose.js index 87fdcee..230d376 100644 --- a/webui-src/app/mail/mail_compose.js +++ b/webui-src/app/mail/mail_compose.js @@ -2,14 +2,28 @@ const m = require('mithril'); const rs = require('rswebui'); const widget = require('widgets'); const peopleUtil = require('people/people_util'); +const chatEmoji = require('chat/chat_emoji'); const UserAvatarsCache = {}; const MAX_RECIPIENTS = 20; +function formatFileSize(bytes) { + if (!bytes) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + const Layout = () => { let showCc = false; let showBcc = false; let ownAvatars = {}; + let attachments = []; + let showEmojiPicker = false; + let emojiSearch = ''; + let emojiCategory = 'Smileys'; + const Data = { allUsers: [], ownId: [], @@ -34,6 +48,51 @@ const Layout = () => { }, }, }; + + function insertContentIntoMailBody(content) { + const mailBody = document.querySelector('#composerMailBody'); + if (!mailBody) return; + mailBody.focus(); + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + if (mailBody.contains(range.commonAncestorContainer)) { + range.deleteContents(); + if (typeof content === 'string') { + const temp = document.createElement('div'); + temp.innerHTML = content; + const frag = document.createDocumentFragment(); + let node, lastNode; + while ((node = temp.firstChild)) { + lastNode = frag.appendChild(node); + } + range.insertNode(frag); + if (lastNode) { + range.setStartAfter(lastNode); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + } else if (content instanceof Node) { + range.insertNode(content); + range.setStartAfter(content); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + return; + } + } + if (typeof content === 'string') { + mailBody.innerHTML += content; + } else if (content instanceof Node) { + mailBody.appendChild(content); + } + } + + function insertEmoji(emoji) { + insertContentIntoMailBody(document.createTextNode(emoji)); + } async function loadMailUserDetails(msgType, senderId, recipientList, isDirectMail, ccList) { Data.allUsers = await peopleUtil.sortUsers(rs.userList.users); @@ -286,31 +345,89 @@ const Layout = () => { ); } function sendMail() { + // Auto-add inputVal if user typed recipient but didn't click dropdown item + ['to', 'cc', 'bcc'].forEach((type) => { + const val = Data.recipients[type].inputVal ? Data.recipients[type].inputVal.trim() : ''; + if (val) { + const match = Data.allUsers.find((u) => u.mGroupName && (u.mGroupName.toLowerCase() === val.toLowerCase() || u.mGroupId === val)); + if (match && !Data.recipients[type].sendList.some((item) => item.mGroupId === match.mGroupId)) { + Data.recipients[type].sendList.push(match); + } else if (!match && val.length > 5) { + Data.recipients[type].sendList.push({ mGroupId: val, mGroupName: val }); + } + Data.recipients[type].inputVal = ''; + } + }); + const to = Data.recipients.to.sendList.map((toItem) => toItem.mGroupId); const cc = Data.recipients.cc.sendList.map((ccItem) => ccItem.mGroupId); const bcc = Data.recipients.bcc.sendList.map((bccItem) => bccItem.mGroupId); - const { identity: from, subject } = Data; + + let from = Data.identity; + if (!from && Data.ownId && Data.ownId.length > 0) { + from = Data.ownId[0]; + Data.identity = from; + } + + if (to.length === 0) { + widget.popupMessage( + m('.widget', [ + m('.widget__heading', m('h3', 'Missing Recipient')), + m('.widget__body', m('p', 'Please select at least one recipient in the "To" field.')), + ]) + ); + return; + } + + if (!from) { + widget.popupMessage( + m('.widget', [ + m('.widget__heading', m('h3', 'Missing Identity')), + m('.widget__body', m('p', 'Please select a "From" identity.')), + ]) + ); + return; + } + + const subject = Data.subject || '(No Subject)'; const mailBodyElement = document.querySelector('#composerMailBody'); - const mailBody = `
${mailBodyElement.innerHTML}
`; - rs.rsJsonApiRequest('/rsMail/sendMail', { from, subject, mailBody, to, cc, bcc }).then( - (res) => { - if (res.body.retval) { - Object.keys(Data.recipients).forEach((recipientType) => { - Data.recipients[recipientType].sendList = []; - }); - Data.subject = ''; - mailBodyElement.innerHTML = ''; - v.attrs.setShowCompose(false); - } - const success = res.body.retval === 1; - widget.popupMessage( - m('.widget', [ - m('.widget__heading', m('h3', success ? 'Success' : 'Error')), - m('.widget__body', m('p', success ? 'Mail sent successfully' : res.body.errorMsg)), - ]) - ); + let fullMailBody = mailBodyElement ? mailBodyElement.innerHTML : ''; + + if (attachments.length > 0) { + const attHtml = ` +

Attachments (${attachments.length}):
+ + `; + fullMailBody += attHtml; + } + + const mailBody = `
${fullMailBody}
`; + + rs.rsJsonApiRequest('/rsMail/sendMail', { from, subject, mailBody, to, cc, bcc }, (data, success) => { + const isOk = success && data && ( + data.retval > 0 || + data.retval === true || + (Array.isArray(data.trackingIds) && data.trackingIds.length > 0) + ); + if (isOk) { + Object.keys(Data.recipients).forEach((recipientType) => { + Data.recipients[recipientType].sendList = []; + }); + Data.subject = ''; + if (mailBodyElement) mailBodyElement.innerHTML = ''; + attachments = []; + v.attrs.setShowCompose(false); } - ); + widget.popupMessage( + m('.widget', [ + m('.widget__heading', m('h3', isOk ? 'Success' : 'Error')), + m('.widget__body', m('p', isOk ? 'Mail sent successfully' : (data?.errorMsg || data?.errorMessage || 'Failed to send mail'))), + ]) + ); + m.redraw(); + }); } return m('.widget', [ m('.widget__heading', m('h3', 'Compose a mail')), @@ -453,6 +570,67 @@ const Layout = () => { value: Data.subject, oninput: (e) => (Data.subject = e.target.value), }), + + // Hidden File Inputs + m('input#mail-file-attach[type=file]', { + style: 'display: none;', + multiple: true, + onchange: (e) => { + const files = Array.from(e.target.files || []); + files.forEach((file) => { + attachments.push({ + name: file.name, + size: formatFileSize(file.size), + type: file.type, + rawFile: file, + }); + }); + e.target.value = ''; + m.redraw(); + }, + }), + m('input#mail-image-attach[type=file]', { + style: 'display: none;', + accept: 'image/*', + onchange: (e) => { + const file = e.target.files && e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + const src = event.target.result; + insertContentIntoMailBody(``); + }; + reader.readAsDataURL(file); + } + e.target.value = ''; + m.redraw(); + }, + }), + + // File Attachments Bar + attachments.length > 0 && + m('.mail-attachments-bar', { + style: 'margin: 0.5rem 0; padding: 0.5rem 0.75rem; background: #f8fafc; border: 1px solid #cbd5e1; border-radius: 0.375rem; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;' + }, [ + m('span', { style: 'font-weight: 600; font-size: 0.85rem; color: #475569; display: flex; align-items: center; gap: 0.35rem; margin-right: 0.25rem;' }, [ + m('i.fas.fa-paperclip', { style: 'color: #019DFF;' }), + `Attachments (${attachments.length}):` + ]), + attachments.map((att, index) => + m('.mail-attachment-chip', { + style: 'display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.65rem; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 1rem; font-size: 0.825rem; font-weight: 500; color: #1e293b; box-shadow: 0 1px 2px rgba(0,0,0,0.05);' + }, [ + m('i.fas.fa-file-alt', { style: 'color: #3b82f6;' }), + m('span', att.name), + m('span', { style: 'color: #94a3b8; font-size: 0.75rem;' }, `(${att.size})`), + m('i.fas.fa-times', { + style: 'cursor: pointer; color: #ef4444; margin-left: 0.2rem; font-size: 0.8rem;', + onclick: () => attachments.splice(index, 1), + }) + ]) + ) + ]), + m('.compose-mail__message', [ m('.compose-mail__message-body[placeholder=Message][contenteditable]#composerMailBody', { oncreate: (vnode) => { @@ -461,10 +639,94 @@ const Layout = () => { } } }), - ]), - m('button.compose-mail__send-btn', { onclick: sendMail }, [ - m('span', 'Send Mail'), - m('i.fas.fa-paper-plane'), + + // Modern Mail Composer Bottom Toolbar + m('.mail-compose-toolbar', { + style: 'display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0.75rem; background: #ffffff; border: 1px solid #cbd5e1; border-top: 1px solid #e2e8f0; border-radius: 0 0 0.375rem 0.375rem; position: relative;' + }, [ + m('.toolbar-left', { style: 'display: flex; align-items: center; gap: 0.5rem;' }, [ + m('button.mail-compose-send-btn', { + style: 'display: flex; align-items: center; gap: 0.5rem; padding: 0.45rem 1.25rem; background: #019DFF; color: #ffffff; border: none; border-radius: 1.5rem; font-weight: 600; font-size: 0.9rem; cursor: pointer; transition: background 0.15s ease; box-shadow: 0 2px 4px rgba(1,157,255,0.25);', + onclick: sendMail, + }, [ + m('span', 'Send'), + m('i.fas.fa-paper-plane', { style: 'font-size: 0.85rem;' }), + ]), + m('.toolbar-divider', { style: 'width: 1px; height: 22px; background: #cbd5e1; margin: 0 0.25rem;' }), + m('button.mail-tool-btn', { + type: 'button', + title: 'Attach files', + style: 'width: 34px; height: 34px; border-radius: 50%; border: none; background: transparent; color: #475569; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;', + onmouseenter: (e) => (e.currentTarget.style.background = '#f1f5f9'), + onmouseleave: (e) => (e.currentTarget.style.background = 'transparent'), + onclick: () => { + const input = document.getElementById('mail-file-attach'); + if (input) input.click(); + }, + }, m('i.fas.fa-paperclip', { style: 'font-size: 1.05rem;' })), + m('button.mail-tool-btn', { + type: 'button', + title: 'Insert image', + style: 'width: 34px; height: 34px; border-radius: 50%; border: none; background: transparent; color: #475569; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;', + onmouseenter: (e) => (e.currentTarget.style.background = '#f1f5f9'), + onmouseleave: (e) => (e.currentTarget.style.background = 'transparent'), + onclick: () => { + const input = document.getElementById('mail-image-attach'); + if (input) input.click(); + }, + }, m('i.fas.fa-image', { style: 'font-size: 1.05rem;' })), + m('button.mail-tool-btn', { + type: 'button', + title: 'Insert emoji', + style: `width: 34px; height: 34px; border-radius: 50%; border: none; background: ${showEmojiPicker ? '#e0f2fe' : 'transparent'}; color: ${showEmojiPicker ? '#0284c7' : '#475569'}; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease;`, + onclick: () => (showEmojiPicker = !showEmojiPicker), + }, m('i.fas.fa-smile', { style: 'font-size: 1.05rem;' })), + ]), + + // Floating Emoji Picker Popover + showEmojiPicker && m('.mail-emoji-picker-popover', { + style: 'position: absolute; bottom: 50px; left: 130px; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 0.5rem; box-shadow: 0 10px 25px -5px rgba(0,0,0,0.15), 0 8px 10px -6px rgba(0,0,0,0.1); width: 320px; max-height: 340px; z-index: 2000; display: flex; flex-direction: column; overflow: hidden;', + onclick: (e) => e.stopPropagation(), + }, [ + m('.emoji-search-bar', { style: 'padding: 0.5rem; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; gap: 0.5rem;' }, [ + m('i.fas.fa-search', { style: 'color: #94a3b8; font-size: 0.85rem;' }), + m('input[type=text][placeholder=Search emoji...]', { + style: 'border: none; outline: none; width: 100%; font-size: 0.85rem;', + value: emojiSearch, + oninput: (e) => (emojiSearch = e.target.value), + }), + emojiSearch && m('i.fas.fa-times', { + style: 'cursor: pointer; color: #94a3b8; font-size: 0.85rem;', + onclick: () => (emojiSearch = ''), + }), + ]), + !emojiSearch && m('.emoji-cat-bar', { style: 'display: flex; background: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 0.25rem; overflow-x: auto;' }, + chatEmoji.EMOJI_CATEGORIES.map(c => + m('button', { + style: `border: none; background: ${c === emojiCategory ? '#ffffff' : 'transparent'}; border-radius: 0.25rem; padding: 0.3rem 0.4rem; cursor: pointer; font-size: 1rem; box-shadow: ${c === emojiCategory ? '0 1px 2px rgba(0,0,0,0.1)' : 'none'};`, + title: c, + onclick: () => (emojiCategory = c), + }, chatEmoji.EMOJI_ICONS[c]) + ) + ), + m('.emoji-grid-body', { style: 'padding: 0.5rem; display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.25rem; max-height: 230px; overflow-y: auto;' }, + (emojiSearch + ? Object.values(chatEmoji.EMOJI_DATA).flat().filter(e => e.includes(emojiSearch)) + : (chatEmoji.EMOJI_DATA[emojiCategory] || []) + ).map(e => + m('button', { + style: 'border: none; background: transparent; font-size: 1.25rem; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; transition: background 0.15s ease;', + onmouseenter: (ev) => (ev.currentTarget.style.background = '#f1f5f9'), + onmouseleave: (ev) => (ev.currentTarget.style.background = 'transparent'), + onclick: () => { + insertEmoji(e); + showEmojiPicker = false; + }, + }, e) + ) + ), + ]) + ]), ]), ]), ]);