diff --git a/customize.dist/src/less2/include/support.less b/customize.dist/src/less2/include/support.less index 9ad195f1b..bb861285a 100644 --- a/customize.dist/src/less2/include/support.less +++ b/customize.dist/src/less2/include/support.less @@ -59,10 +59,29 @@ display: none; } } + .cp-support-ispremium { + padding: 0 5px; + background-color: @cp_admin-premium-bg; + } .cp-support-list-message { background-color: @msg-bg; padding: 5px 5px; border-radius: @variables_radius; + &.cp-support-fromadmin { + background-color: @cp_admin-isadmin-bg !important; + .cp-support-message-from, .cp-support-showdata { + color: @cryptpad_text_col; + background-color: fade(@cp_admin-isadmin-bg, 10%) !important; + } + } + &:last-child { + &.cp-support-frompremium { + background-color: @cp_admin-premium-bg; + .cp-support-showdata { + background-color: fade(@cp_admin-premium-bg, 10%); + } + } + } .cp-support-fromme { background-color: @fromme-bg; } @@ -86,6 +105,9 @@ margin-bottom: 10px; } } + &:not(:last-child) { + margin-bottom: 3px; + } } .cp-support-list-actions { display: flex; diff --git a/www/common/outer/mailbox-handlers.js b/www/common/outer/mailbox-handlers.js index 2635c5894..60728af77 100644 --- a/www/common/outer/mailbox-handlers.js +++ b/www/common/outer/mailbox-handlers.js @@ -861,17 +861,20 @@ define([ var content = msg.content; content.time = data.time; - if (!content.isAdmin) { // A user replied to an admin - // Update admin chainpad - let i = 0; - let handle = function () { - var support = Util.find(ctx, ['store', 'modules', 'support']); - if (!support && i++ < 100) { setTimeout(handle, 600); } - if (!support) { return; } + let i = 0; + let handle = function () { + var support = Util.find(ctx, ['store', 'modules', 'support']); + if (!support && i++ < 100) { setTimeout(handle, 600); } + if (!support) { return; } + if (!content.isAdmin) { // A user replied to an admin + // Update admin chainpad support.updateAdminTicket(content); - }; - handle(); - } + } else { + // Trigger realtime update of user support + support.updateUserTicket(content); + } + }; + handle(); if (supportNotif) { return void cb(true); } supportNotif = content.channel; diff --git a/www/common/outer/support.js b/www/common/outer/support.js index abefc99a9..2e4eb996f 100644 --- a/www/common/outer/support.js +++ b/www/common/outer/support.js @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later define([ + '/api/config', '/common/common-util.js', '/common/common-hash.js', '/common/common-realtime.js', @@ -11,7 +12,7 @@ define([ 'chainpad-listmap', '/components/chainpad/chainpad.dist.js', 'chainpad-netflux' -], function (Util, Hash, Realtime, nThen, Crypto, Listmap, ChainPad, CpNetflux) { +], function (ApiConfig, Util, Hash, Realtime, nThen, Crypto, Listmap, ChainPad, CpNetflux) { var Support = {}; // UTILS @@ -20,6 +21,8 @@ define([ var cb = Util.mkAsync(_cb); if (isAdmin && !ctx.adminRdyEvt) { return void cb('EFORBIDDEN'); } require(['/api/config?' + (+new Date())], function (NewConfig) { + ctx.adminKeys = NewConfig.adminKeys; // Update admin keys // XXX MODERATOR + var supportKey = NewConfig.newSupportMailbox; if (!supportKey) { return void cb('E_NOT_INIT'); } @@ -222,6 +225,11 @@ define([ var getMyTickets = function (ctx, data, cId, cb) { var all = []; var n = nThen; + if (!ctx.clients[cId]) { + ctx.clients[cId] = { + admin: false + }; + } Object.keys(ctx.supportData).forEach(function (ticket) { n = n((waitFor) => { var t = Util.clone(ctx.supportData[ticket]); @@ -250,6 +258,11 @@ define([ var listTicketsAdmin = function (ctx, data, cId, cb) { if (!ctx.adminRdyEvt) { return void cb({ error: 'EFORBIDDEN' }); } + if (!ctx.clients[cId]) { + ctx.clients[cId] = { + admin: true + }; + } ctx.adminRdyEvt.reg(() => { var doc = ctx.adminDoc.proxy; cb(Util.clone(doc.tickets.active)); @@ -264,8 +277,22 @@ define([ if (Array.isArray(res) && res.length) { res.sort((t1, t2) => { return t1.time - t2.time; }); let last = res[res.length - 1]; + let premium = res.some((msg) => { + let curve = Util.find(msg, ['sender', 'curvePublic']); + if (data.curvePublic !== curve) { return; } + return Util.find(msg, ['sender', 'quota', 'plan']); + }); + var senderKey = last.sender && last.sender.edPublic; + var entry = doc.tickets.active[data.channel]; - if (entry) { entry.time = last.time; } + if (entry) { + entry.time = last.time; + entry.premium = premium; + + if (senderKey) { + entry.lastAdmin = ctx.adminKeys.indexOf(senderKey) !== -1 + } + } } cb(res); }); @@ -288,6 +315,14 @@ define([ // Mailbox events + var notifyClient = function (ctx, admin, type, channel) { + let notifyList = Object.keys(ctx.clients).filter((cId) => { + return Boolean(ctx.clients[cId].admin) === admin; + }); + if (!notifyList.length) { return; } + ctx.emit(type, { channel }, [notifyList]); + }; + var addAdminTicket = function (ctx, data, cb) { // Wait for the chainpad to be ready before adding the data if (!ctx.adminRdyEvt) { return void cb(false); } // XXX not an admin, delete mailbox? @@ -310,6 +345,7 @@ define([ Realtime.whenRealtimeSyncs(ctx.adminDoc.realtime, function () { cb(false); }); + notifyClient(ctx, true, 'NEW_TICKET', data.channel); }); }); }; @@ -325,10 +361,15 @@ define([ if (!doc.tickets.active[data.channel] && !doc.tickets.pending[data.channel]) { return; } let t = doc.tickets.active[data.channel] || doc.tickets.pending[data.channel]; - t.time = data.time; + if (data.time > (t.time + 2000)) { t.time = data.time; } + t.lastAdmin = false; + notifyClient(ctx, true, 'UPDATE_TICKET', data.channel); }); }); }; + var updateUserTicket = function (ctx, data) { + notifyClient(ctx, false, 'UPDATE_TICKET', data.channel); + }; // INITIALIZE ADMIN @@ -354,6 +395,7 @@ define([ doc.tickets = doc.tickets || {}; doc.tickets.active = doc.tickets.active || {}; doc.tickets.closed = doc.tickets.closed || {}; + doc.tickets.pending = doc.tickets.pending || {}; ctx.adminRdyEvt.fire(); cb(); }); @@ -405,11 +447,11 @@ define([ var proxy = store.proxy.support = store.proxy.support || {}; var ctx = { + adminKeys: ApiConfig.adminKeys, supportData: proxy, store: cfg.store, Store: cfg.Store, emit: emit, - channels: {}, clients: {} }; @@ -419,7 +461,7 @@ define([ support.ctx = ctx; support.removeClient = function (clientId) { - // XXX TODO + delete ctx.clients[clientId]; }; support.leavePad = function (padChan) { // XXX TODO @@ -430,6 +472,9 @@ define([ support.updateAdminTicket = function (content) { updateAdminTicket(ctx, content); }; + support.updateUserTicket = function (content) { + updateUserTicket(ctx, content); + }; support.execCommand = function (clientId, obj, cb) { var cmd = obj.cmd; var data = obj.data; diff --git a/www/moderation/inner.js b/www/moderation/inner.js index 4b3c8895f..678cf5661 100644 --- a/www/moderation/inner.js +++ b/www/moderation/inner.js @@ -52,16 +52,27 @@ define([ var Nacl = window.nacl; var common; var sFrameChan; + var events = { + 'NEW_TICKET': Util.mkEvent(), + 'UPDATE_TICKET': Util.mkEvent() + }; - var andThen = function () { + var andThen = function (linkedTicket) { var $body = $('#cp-content-container'); var button = h('button.btn.btn-primary', 'refresh'); // XXX $body.append(h('div', button)); var $container = $(h('div.cp-support-container')).appendTo($body); - var refresh = () => { + let open = []; + let refresh = () => { APP.module.execCommand('LIST_TICKETS_ADMIN', {}, (tickets) => { + let activeForms = {}; + $container.find('.cp-support-form-container').each((i, el) => { + let id = $(el).attr('data-id'); + if (!id) { return; } + activeForms[id] = el; + }); $container.empty(); var col1 = h('div.cp-support-column', h('h1', [ h('span', Messages.admin_support_premium), @@ -80,24 +91,33 @@ define([ return tickets[c2].time - tickets[c1].time; }; - const onLoad = function (ticket, channel, data) { + const onShow = function (ticket, channel, data, done) { APP.module.execCommand('LOAD_TICKET_ADMIN', { channel: channel, curvePublic: data.authorKey }, function (obj) { if (!Array.isArray(obj)) { console.error(obj && obj.error); + done(); return void UI.warn(Messages.error); } obj.forEach(function (msg) { - console.error(msg); if (!data.notifications) { data.notifications = Util.find(msg, ['sender', 'notifications']); } $(ticket).append(APP.support.makeMessage(msg)); }); + if (!open.includes(channel)) { open.push(channel); } + done(); }); }; + const onHide = function (ticket, channel, data, done) { + $(ticket).find('.cp-support-list-message').remove(); + open = open.filter((chan) => { + return chan !== channel; + }); + done(); + }; const onClose = function (ticket, channel, data) { APP.module.execCommand('CLOSE_TICKET_ADMIN', { channel: channel, @@ -120,26 +140,43 @@ define([ return void UI.warn(Messages.error); } $(ticket).find('.cp-support-list-message').remove(); - refresh(); // XXX RE-open this ticket and scroll to? + $(ticket).find('.cp-support-form-container').remove(); + refresh(); }); }; - Object.keys(tickets).sort(sortTicket).forEach(function (channel) { var d = tickets[channel]; - var ticket = APP.support.makeTicket(channel, d, onLoad, onClose, onReply); + var ticket = APP.support.makeTicket({ + id: channel, + content: d, + form: activeForms[channel], + onShow, onHide, onClose, onReply + }); + var container; if (d.lastAdmin) { container = col3; } else if (d.premium) { container = col1; } else { container = col2; } $(container).append(ticket); + + if (open.includes(channel)) { return void ticket.open(); } + if (linkedTicket === channel) { + linkedTicket = undefined; + ticket.open(); + ticket.scrollIntoView(); + } }); + open = []; console.log(tickets); }); }; + let _refresh = Util.throttle(refresh, 500); Util.onClickEnter($(button), function () { refresh(); }); + events.NEW_TICKET.reg(_refresh); + events.UPDATE_TICKET.reg(_refresh); // XXX dont refresh all? refresh(); }; @@ -177,10 +214,24 @@ define([ APP.privateKey = privateData.supportPrivateKey; APP.origin = privateData.origin; APP.readOnly = privateData.readOnly; - APP.module = common.makeUniversal('support'); + APP.module = common.makeUniversal('support', { + onEvent: (obj) => { + let cmd = obj.ev; + let data = obj.data; + if (!events[cmd]) { return; } + events[cmd].fire(data); + } + }); APP.support = Support.create(common, true); - andThen(); + let active = privateData.category || 'active'; + let linkedTicket; + if (active.indexOf('-') !== -1) { + linkedTicket = active.split('-')[1]; + active = active.split('-')[0]; + } + + andThen(linkedTicket); UI.removeLoadingScreen(); }); diff --git a/www/moderation/main.js b/www/moderation/main.js index f6be3331c..0405e0792 100644 --- a/www/moderation/main.js +++ b/www/moderation/main.js @@ -16,20 +16,6 @@ define([ }).nThen(function (waitFor) { SFCommonO.initIframe(waitFor); }).nThen(function (/*waitFor*/) { - var addRpc = function (sframeChan, Cryptpad/*, Utils*/) { - // Adding a new avatar from the profile: pin it and store it in the object - sframeChan.on('Q_ADMIN_MAILBOX', function (data, cb) { - Cryptpad.addAdminMailbox(data, cb); - }); - sframeChan.on('Q_ADMIN_RPC', function (data, cb) { - Cryptpad.adminRpc(data, cb); - }); - sframeChan.on('Q_UPDATE_LIMIT', function (data, cb) { - Cryptpad.updatePinLimit(function (e) { - cb({error: e}); - }); - }); - }; var category; if (window.location.hash) { category = window.location.hash.slice(1); @@ -40,7 +26,6 @@ define([ }; SFCommonO.start({ noRealtime: true, - addRpc: addRpc, addData: addData }); }); diff --git a/www/support/inner.js b/www/support/inner.js index 9fad0b49d..8038a34b9 100644 --- a/www/support/inner.js +++ b/www/support/inner.js @@ -152,6 +152,9 @@ define([ return $div; }; + var events = { + 'UPDATE_TICKET': Util.mkEvent() + }; create['listnew'] = function () { var key = 'listnew'; var $div = makeBlock(key); // Msg.support_listHint, .support_listTitle @@ -159,6 +162,8 @@ define([ var $list = $(list); + let activeForm = {}; // .channel and .form + let refresh = function () { const onClose = function (ticket, channel, data) { APP.supportModule.execCommand('CLOSE_TICKET', { @@ -176,7 +181,8 @@ define([ ticket: formData }, function (obj) { if (obj && obj.error) { return void UI.warn(Messages.error); } - refresh(); // XXX RE-open this ticket and scroll to? + $(ticket).find('.cp-support-form-container').remove(); + refresh(); }); }; @@ -185,6 +191,15 @@ define([ return void UI.warn(Messages.error); } if (!Array.isArray(obj.tickets)) { return void UI.warn(Messages.error); } + + // Recover forms + let activeForms = {}; + $list.find('.cp-support-form-container').each((i, el) => { + let id = $(el).attr('data-id'); + if (!id) { return; } + activeForms[id] = el; + }); + $list.empty(); obj.tickets.forEach((data) => { var title = data.title; @@ -192,7 +207,12 @@ define([ var messages = data.messages; var first = messages[0]; first.id = data.id; - var ticket = APP.support.makeTicket(data.id, data, null, onClose, onReply); + var ticket = APP.support.makeTicket({ + id: data.id, + content: data, + form: activeForms[data.id], + onClose, onReply + }); $list.append(ticket); messages.forEach(msg => { $(ticket).append(APP.support.makeMessage(msg)); @@ -205,6 +225,8 @@ define([ Util.onClickEnter($(button), function () { refresh(); }); + let _refresh = Util.throttle(refresh, 500);; + events.UPDATE_TICKET.reg(_refresh); refresh(); $div.append([ button, @@ -409,7 +431,14 @@ define([ APP.origin = privateData.origin; APP.readOnly = privateData.readOnly; APP.support = Support.create(common, false, APP.pinUsage, APP.teamsUsage); - APP.supportModule = common.makeUniversal('support'); + APP.supportModule = common.makeUniversal('support', { + onEvent: (obj) => { + let cmd = obj.ev; + let data = obj.data; + if (!events[cmd]) { return; } + events[cmd].fire(data); + } + }); // Content var $rightside = APP.$rightside; diff --git a/www/support/ui.js b/www/support/ui.js index c66d570e5..e6a71dac2 100644 --- a/www/support/ui.js +++ b/www/support/ui.js @@ -187,7 +187,7 @@ define([ abuse: Pages.customURLs.terms, }; - var makeForm = function (ctx, cb, title, hideNotice) { + var makeForm = function (ctx, oldData, cb, title, hideNotice) { var button; if (typeof(cb) === "function") { @@ -253,7 +253,7 @@ define([ cb ? undefined : h('br'), h('textarea.cp-support-form-msg', { placeholder: Messages.support_formMessage - }), + }, (oldData && oldData.message) || ''), h('label', Messages.support_attachments), attachments = h('div.cp-support-attachments'), addAttachment = h('button.btn', Messages.support_addAttachment), @@ -262,6 +262,32 @@ define([ cancel ]; + var _addAttachment = (name, href) => { + var x, a; + var span = h('span', { + 'data-name': name, + 'data-href': href + }, [ + x = h('i.fa.fa-times'), + 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', @@ -273,25 +299,7 @@ define([ files.forEach(function (file) { var ev = {}; ev.callback = function (data) { - var x, a; - var span = h('span', { - 'data-name': data.name, - 'data-href': data.url - }, [ - x = h('i.fa.fa-times'), - a = h('a', { - href: '#' - }, data.name) - ]); - $(x).click(function () { - $(span).remove(); - }); - $(a).click(function (e) { - e.preventDefault(); - ctx.common.openURL(data.url); - }); - - $(attachments).append(span); + _addAttachment(data.name, data.url); }; // The empty object allows us to bypass the file upload modal ctx.FM.handleFile(file, ev, {}); @@ -310,7 +318,8 @@ define([ return form; }; - var makeTicket = function (ctx, id, content, onShow, onClose, onReply) { + var makeTicket = function (ctx, opts) { + let { id, content, form, onShow, onHide, onClose, onReply, onForm } = opts; var common = ctx.common; var metadataMgr = common.getMetadataMgr(); var privateData = metadataMgr.getPrivateData(); @@ -321,10 +330,10 @@ define([ var adminActions; var adminClasses = ''; + var adminOpen; if (ctx.isAdmin) { // Admin custom style - let isPremium = content.premium ? '.cp-support-premium' : ''; - adminClasses = `.cp-not-loaded${isPremium}`; + adminClasses = `.cp-not-loaded`; // Admin actions let show = h('button.btn.btn-primary.cp-support-expand', Messages.admin_support_open); let $show = $(show); @@ -335,31 +344,49 @@ define([ ]); $(url).click(function (e) { e.stopPropagation(); - var link = privateData.origin + privateData.pathname + '#' + 'support-' + id; + var link = privateData.origin + privateData.pathname + '#' + 'active-' + id; Clipboard.copy(link, (err) => { if (!err) { UI.log(Messages.shareSuccess); } }); }); - Util.onClickEnter($show, function () { - $ticket.removeClass('cp-not-loaded'); - $show.remove(); - onShow(ticket, id, content); - }); + + + let visible = false; + adminOpen = function (force) { + $show.prop('disabled', 'disabled'); + if (visible && !force) { + $ticket.toggleClass('cp-not-loaded', true); + return onHide(ticket, id, content, function () { + visible = false; + $show.text(Messages.admin_support_open); + $show.prop('disabled', ''); + }); + } + $ticket.toggleClass('cp-not-loaded', false); + onShow(ticket, id, content, function () { + visible = true; + $show.text(Messages.admin_support_collapse); + $show.prop('disabled', ''); + }); + }; + Util.onClickEnter($show, adminOpen); adminActions = h('span.cp-support-title-buttons', [ url, show ]) } + let isPremium = content.premium ? '.cp-support-ispremium' : ''; var name = Util.fixHTML(content.author) || Messages.anonymous; var ticket = h(`div.cp-support-list-ticket${adminClasses}`, { 'data-id': id }, [ h('div.cp-support-ticket-header', [ h('span', content.title), - ctx.isAdmin ? UI.setHTML(h('span'), Messages._getKey('support_from', [name])) : '', + ctx.isAdmin ? UI.setHTML(h(`span${isPremium}`), Messages._getKey('support_from', [name])) : '', h('span', new Date(content.time).toLocaleString()), adminActions, ]), actions ]); + ticket.open = adminOpen; // Add button handlers var $ticket = $(ticket); @@ -369,17 +396,23 @@ define([ $(close).remove(); onClose(ticket, id, content); }); - $(answer).click(function () { + + var addForm = function () { $ticket.find('.cp-support-form-container').remove(); $(actions).hide(); - var form = makeForm(ctx, function () { - onReply(ticket, id, content, form, function () { + + var oldData = form ? getFormData(ctx, form) : {}; + form = undefined; + var newForm = makeForm(ctx, oldData, function () { + onReply(ticket, id, content, newForm, function () { $(actions).css('display', ''); - $(form).remove(); }); }, content.title, true); - $ticket.append(form); - }); + $(newForm).attr('data-id', id); + $ticket.append(newForm); + }; + if (form) { addForm(); } + $(answer).click(addForm); return ticket; }; @@ -446,7 +479,7 @@ define([ $pre.text(displayed); var adminClass = (fromAdmin? '.cp-support-fromadmin': ''); - var premiumClass = (fromPremium && !fromAdmin? '.cp-support-frompremium': ''); + 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, { 'data-hash': hash @@ -507,13 +540,13 @@ define([ return getFormData(ctx, form); }; ui.makeForm = function (cb, title, hideNotice) { - return makeForm(ctx, cb, title, hideNotice); + return makeForm(ctx, {}, cb, title, hideNotice); }; ui.makeCategoryDropdown = function (container, onChange, all) { return makeCategoryDropdown(ctx, container, onChange, all); }; - ui.makeTicket = function (id, content, onShow, onClose, onReply) { - return makeTicket(ctx, id, content, onShow, onClose, onReply); + ui.makeTicket = function (opts) { + return makeTicket(ctx, opts); }; ui.makeMessage = function (content, hash) { return makeMessage(ctx, content, hash);