Added attach file, image & emoji support to mail composer

This commit is contained in:
defnax 2026-07-25 23:40:21 +02:00
parent 97c05df59a
commit df977c089e

View File

@ -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 = `<div>${mailBodyElement.innerHTML}</div>`;
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 = `
<br/><hr style="border:none;border-top:1px solid #e2e8f0;margin:1rem 0;"/><div style="margin-top:10px;font-weight:bold;color:#475569;font-size:0.9rem;">Attachments (${attachments.length}):</div>
<ul style="list-style:none;padding:0;margin:6px 0;">
${attachments.map(att => `<li style="padding:4px 0;color:#1e293b;font-size:0.875rem;">📎 <b>${att.name}</b> <span style="color:#94a3b8;font-size:0.8em;">(${att.size})</span></li>`).join('')}
</ul>
`;
fullMailBody += attHtml;
}
const mailBody = `<div>${fullMailBody}</div>`;
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(`<img src="${src}" style="max-width: 100%; max-height: 400px; border-radius: 0.375rem; margin: 0.5rem 0;" />`);
};
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)
)
),
])
]),
]),
]),
]);