// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors // // SPDX-License-Identifier: AGPL-3.0-or-later define([ 'jquery', '/api/config', '/common/hyperscript.js', '/common/common-interface.js', '/common/common-hash.js', '/common/common-util.js', '/common/clipboard.js', '/common/common-ui-elements.js', '/customize/messages.js', '/customize/pages.js', '/common/common-icons.js', ], function ($, ApiConfig, h, UI, Hash, Util, Clipboard, UIElements, Messages, Pages, Icons) { var getDebuggingData = function (ctx, data) { var common = ctx.common; var metadataMgr = common.getMetadataMgr(); var privateData = metadataMgr.getPrivateData(); var user = metadataMgr.getUserData(); var teams = privateData.teams || {}; data = data || {}; if (data.sender) { return data; } data.sender = { name: user.name, curvePublic: user.curvePublic, edPublic: privateData.edPublic, notifications: user.notifications }; if (ctx.isAdmin && ctx.anonymous) { data.sender = { name: Messages.support_team, accountName: 'support' // XXX send edPublic? or keep it private }; } if (typeof(ctx.pinUsage) === 'object') { // pass pin.usage, pin.limit, and pin.plan if supplied data.sender.quota = ctx.pinUsage; } if (!ctx.isAdmin) { data.sender.drive = privateData.driveChannel; data.sender.accountName = privateData.accountName; data.sender.userAgent = Util.find(window, ['navigator', 'userAgent']); data.sender.vendor = Util.find(window, ['navigator', 'vendor']); data.sender.appVersion = Util.find(window, ['navigator', 'appVersion']); data.sender.screenWidth = Util.find(window, ['screen', 'width']); data.sender.screenHeight = Util.find(window, ['screen', 'height']); data.sender.blockLocation = privateData.blockLocation || ''; data.sender.teams = Object.keys(teams).map(function (key) { var team = teams[key]; if (!team) { return; } var ret = {}; ['channel', 'roster', 'numberPads', 'numberSf', 'edPublic', 'curvePublic', 'owner', 'viewer', 'hasSecondaryKey', 'validKeys'].forEach(function (k) { ret[k] = team[k]; }); if (ctx.teamsUsage && ctx.teamsUsage[key]) { ret.quota = ctx.teamsUsage[key]; } return ret; }).filter(Boolean); } return data; }; var getFormData = function (ctx, form, restore) { var $form = $(form); var $cat = $form.find('.cp-support-form-category'); var $title = $form.find('.cp-support-form-title'); var $content = $form.find('.cp-support-form-msg'); // TODO block submission until pending uploads are complete? var $attachments = $form.find('.cp-support-attachments'); var category = $cat.val().trim(); var title = $title.val().trim(); if (!title && !restore) { return void UI.alert(Messages.support_formTitleError); } var content = $content.val().trim(); if (!content && !restore) { return void UI.alert(Messages.support_formContentError); } $cat.val(''); $content.val(''); $title.val(''); var attachments = []; $attachments.find('> span').each(function (i, el) { var $el = $(el); attachments.push({ href: $el.attr('data-href'), name: $el.attr('data-name') }); }); $attachments.html(''); return getDebuggingData(ctx, { category: category, title: title, attachments: attachments, message: content, }); }; var makeCategoryDropdown = function (ctx, container, onChange, all) { var categories = [ // Msg.support_cat_data is left included because old tickets may still use it 'account', // Msg.support_cat_account 'drives', // Msg.support_cat_drives 'document', // Msg.support_cat_document, Pages.customURLs.terms? 'abuse': undefined, // Msg.support_cat_abuse 'bug', // Msg.support_cat_bug 'other' // Msg.support_cat_other ]; if (all) { categories.push('all'); } // Msg.support_cat_all categories = categories.map(function (key) { if (!key) { return; } return { tag: 'a', content: h('span', Messages['support_cat_'+key]), attributes: { 'data-value': key } }; }).filter(Boolean); var dropdownCfg = { text: Messages.support_category, angleDown: 1, options: categories, container: $(container), isSelect: true }; var $select = UIElements.createDropdown(dropdownCfg); $select.find('button').addClass('btn'); $select.onChange.reg(onChange); return $select; }; var documentIdDocs = Pages.localizeDocsLink('https://docs.cryptpad.org/en/user_guide/apps/general.html#properties'); var warningLinks = { account: documentIdDocs, document: documentIdDocs, drives: documentIdDocs, abuse: Pages.customURLs.terms, }; var getAnswerAs = function (ctx) { var common = ctx.common; var metadataMgr = common.getMetadataMgr(); var user = metadataMgr.getUserData(); var answerName = Util.fixHTML(ctx.anonymous ? Messages.support_team : user.name); return Messages._getKey('support_answerAs', [answerName]); }; var makeForm = function (ctx, opts, cb) { let { oldData, recorded, title, hideNotice } = opts || {}; var button; cb = cb && Util.once(cb); if (typeof(cb) === "function") { button = h('button.btn.btn-primary.cp-support-list-send', [Icons.get('send'), Messages.contacts_send]); $(button).click(cb); } var cancel = title ? h('button.btn.btn-secondary.cp-support-reply-cancel', Messages.cancel) : undefined; var category = h('input.cp-support-form-category', { type: 'hidden', value: '' }); var catContainer = h('div.cp-dropdown-container' + (title ? '.cp-hidden': '')); var notice; if (!(hideNotice || ctx.isAdmin)) { notice = h('div.alert.alert-info', Messages.support_warning_prompt); } var clickHandler = function (ev) { ev.preventDefault(); var $link = $(this); var href = $link.attr('href'); if (!href) { return; } ctx.common.openUnsafeURL(href); }; makeCategoryDropdown(ctx, catContainer, function (text, key) { $(category).val(key); if (!notice) { return; } //console.log(key); // Msg.support_warning_abuse.support_warning_account.support_warning_bug.support_warning_document.support_warning_drives.support_warning_other var warning = Messages['support_warning_' + key] || ''; var warningLink = warningLinks[key]; if (!warningLink) { notice.innerText = warning; return; } notice.innerHTML = ''; var content = UI.setHTML(h('span'), warning); var link = content.querySelector('a'); if (link) { link.href = warningLink; link.onclick = clickHandler; } notice.appendChild(content); }); var attachments, addAttachment; var answerAs = UI.setHTML(h('span.cp-support-answeras'), getAnswerAs(ctx)); let textarea = h('textarea.cp-support-form-msg', { placeholder: Messages.support_formMessage }, (oldData && oldData.message) || ''); let insertText = (text) => { let start = textarea.selectionStart; let end = textarea.selectionEnd; if (start > end) { let _end = end; end = start; start = _end; } let oldVal = $(textarea).val(); let before = oldVal.slice(0, start); let after = oldVal.slice(end); $(textarea).val(`${before}${text}${after}`); setTimeout(() => { $(textarea).focus(); }); }; let recordedContent = h('div.cp-support-recorded'); let $recorded = $(recordedContent); let updateRecorded = (recorded) => { $recorded.empty(); if (!recorded || !Object.keys(recorded.all).length) { return; } $recorded.append(h('span.cp-support-recorded-insert', Messages.support_insertRecorded)); let $span = $(h('span')).appendTo($recorded); let $fakeMore = $(h('button.btn.btn-secondary.cp-fake-dropdown', Icons.get('ellipsis-horizontal'))).appendTo($recorded); let opts = []; let all = recorded.all; let sort = (a, b) => { let diff = all[b].count - all[a].count; if (diff !== 0) { return diff; } return a - b; }; let overflow = false; let $label = $recorded.find('.cp-support-recorded-insert'); let maxWidth = $recorded.width() - $label.outerWidth(true) - $fakeMore.outerWidth(true); Object.keys(all).sort(sort).forEach(id => { let action = () => { insertText(all[id].content); if (typeof(recorded.onClick) === "function") { recorded.onClick(id); } }; // Add one-click button until the list would overflow // On overflow, use a dropdown instead if (!overflow) { let button = h('button.btn.btn-secondary', id); $span.append(button); let current = $span.width(); if (current < maxWidth) { return Util.onClickEnter($(button), action); } $(button).remove(); overflow = true; } opts.push({ tag: 'a', content: h('span', id), action: () => { action(); return true; } }); }); let dropdownConfig = { buttonCls: 'btn btn-secondary', iconCls: 'ellipsis-horizontal', options: opts, // Entries displayed in the menu left: true, // Open to the left of the button common: ctx.common }; let $more = UIElements.createDropdown(dropdownConfig); $fakeMore.remove(); if (opts.length) { $recorded.append($more); } }; setTimeout(() => { updateRecorded(recorded); }); var content = [ h('hr'), category, !ctx.isAdmin ? catContainer : undefined, notice, //h('br'), h('input.cp-support-form-title' + (title ? '.cp-hidden' : ''), { placeholder: Messages.support_formTitle, type: 'text', value: title || '' }), cb ? undefined : h('br'), textarea, recordedContent, h('label', Messages.support_attachments), attachments = h('div.cp-support-attachments'), addAttachment = h('button.btn', [Icons.get('add'),Messages.support_addAttachment]), h('hr'), button, cancel, ctx.isAdmin? answerAs : undefined ]; var _addAttachment = (name, href) => { var x, a; var span = h('span', { 'data-name': name, 'data-href': href }, [ x = Icons.get('close'), a = h('a', { href: '#' }, name) ]); $(x).click(function () { $(span).remove(); }); $(a).click(function (e) { e.preventDefault(); ctx.common.openURL(href); }); $(attachments).append(span); }; if (oldData && Array.isArray(oldData.attachments)) { oldData.attachments.forEach((data) => { _addAttachment(data.name, data.href); }); } $(addAttachment).click(function () { var $input = $('', { 'type': 'file', 'style': 'display: none;', 'multiple': 'multiple', 'accept': 'image/*' }).on('change', function (e) { var files = Util.slice(e.target.files); files.forEach(function (file) { var ev = {}; ev.callback = function (data) { _addAttachment(data.name, data.url); }; // The empty object allows us to bypass the file upload modal ctx.FM.handleFile(file, ev, {}); }); }); $input.click(); }); var form = h('div.cp-support-form-container', content); $(cancel).click(function () { $(form).closest('.cp-support-list-ticket').find('.cp-support-list-actions').css('display', ''); $(form).remove(); }); form.updateRecorded = updateRecorded; return form; }; var makeTicket = function (ctx, opts) { let { id, content, form, recorded, onShow, onHide, onClose, onReply, onMove, onDelete, onTag } = opts; var common = ctx.common; var metadataMgr = common.getMetadataMgr(); var privateData = metadataMgr.getPrivateData(); var answer = h('button.btn.btn-primary.cp-support-answer', [Icons.get('reply'), Messages.support_answer]); var close = h('button.btn.btn-danger.cp-support-close', [Icons.get('close'), Messages.support_close]); var remove = h('button.btn.btn-danger.cp-support-hide', [Icons.get('close'), Messages.support_remove]); var _actions = [answer, close]; if (content.closed && !ctx.isAdmin) { _actions = [remove]; // XXX update key to "Delete permanently" ? } let linkId = Util.hexToBase64(id).slice(0,10); var actions = h('div.cp-support-list-actions', _actions); var adminActions; var adminClasses = ''; var adminOpen; var ticket; let tagsContainer, tagsList; let visible = false; if (ctx.isAdmin) { // Admin custom style adminClasses = `.cp-not-loaded`; // Admin actions // Copy URL let url = h('button.btn', { title: Messages.share_linkCopy, }, Icons.get('link')); $(url).click(function (e) { e.stopPropagation(); let cat = content.category === 'closed' ? 'closed' : 'open'; var link = privateData.origin + privateData.pathname + `#${cat}-${linkId}`; Clipboard.copy(link, (err) => { if (!err) { UI.log(Messages.shareSuccess); } }); }); if (content.category === 'legacy') { url = undefined; } // Load & open ticket let show = h('button.btn.btn-primary.cp-support-expand', Messages.admin_support_open); let $show = $(show); adminOpen = function (force, cb) { var $ticket = $(ticket); $show.prop('disabled', 'disabled'); if (visible && !force) { return onHide(ticket, id, content, function () { $(tagsContainer).hide(); $(tagsList).show(); $ticket.toggleClass('cp-not-loaded', true); visible = false; $(ticket).find('.cp-support-reply-cancel').click(); $show.text(Messages.admin_support_open); $show.prop('disabled', ''); }); } onShow(ticket, id, content, function () { $ticket.toggleClass('cp-not-loaded', false); visible = true; $show.text(Messages.admin_support_collapse); $show.prop('disabled', ''); if (typeof(cb) === "function") { cb(); } }); }; Util.onClickEnter($show, () => { adminOpen();}); if (!onShow) { show = undefined; } // Move active/pending let move; if (onMove && !onMove.disableMove) { let text = onMove.isTicketActive ? Messages.support_movePending : Messages.support_moveActive; move = h('button.btn.btn-secondary', { title: text }, Icons.get('history')); Util.onClickEnter($(move), function () { onMove(ticket, id, content); }); } let tag; if (onTag && onTag.getAllTags) { tag = h('button.btn.btn-secondary', { title: Messages.fm_tagsName }, Icons.get('tag')); if (onTag.readOnly) { tag = undefined; } tagsContainer = h('div.cp-support-ticket-tags'); tagsList = h('div.cp-tags-list'); let $list = $(tagsList); let redrawTags = (tags) => { $list.empty(); (tags || []).forEach(tag => { $list.append(h('span', tag)); }); }; redrawTags(content.tags); let $tags = $(tagsContainer).hide(); let input = UI.dialog.textInput({id: `cp-${Util.uid()}`}); $tags.append(input); let existing = onTag.getAllTags(); let _field = UI.tokenField(input, existing).preventDuplicates(function (val) { UI.warn(Messages._getKey('tags_duplicate', [val])); }); _field.setTokens(content.tags || []); $tags.find('.token').off('click'); let commitTags = function () { setTimeout(() => { let newTags = Util.deduplicateString(_field.getTokens().map(function (t) { return t.toLowerCase(); })); $tags.find('.token').off('click'); redrawTags(newTags); onTag(id, newTags); }); }; _field.tokenfield.on('tokenfield:createdtoken', commitTags); _field.tokenfield.on('tokenfield:editedoken', commitTags); _field.tokenfield.on('tokenfield:removedtoken', commitTags); $(tagsContainer).click(e => { e.stopPropagation(); }); Util.onClickEnter($(tag), () => { $list.toggle(); $tags.toggle(); }); let close = h('button.btn.btn-secondary.cp-token-close', [ Icons.get('close'), h('span', Messages.filePicker_close) ]); Util.onClickEnter($(close), () => { $list.toggle(true); $tags.toggle(false); }); setTimeout(() => { $tags.find('.cp-tokenfield-form').append(close); }); } adminActions = h('span.cp-support-title-buttons', [ url, move, tag, show ]); } let isPremium = content.premium ? '.cp-support-ispremium' : ''; let title = content.title + ` (#${linkId})`; var name = Util.fixHTML(content.author) || Messages.anonymous; ticket = h(`div.cp-support-list-ticket${adminClasses}`, { 'data-link-id': linkId, 'data-id': id }, [ h('div.cp-support-ticket-header', [ h('div.cp-support-header-data', [ h('span.cp-support-ticket-title', title), ctx.isAdmin ? UI.setHTML(h(`span${isPremium}`), Messages._getKey('support_from', [name])) : '', h('span', new Date(content.time).toLocaleString()), tagsList ]), adminActions, ]), tagsContainer, actions ]); ticket.open = adminOpen; // Add button handlers var $ticket = $(ticket); if (adminOpen) { $ticket.click(function (e) { if ($(e.target).is('button')) { return; } e.preventDefault(); e.stopPropagation(); if (!visible) { adminOpen(true); } }); } UI.confirmButton(close, { classes: 'btn-danger' }, function() { $(close).remove(); onClose(ticket, id, content); }); if (!onClose) { $(close).remove(); } UI.confirmButton(remove, { classes: 'btn-danger' }, function() { $(remove).remove(); onDelete(ticket, id, content); }); var addForm = function () { $ticket.find('.cp-support-form-container').remove(); $(actions).hide(); var oldData = form ? getFormData(ctx, form, true) : {}; form = undefined; var newForm = makeForm(ctx, { oldData, recorded, title: content.title, hideNotice: true }, function () { onReply(ticket, id, content, newForm); }); $(newForm).attr('data-id', id); $ticket.append(newForm); }; if (form) { addForm(); } Util.onClickEnter($(answer), addForm); if (!onReply) { $(answer).remove(); } return ticket; }; var makeMessage = function (ctx, content) { var common = ctx.common; var isAdmin = ctx.isAdmin; var metadataMgr = common.getMetadataMgr(); var privateData = metadataMgr.getPrivateData(); // Check content.sender to see if it comes from us or from an admin var senderKey = content.sender && content.sender.edPublic; var fromMe = senderKey === privateData.edPublic; var fromAdmin = ctx.moderatorKeys.indexOf(senderKey) !== -1 || (!senderKey && content.sender.accountName === 'support'); // XXX anon key? var fromPremium = Boolean(content.sender.plan || Util.find(content, ['sender', 'quota', 'plan'])); var copyUser = h('button.btn.btn-secondary.cp-support-copydata', { title: Messages.support_copyUserData }, Icons.get('copy')); Util.onClickEnter($(copyUser), () => { let data = JSON.stringify({ name: content.sender.name, curvePublic: content.sender.curvePublic, notifications: content.sender.notifications }); Clipboard.copy(data, (err) => { if (!err) { UI.log(Messages.genericCopySuccess); } }); }); var userData = h('div.cp-support-showdata', [ Messages.support_showData, h('pre.cp-support-message-data', [copyUser, JSON.stringify(content.sender, 0, 2)]) ]); $(userData).click(function () { $(userData).find('.cp-support-message-data').toggle(); }).find('*').click(function (ev) { ev.stopPropagation(); }); var attachments = (content.attachments || []).map(function (obj) { if (!obj || !obj.name || !obj.href) { return; } // only support files explicitly beginning with /file/ so that users can't link outside of the instance if (!/^\/file\//.test(obj.href)) { return; } var a = h('a', { href: '#' }, obj.name); $(a).click(function (e) { e.preventDefault(); ctx.common.openURL(obj.href); }); return h('span', [ a ]); }); var displayed = content.message; var pre = h('pre.cp-support-message-content'); var $pre = $(pre); var more; if (content.message.length >= 2000) { displayed = content.message.slice(0, 2000) + '...'; var expand = h('button.btn.btn-secondary', Messages.admin_support_open); var collapse = h('button.btn.btn-secondary', Messages.admin_support_collapse); var $collapse = $(collapse).hide(); var $expand = $(expand).click(function () { $pre.text(content.message); $expand.hide(); $collapse.show(); }); $collapse.click(function () { $pre.text(displayed); $collapse.hide(); $expand.show(); }); more = h('div', [expand, collapse]); } $pre.text(displayed); var adminClass = (fromAdmin? '.cp-support-fromadmin': ''); var premiumClass = (ctx.isAdmin && fromPremium && !fromAdmin? '.cp-support-frompremium': ''); var name = Util.fixHTML(content.sender.name) || Messages.anonymous; return h('div.cp-support-list-message' + adminClass + premiumClass, {}, [ h('div.cp-support-message-from' + (fromMe ? '.cp-support-fromme' : ''), [ UI.setHTML(h('span'), Messages._getKey('support_from', [name])), h('span.cp-support-message-time', content.time ? new Date(content.time).toLocaleString() : '') ]), pre, more, h('div.cp-support-attachments', attachments), isAdmin ? userData : undefined, ]); }; var makeCloseMessage = function (ctx, content) { var common = ctx.common; var metadataMgr = common.getMetadataMgr(); var privateData = metadataMgr.getPrivateData(); var senderKey = content.sender && content.sender.edPublic; var fromMe = senderKey === privateData.edPublic; var fromAdmin = ctx.moderatorKeys.indexOf(senderKey) !== -1; var adminClass = (fromAdmin? '.cp-support-fromadmin': ''); var name = Util.fixHTML(content.sender.name) || Messages.anonymous; return h('div.cp-support-list-message' + adminClass, {}, [ h('div.cp-support-message-from' + (fromMe ? '.cp-support-fromme' : ''), [ UI.setHTML(h('span'), Messages._getKey('support_from', [name])), h('span.cp-support-message-time', content.time ? new Date(content.time).toLocaleString() : '') ]), h('pre.cp-support-message-content', Messages.support_closed) ]); }; var create = function (common, isAdmin, pinUsage, teamsUsage) { var ui = {}; var ctx = { common: common, isAdmin: isAdmin, pinUsage: pinUsage || false, teamsUsage: teamsUsage || false, moderatorKeys: Array.isArray(ApiConfig.moderatorKeys)? ApiConfig.moderatorKeys.slice(): [], }; ctx.supportModule = common.makeUniversal('support'); var fmConfig = { body: $('body'), noStore: true, // Don't store attachments into our drive onUploaded: function (ev, data) { if (ev.callback) { ev.callback(data); } } }; ctx.FM = common.createFileManager(fmConfig); ui.setAnonymous = function (val) { ctx.anonymous = ctx.isAdmin && val; $('.cp-support-answeras').html(getAnswerAs(ctx)); }; ui.getFormData = function (form) { return getFormData(ctx, form); }; ui.makeForm = function (opts, cb) { return makeForm(ctx, opts, cb); }; ui.makeCategoryDropdown = function (container, onChange, all) { return makeCategoryDropdown(ctx, container, onChange, all); }; ui.makeTicket = function (opts) { return makeTicket(ctx, opts); }; ui.makeMessage = function (content) { return makeMessage(ctx, content); }; ui.makeCloseMessage = function (content) { return makeCloseMessage(ctx, content); }; ui.getDebuggingData = function (data) { return getDebuggingData(ctx, data); }; return ui; }; return { create: create }; });