diff --git a/CHANGELOG.md b/CHANGELOG.md index 324c03785..866196d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,54 @@ +# 4.4.0 + +## Goals + +## Update notes + +* no default privacy policy +* nginx update + * calendar + * /api/broadcast +* clarified TELEMETRY in the 4.3.1 release notes + +## Features + +* prompt premium users to cancel their subscriptions before deleting their accounts +* check that headers for XLSX export are correctly set via the checkup app +* remove HTML from most translations +* localize links to the docs where a translation exists +* implement admin-broadcast features +* add "getting started" banner in the drive +* calendars: BETA +* clear document cache when visiting /logout/ + +## Bug fixes + +* bad channel IDs stored in your drive or accessed via bad links (corrupted somehow) + * don't try to join invalid channels + * don't try to get their metadata +* guard against some type errors in the support page +* remove redundant link from OpenCollective popup +* guard against a type error when copying a pad in nodrive mode +* correctly navigate to anchors when clicking links to anchors in read-only rich-text pads + + +* OnlyOffice + * inform OnlyOffice of userlist changes + * rename doc and slide editors + * handle different lock formats for docs and slides + * relative to sheets + * handle some cursor logic outside of sheets + * handle locks when integrating remote checkpoints in strict mode + * OnlyOffice renamed buttons in slides and docs and we need to hardcode CSS that hides them by their randomly generated IDs + * support CryptPad cursor colors in OnlyOffice by adding opacity value + * use the appropriate APIs to detect if the document is modified + * display users cursor colors in the toolbar next to their name + * handle errors when migrating in embed mode + * change the method we use to lock the whole sheet since OnlyOffice changed their internal API's behaviour + * **soft release of OnlyOffice presentations and docs** + * if you've been using them, tell your users to export them before they break + * we still don't recommend that you use either editor! + # 4.3.1 This minor release addresses some bugs discovered after deploying and tagging 4.3.0 @@ -6,7 +57,7 @@ This minor release addresses some bugs discovered after deploying and tagging 4. * Our 4.2.0 update introduced a new internal format for spreadsheets which broke support for spreadsheet templates using the older format. This release implements a compatibility layer. * We fixed some minor bugs in our rich text editor. Section links in the table of contents now navigate correctly. Adding a comment to a link no longer prevents clicking on that link. * A race condition that caused poll titles to reset occasionally has been fixed. -* We've added a little bit of telemetry to tell our server when a newly registered user opens the new user guide which is automatically added to their drive. We're considering either rewriting or removing this guide, so it's helpful to be able to determine how often people actually read it. +* We've added a little bit of telemetry to tell the application server when a newly registered user opens the new user guide which is automatically added to their drive. We're considering either rewriting or removing this guide, so it's helpful to be able to determine how often people actually read it. * An error introduced in 4.3.0 was preventing the creation of new teams. It's been fixed. * 4.3.0 temporarily broke the sheet editor for iPad users. Migrations to a new internal format that were run while the editor was in a bad state produced some invalid data that prevented sheets from loading correctly. This release improves the platforms ability to recover from bad states like this and improves its ability to detect the kind of errors we observed. diff --git a/customize.dist/login.js b/customize.dist/login.js index 035a11d72..8b15cdcd3 100644 --- a/customize.dist/login.js +++ b/customize.dist/login.js @@ -135,10 +135,6 @@ define([ Exports.mergeAnonDrive = 1; }; - var setCreateReadme = function () { - Exports.createReadme = 1; - }; - Exports.loginOrRegister = function (uname, passwd, isRegister, shouldImport, cb) { if (typeof(cb) !== 'function') { return; } @@ -372,7 +368,6 @@ define([ proxy.curvePrivate = opt.curvePrivate; proxy.login_name = uname; proxy[Constants.displayNameKey] = uname; - setCreateReadme(); if (shouldImport) { setMergeAnonDrive(); } else { @@ -436,9 +431,6 @@ define([ if (Exports.mergeAnonDrive) { loginOpts.mergeAnonDrive = 1; } - if (Exports.createReadme) { - loginOpts.createReadme = 1; - } h = Hash.getLoginURL(h, loginOpts); var parser = document.createElement('a'); diff --git a/customize.dist/messages.js b/customize.dist/messages.js index b2a264b70..977405db1 100755 --- a/customize.dist/messages.js +++ b/customize.dist/messages.js @@ -120,10 +120,6 @@ define(req, function(AppConfig, Default, Language) { } }; - messages.driveReadme = '["BODY",{"class":"cke_editable cke_editable_themed cke_contents_ltr cke_show_borders","contenteditable":"true","spellcheck":"false","style":"color: rgb(51, 51, 51);"},' + - '[["H1",{},["'+messages.readme_welcome+'"]],["P",{},["'+messages.readme_p1+'"]],["P",{},["'+messages.readme_p2+'"]],["HR",{},[]],["H2",{},["'+messages.readme_cat1+'",["BR",{},[]]]],["UL",{},[["LI",{},["'+messages._getKey("readme_cat1_l1", ['",["STRONG",{},["'+messages.newButton+'"]],"', '",["STRONG",{},["'+messages.type.pad+'"]],"'])+'"]],["LI",{},["'+messages.readme_cat1_l2+'"]],["LI",{},["'+messages._getKey("readme_cat1_l3", ['",["STRONG",{},["'+messages.fm_unsortedName+'"]],"'])+'",["UL",{},[["LI",{},["'+messages._getKey("readme_cat1_l3_l1", ['",["STRONG",{},["'+messages.fm_rootName+'"]],"'])+'"]],["LI",{},["'+messages.readme_cat1_l3_l2+'"]]]]]],["LI",{},["'+messages._getKey("readme_cat1_l4", ['",["STRONG",{},["'+messages.fm_trashName+'"]],"'])+'",["BR",{},[]]]]]],["P",{},[["BR",{},[]]]],["H2",{},["'+messages.readme_cat2+'",["BR",{},[]]]],["UL",{},[["LI",{},["'+messages._getKey("readme_cat2_l1", ['",["STRONG",{},["'+messages.shareButton+'"]],"', '",["STRONG",{},["'+messages.edit+'"]],"', '",["STRONG",{},["'+messages.view+'"]],"'])+'"]],["LI",{},["'+messages.readme_cat2_l2+'"]]]],["P",{},[["BR",{},[]]]],["H2",{},["'+messages.readme_cat3+'"]],["UL",{},[["LI",{},["'+messages.readme_cat3_l1+'"]],["LI",{},["'+messages.readme_cat3_l2+'"]],["LI",{},["'+messages.readme_cat3_l3+'",["BR",{},[]]]]]]],' + - '{"metadata":{"defaultTitle":"' + messages.driveReadmeTitle + '","title":"' + messages.driveReadmeTitle + '"}}]'; - return messages; }); diff --git a/customize.dist/pages.js b/customize.dist/pages.js index a966da524..251d4b597 100644 --- a/customize.dist/pages.js +++ b/customize.dist/pages.js @@ -12,6 +12,36 @@ define([ return e; }; + Pages.externalLink = function (el, href) { + if (!el) { return el; } + el.setAttribute("rel", "noopener noreferrer"); + el.setAttribute("target", "_blank"); + if (typeof(href) === 'string') { + el.setAttribute("href", href); + } + return el; + }; + + // this rewrites URLS to point to the appropriate translation: + // French, German, or English as a default + var documentedLanguages = ['en', 'fr', 'de']; + Pages.localizeDocsLink = function (href) { + try { + var lang = Msg._getLanguage(); + if (documentedLanguages.indexOf(lang) > 0) { + return href.replace('/en/', '/' + lang + '/'); + } + } catch (err) { + console.error(err); + // if it fails just use the default href (English) + } + return href; + }; + + Pages.documentationLink = function (el, href) { + return Pages.externalLink(el, Pages.localizeDocsLink(href)); + }; + var languageSelector = function () { var options = []; var languages = Msg._languages; @@ -45,6 +75,7 @@ define([ }; var footLink = function (ref, loc, text) { + if (!ref) { return; } var attrs = { href: ref, }; @@ -62,7 +93,7 @@ define([ var imprintUrl = AppConfig.imprint && (typeof(AppConfig.imprint) === "boolean" ? '/imprint.html' : AppConfig.imprint); - Pages.versionString = "v4.3.1"; + Pages.versionString = "v4.4.0"; // used for the about menu Pages.imprintLink = AppConfig.imprint ? footLink(imprintUrl, 'imprint') : undefined; @@ -71,6 +102,18 @@ define([ Pages.docsLink = footLink('https://docs.cryptpad.fr', 'docs_link'); Pages.infopageFooter = function () { + var terms = footLink('/terms.html', 'footer_tos'); // FIXME this should be configurable like the other legal pages + var legalFooter; + + // only display the legal part of the footer if it has content + if (terms || Pages.privacyLink || Pages.imprintLink) { + legalFooter = footerCol('footer_legal', [ + terms, + Pages.privacyLink, + Pages.imprintLink, + ]); + } + return h('footer', [ h('div.container', [ h('div.row', [ @@ -97,11 +140,7 @@ define([ footLink('https://github.com/xwiki-labs/cryptpad/wiki/Contributors', 'footer_team'), footLink('http://www.xwiki.com', null, 'XWiki SAS'), ]), - footerCol('footer_legal', [ - footLink('/terms.html', 'footer_tos'), - Pages.privacyLink, - Pages.imprintLink, - ]), + legalFooter, ]) ]), h('div.cp-version-footer', [ diff --git a/customize.dist/pages/index.js b/customize.dist/pages/index.js index f15d1819b..3257126e9 100644 --- a/customize.dist/pages/index.js +++ b/customize.dist/pages/index.js @@ -80,6 +80,12 @@ define([ }); } + var supportText = Pages.setHTML(h('span'), Msg.home_support); + Pages.documentationLink(supportText.querySelector('a'), "https://docs.cryptpad.fr/en/how_to_contribute.html"); + + var opensource = Pages.setHTML(h('p'), Msg.home_opensource); + Pages.externalLink(opensource.querySelector('a'), "https://github.com/xwiki-labs/cryptpad"); + var blocks = [ h('div.row.cp-page-section', [ h('div.col-sm-6', @@ -103,7 +109,7 @@ define([ h('div.row.cp-page-section', [ h('div.col-sm-6', [ h('h2', Msg.home_opensource_title), - Pages.setHTML(h('p'), Msg.home_opensource), + opensource, h('img.small-logo.cp-img-invert', { src: '/customize/images/logo_AGPLv3.svg', alt: 'APGL3 License Logo' @@ -111,7 +117,7 @@ define([ ]), h('div.col-sm-6', [ h('h2', Msg.home_support_title), - Pages.setHTML(h('span'), Msg.home_support), + supportText, subscribeButton, Pages.crowdfundingButton(function () { Feedback.send('HOME_SUPPORT_CRYPTPAD'); diff --git a/customize.dist/pages/privacy.js b/customize.dist/pages/privacy.js deleted file mode 100644 index 84a43ad3f..000000000 --- a/customize.dist/pages/privacy.js +++ /dev/null @@ -1,36 +0,0 @@ -define([ - '/common/hyperscript.js', - '/customize/messages.js', - '/customize/pages.js' -], function (h, Msg, Pages) { - return function () { - return h('div#cp-main', [ - Pages.infopageTopbar(), - h('div.container.cp-container.cp-privacy',[ - h('div.row.cp-page-title', h('h1', Msg.policy_title)), - h('h2', Msg.policy_whatweknow), - Pages.setHTML(h('p'), Msg.policy_whatweknow_p1), - - h('h2', Msg.policy_howweuse), - h('p', Msg.policy_howweuse_p1), - h('p', Msg.policy_howweuse_p2), - - h('h2', Msg.policy_whatwetell), - h('p', Msg.policy_whatwetell_p1), - - h('h2', Msg.policy_links), - h('p', Msg.policy_links_p1), - - h('h2', Msg.policy_ads), - h('p', Msg.policy_ads_p1), - - h('h2', Msg.policy_choices), - h('p', Msg.policy_choices_open), - Pages.setHTML(h('p'), Msg.policy_choices_vpn), - ]), - Pages.infopageFooter() - ]); - }; - -}); - diff --git a/customize.dist/pages/register.js b/customize.dist/pages/register.js index c9e28bed4..1cdc6e356 100644 --- a/customize.dist/pages/register.js +++ b/customize.dist/pages/register.js @@ -9,6 +9,13 @@ define([ return function () { var urlArgs = Config.requireConf.urlArgs; + var tos = $(UI.createCheckbox('accept-terms')).find('.cp-checkmark-label').append(Msg.register_acceptTerms).parent()[0]; + $(tos).find('a').attr({ + href: '/terms.html', + target: '_blank', + tabindex: '-1', + }); + return [h('div#cp-main', [ Pages.infopageTopbar(), h('div.container.cp-container', [ @@ -47,7 +54,7 @@ define([ UI.createCheckbox('import-recent', Msg.register_importRecent, true) ]), h('div.checkbox-container', [ - $(UI.createCheckbox('accept-terms')).find('.cp-checkmark-label').append(Msg.register_acceptTerms).parent()[0] + tos, ]), h('button#register', Msg.login_register) ]) diff --git a/customize.dist/pages/what-is-cryptpad.js b/customize.dist/pages/what-is-cryptpad.js index b5d2f8ac4..99e675c0f 100644 --- a/customize.dist/pages/what-is-cryptpad.js +++ b/customize.dist/pages/what-is-cryptpad.js @@ -17,6 +17,9 @@ define([ }; return function () { + var xwiki_info = Pages.setHTML(h('span'), Msg.whatis_xwiki_info); + Pages.externalLink(xwiki_info.querySelector('a'), "https://xwiki.com"); + return h('div#cp-main', [ Pages.infopageTopbar(), h('div.container.cp-container', [ @@ -52,7 +55,7 @@ define([ h('div.row.cp-page-section', [ h('div.col-md-6', [ Pages.setHTML(h('h2'), Msg.whatis_drive), - Pages.setHTML(h('spam'), Msg.whatis_drive_info), + Pages.setHTML(h('span'), Msg.whatis_drive_info), ]), h('div.col-md-6', [ h('img.cp-shadow', { @@ -91,7 +94,7 @@ define([ h('div.row.cp-page-section', [ h('div.col-md-6', [ Pages.setHTML(h('h2'), Msg.whatis_xwiki), - Pages.setHTML(h('spam'), Msg.whatis_xwiki_info), + xwiki_info, ]), h('div.col-md-6.small-logos', [ h('img', { diff --git a/customize.dist/privacy.html b/customize.dist/privacy.html deleted file mode 100644 index f1cf1b429..000000000 --- a/customize.dist/privacy.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - CryptPad: Collaboration suite, encrypted and open-source - - - - - - - - diff --git a/customize.dist/src/less2/include/alertify.less b/customize.dist/src/less2/include/alertify.less index 2f95a637e..96b8e7cde 100644 --- a/customize.dist/src/less2/include/alertify.less +++ b/customize.dist/src/less2/include/alertify.less @@ -58,12 +58,17 @@ width: 100%; height: 100%; z-index: 100000; // alertify container + outline: none; font: @colortheme_app-font; .cp-checkmark { color: @cryptpad_text_col; } + .cp-admin-message { + color: @cryptpad_text_col; + } + .cp-inline-alert-text { flex: 1; } diff --git a/customize.dist/src/less2/include/colortheme-dark.less b/customize.dist/src/less2/include/colortheme-dark.less index fc17d7d24..869ff862b 100644 --- a/customize.dist/src/less2/include/colortheme-dark.less +++ b/customize.dist/src/less2/include/colortheme-dark.less @@ -13,8 +13,8 @@ whiteboard: #a72ba7; kanban: #8C4; sheet: #40865c; - oodoc: #5170B5; - ooslide: #C65D27; + doc: #5170B5; + presentation: #C65D27; file: #CD2532; } @@ -414,3 +414,12 @@ @cp_whiteboard-board-border: @cryptpad_color_grey_800; @cp_whiteboard-bg: @cp_app-bg; @cp_whiteboard-fg: @cryptpad_text_col; + +// Flatpickr +@cp_flatpickr-bg: @cryptpad_color_grey_800; +@cp_flatpickr-highlight: @cryptpad_color_brand_300; +@cp_flatpickr-highlight-text: @cryptpad_color_grey_800; + +// Calendar + +@cp_calendar-border: @cryptpad_color_grey_600; diff --git a/customize.dist/src/less2/include/colortheme.less b/customize.dist/src/less2/include/colortheme.less index f353758c8..6af7fe46a 100644 --- a/customize.dist/src/less2/include/colortheme.less +++ b/customize.dist/src/less2/include/colortheme.less @@ -13,8 +13,8 @@ whiteboard: #a72ba7; kanban: #8C4; sheet: #40865c; - oodoc: #5170B5; - ooslide: #C65D27; + doc: #5170B5; + presentation: #C65D27; file: #CD2532; } @@ -190,7 +190,7 @@ // Dropdown @cp_dropdown-fg: @cryptpad_text_col; @cp_dropdown-bg: @cryptpad_color_grey_100; -@cp_dropdown-bg-hover: @cryptpad_color_grey_100; +@cp_dropdown-bg-hover: @cryptpad_color_grey_200; @cp_dropdown-bg-active: @cryptpad_color_grey_300; // Rendered Markdown @@ -414,3 +414,12 @@ @cp_whiteboard-board-border: @cryptpad_color_grey_600; @cp_whiteboard-bg: @cp_app-bg; @cp_whiteboard-fg: @cryptpad_text_col; + +// Flatpickr +@cp_flatpickr-bg: @cryptpad_color_grey_50; +@cp_flatpickr-highlight: @cryptpad_color_brand_fadest; +@cp_flatpickr-highlight-text: @cryptpad_text_col; + +// Calendar + +@cp_calendar-border: @cryptpad_color_grey_300; diff --git a/customize.dist/src/less2/include/forms.less b/customize.dist/src/less2/include/forms.less index e74b67cb0..969c873c7 100644 --- a/customize.dist/src/less2/include/forms.less +++ b/customize.dist/src/less2/include/forms.less @@ -1,5 +1,6 @@ @import (reference) "./colortheme-all.less"; @import (reference) "./variables.less"; +@import (reference) "./tools.less"; .forms_main() { --LessLoader_require: LessLoader_currentFile(); @@ -8,19 +9,25 @@ & { @alertify_padding-base: @variables_padding; - input:not(.form-control):not([type="checkbox"]), textarea, div.cp-textarea { + input:not(.numInput):not(.form-control):not([type="checkbox"]), textarea, div.cp-textarea { // background-color: @alertify-input-fg; color: @cp_forms-fg; background-color: @cp_forms-bg; border: 1px solid @cp_forms-border; - width: 100%; font-size: 100%; padding: @alertify_padding-base; + &:not(.tui-full-calendar-content) { + width: 100%; + } + &.tui-full-calendar-content { + font-size: @colortheme_app-font-size; + } &[readonly] { background-color: @cp_forms-readonly; border-color: @cp_forms-readonly-border; color: @cp_forms-fg; } + .tools_placeholder-color(); } input:not(.form-control) { @@ -112,6 +119,11 @@ &.no-margin { margin: 0; } + &.small { + line-height: initial; + padding: 5px; + height: auto; + } &:hover, &:not(:disabled):not(.disabled):active, &:focus { color: @cp_buttons-fg; @@ -169,7 +181,7 @@ &:hover, &:not(:disabled):active, &:focus { border-color: @cryptpad_text_col; color: @cryptpad_text_col; - background-color: fade(@cryptpad_text_col, 25%); + background-color: fade(@cryptpad_text_col, 10%); } } @@ -280,4 +292,90 @@ color: @cp_drive-infobox-fg; } } + + // Flatpickr + body { + .flatpickr-calendar { + background: @cp_flatpickr-bg; + color: @cryptpad_text_col; + border-radius: 0; + box-shadow: @variables_shadow; + -webkit-box-shadow: @variables_shadow; + &.arrowTop::before, &.arrowTop::after { + border-bottom: 0; + } + .flatpickr-months { + .flatpickr-month, .flatpickr-months, .flatpickr-next-month, .flatpickr-prev-month { + color: @cryptpad_text_col; + fill: @cryptpad_text_col; + &:hover { + svg { + fill: @cryptpad_text_col; + } + } + } + .flatpickr-current-month { + span.cur-month:hover { + background: fade(@cryptpad_text_col, 10%); + } + .numInputWrapper span.arrowUp:after { + border-bottom-color: @cryptpad_text_col; + } + .numInputWrapper span.arrowDown:after { + border-top-color: @cryptpad_text_col; + } + } + } + .flatpickr-innerContainer { + border-bottom: 0; + .flatpickr-weekdays { + span.flatpickr-weekday { + color: @cryptpad_text_col; + } + } + .flatpickr-days { + border-left: 0; + border-right: 0; + .flatpickr-day { + color: @cryptpad_text_col; + &:hover { + background-color: fade(@cryptpad_text_col, 10%); + border: 0; + } + &.selected { + background: @cp_flatpickr-highlight; + color: @cp_flatpickr-highlight-text; + border: 0; + } + } + .flatpickr-disabled { + color: fade(@cryptpad_text_col, 20%); + } + } + } + .flatpickr-time { + border-top: none; + .flatpickr-time-separator, .flatpickr-am-pm { + color: @cryptpad_text_col; + } + .flatpickr-am-pm { + &:hover { + background-color: fade(@cryptpad_text_col, 10%); + } + } + .numInputWrapper { + .numInput, .arrowUp, .arrowDown { + color: @cryptpad_text_col; + &:hover, &:focus { + background-color: fade(@cryptpad_text_col, 10%); + } + } + span.arrowDown::after, span.arrowUp::after { + border-top-color: @cryptpad_text_col; + border-bottom-color: @cryptpad_text_col; + } + } + } + } + } } diff --git a/customize.dist/src/less2/include/notifications.less b/customize.dist/src/less2/include/notifications.less index 46209eb6a..892779a37 100644 --- a/customize.dist/src/less2/include/notifications.less +++ b/customize.dist/src/less2/include/notifications.less @@ -17,6 +17,16 @@ .cp-notification { min-height: @notif-height; display: flex; + .cp-broadcast { + display: flex; + font-size: 30px; + align-items: center; + padding: 0 5px; + color: @cp_dropdown-fg; + &.preview { + color: @cryptpad_color_red; + } + } .cp-avatar { .avatar_main(30px); padding: 0 5px; diff --git a/customize.dist/src/less2/include/toolbar.less b/customize.dist/src/less2/include/toolbar.less index 24bb09c11..31846b391 100644 --- a/customize.dist/src/less2/include/toolbar.less +++ b/customize.dist/src/less2/include/toolbar.less @@ -400,7 +400,7 @@ button { .toolbar_button; - &.cp-notifications-bell { + &.cp-notifications-bell, &.cp-maintenance-wrench { color: @cryptpad_text_col; } } @@ -506,7 +506,7 @@ } .cp-toolbar-user { height: @toolbar_line-height; - .cp-toolbar-notifications { + .cp-toolbar-notifications, .cp-toolbar-maintenance { height: @toolbar_line-height; width: @toolbar_line-height; margin-left: 0; @@ -709,7 +709,7 @@ height: 43px; } } - .cp-toolbar-link, .cp-toolbar-notifications { + .cp-toolbar-link, .cp-toolbar-notifications, .cp-toolbar-maintenance { line-height: @toolbar_top-height; width: @toolbar_top-height; height: @toolbar_top-height; @@ -717,7 +717,7 @@ box-sizing: border-box; display: inline-block; } - .cp-toolbar-notifications { + .cp-toolbar-notifications, .cp-toolbar-maintenance { text-align: center; font-size: 32px; margin-left: 10px; @@ -996,6 +996,9 @@ display: flex; #cp-toolbar-chat-drawer-open { order: 0; } #cp-toolbar-userlist-drawer-open { order: 1; } + & > .cp-dropdown-container { + height: @toolbar_line-height; + } } .cp-toolbar-bottom-right { diff --git a/customize.dist/src/less2/include/tools.less b/customize.dist/src/less2/include/tools.less index 87650ccdd..2b385cf6a 100644 --- a/customize.dist/src/less2/include/tools.less +++ b/customize.dist/src/less2/include/tools.less @@ -4,16 +4,20 @@ @color: @cp_forms-placeholder; &::-webkit-input-placeholder { /* WebKit, Blink, Edge */ color: @color; + font-weight: normal; } &::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */ color: @color; opacity: 1; /* Firefox */ + font-weight: normal; } &:-ms-input-placeholder { /* Internet Explorer 10-11 */ color: @color; + font-weight: normal; } &::-ms-input-placeholder { /* Microsoft Edge */ color: @color; + font-weight: normal; } } diff --git a/customize.dist/src/less2/pages/page-checkup.less b/customize.dist/src/less2/pages/page-checkup.less index 2a05600c7..9119a4b72 100644 --- a/customize.dist/src/less2/pages/page-checkup.less +++ b/customize.dist/src/less2/pages/page-checkup.less @@ -52,7 +52,7 @@ html, body { .advisory-text { display: inline-block; - word-break: break-all; + word-break: break-word; padding: 5px; //font-size: 16px; border: 1px solid red; diff --git a/customize.dist/src/less2/pages/page-feedback.less b/customize.dist/src/less2/pages/page-feedback.less new file mode 100644 index 000000000..d55eddd7b --- /dev/null +++ b/customize.dist/src/less2/pages/page-feedback.less @@ -0,0 +1,20 @@ +@import (reference) "../include/colortheme-all.less"; +@import (reference) "../include/font.less"; + +html, body { + .font_main(); + margin: 0px; + padding: 0px; + background-color: @cp_static-bg !important; + color: @cryptpad_text_col; + font-family: "IBM Plex Mono"; + a { + color: @cryptpad_color_link; + } +} +body { + width: 50%; + min-width: 650px; + margin: auto; +} + diff --git a/docs/example.nginx.conf b/docs/example.nginx.conf index 2c677436b..02a63bd98 100644 --- a/docs/example.nginx.conf +++ b/docs/example.nginx.conf @@ -61,7 +61,7 @@ server { # add_header X-Frame-Options "SAMEORIGIN"; set $coop ''; - if ($uri ~ ^\/sheet\/.*$) { set $coop 'same-origin'; } + if ($uri ~ ^\/(sheet|presentation|doc)\/.*$) { set $coop 'same-origin'; } # Enable SharedArrayBuffer in Firefox (for .xlsx export) add_header Cross-Origin-Resource-Policy cross-origin; @@ -116,7 +116,7 @@ server { set $unsafe 0; # the following assets are loaded via the sandbox domain # they unfortunately still require exceptions to the sandboxing to work correctly. - if ($uri = "/sheet/inner.html") { set $unsafe 1; } + if ($uri ~ ^\/(sheet|doc|presentation)\/inner.html.*$) { set $unsafe 1; } if ($uri ~ ^\/common\/onlyoffice\/.*\/index\.html.*$) { set $unsafe 1; } # everything except the sandbox domain is a privileged scope, as they might be used to handle keys @@ -159,7 +159,7 @@ server { # /api/config is loaded once per page load and is used to retrieve # the caching variable which is applied to every other resource # which is loaded during that session. - location = /api/config { + location ~ ^/api/(config|broadcast).*$ { proxy_pass http://localhost:3000; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Host $host; @@ -204,7 +204,7 @@ server { # The nodejs server has some built-in forwarding rules to prevent # URLs like /pad from resulting in a 404. This simply adds a trailing slash # to a variety of applications. - location ~ ^/(register|login|settings|user|pad|drive|poll|slide|code|whiteboard|file|media|profile|contacts|todo|filepicker|debug|kanban|sheet|support|admin|notifications|teams)$ { + location ~ ^/(register|login|settings|user|pad|drive|poll|slide|code|whiteboard|file|media|profile|contacts|todo|filepicker|debug|kanban|sheet|support|admin|notifications|teams|calendar|presentation|doc)$ { rewrite ^(.*)$ $1/ redirect; } diff --git a/lib/commands/channel.js b/lib/commands/channel.js index 86ab7bc8c..e69f93180 100644 --- a/lib/commands/channel.js +++ b/lib/commands/channel.js @@ -191,7 +191,8 @@ var ARRAY_LINE = /^\[/; */ Channel.isNewChannel = function (Env, channel, cb) { if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); } - if (channel.length !== 32) { return void cb('INVALID_CHAN'); } + if (channel.length !== HK.STANDARD_CHANNEL_LENGTH && + channel.length !== HK.ADMIN_CHANNEL_LENGTH) { return void cb('INVALID_CHAN'); } // TODO replace with readMessagesBin var done = false; @@ -229,7 +230,8 @@ Channel.writePrivateMessage = function (Env, args, _cb, Server, netfluxId) { if (!msg) { return void cb("INVALID_MESSAGE"); } // don't support anything except regular channels - if (!Core.isValidId(channelId) || channelId.length !== 32) { + if (!Core.isValidId(channelId) || (channelId.length !== HK.STANDARD_CHANNEL_LENGTH + && channelId.length !== HK.ADMIN_CHANNEL_LENGTH)) { return void cb("INVALID_CHAN"); } @@ -254,6 +256,11 @@ Channel.writePrivateMessage = function (Env, args, _cb, Server, netfluxId) { var session = HK.getNetfluxSession(Env, netfluxId); var allowed = HK.listAllowedUsers(metadata); + // Special broadcast channel + if (channelId.length === HK.ADMIN_CHANNEL_LENGTH) { + allowed = Env.admins; + } + if (HK.isUserSessionAllowed(allowed, session)) { return; } w.abort(); @@ -278,12 +285,19 @@ Channel.writePrivateMessage = function (Env, args, _cb, Server, netfluxId) { // historyKeeper already knows how to handle metadata and message validation, so we just pass it off here // if the message isn't valid it won't be stored. - Env.historyKeeper.channelMessage(Server, channelStruct, fullMessage); + Env.historyKeeper.channelMessage(Server, channelStruct, fullMessage, function (err) { + if (err) { + // Message not stored... + return void cb(err); + } - Server.getChannelUserList(channelId).forEach(function (userId) { - Server.send(userId, fullMessage); + // Broadcast the message + Server.getChannelUserList(channelId).forEach(function (userId) { + Server.send(userId, fullMessage); + }); }); + cb(); }); }; diff --git a/lib/commands/core.js b/lib/commands/core.js index f4e6a9f70..42ed0455b 100644 --- a/lib/commands/core.js +++ b/lib/commands/core.js @@ -10,7 +10,7 @@ Core.SESSION_EXPIRATION_TIME = 60 * 1000; Core.isValidId = function (chan) { return chan && chan.length && /^[a-zA-Z0-9=+-]*$/.test(chan) && - [32, 48].indexOf(chan.length) > -1; + [32, 33, 48].indexOf(chan.length) > -1; }; var makeToken = Core.makeToken = function () { diff --git a/lib/commands/metadata.js b/lib/commands/metadata.js index 896c89f31..9b6a23e02 100644 --- a/lib/commands/metadata.js +++ b/lib/commands/metadata.js @@ -9,7 +9,18 @@ const HK = require("../hk-util"); Data.getMetadataRaw = function (Env, channel /* channelName */, _cb) { const cb = Util.once(Util.mkAsync(_cb)); if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); } - if (channel.length !== HK.STANDARD_CHANNEL_LENGTH) { return cb("INVALID_CHAN_LENGTH"); } + if (channel.length !== HK.STANDARD_CHANNEL_LENGTH && + channel.length !== HK.ADMIN_CHANNEL_LENGTH) { return cb("INVALID_CHAN_LENGTH"); } + + // return synthetic metadata for admin broadcast channels as a safety net + // in case anybody manages to write metadata + if (channel.length === HK.ADMIN_CHANNEL_LENGTH) { + return void cb(void 0, { + channel: channel, + creation: +new Date(), + owners: Env.admins, + }); + } var cached = Env.metadata_cache[channel]; if (HK.isMetadataMessage(cached)) { diff --git a/lib/decrees.js b/lib/decrees.js index 2672efdd3..fca8fb331 100644 --- a/lib/decrees.js +++ b/lib/decrees.js @@ -24,6 +24,11 @@ SET_PREMIUM_UPLOAD_SIZE DISABLE_INTEGRATED_TASKS DISABLE_INTEGRATED_EVICTION +// BROADCAST +SET_LAST_BROADCAST_HASH +SET_SURVEY_URL +SET_MAINTENANCE + NOT IMPLEMENTED: // RESTRICTED REGISTRATION @@ -121,6 +126,44 @@ commands.SET_ARCHIVE_RETENTION_TIME = makeIntegerSetter('archiveRetentionTime'); // CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_ACCOUNT_RETENTION_TIME', [365]]], console.log) commands.SET_ACCOUNT_RETENTION_TIME = makeIntegerSetter('accountRetentionTime'); +var args_isString = function (args) { + return Array.isArray(args) && typeof(args[0]) === "string"; +}; + +// Maintenance: Empty string or an object with a start and end time +var isNumber = function (value) { + return typeof(value) === "number" && !isNaN(value); +}; +var args_isMaintenance = function (args) { + return Array.isArray(args) && args[0] && + (args[0] === "" || (isNumber(args[0].end) && isNumber(args[0].start))); +}; + +// we anticipate that we'll add language-specific surveys in the future +// whenever that happens we can relax validation a bit to support more formats +var makeBroadcastSetter = function (attr, validation) { + return function (Env, args) { + if ((validation && !validation(args)) && !args_isString(args)) { + throw new Error('INVALID_ARGS'); + } + var str = args[0]; + if (str === Env[attr]) { return false; } + Env[attr] = str; + Env.broadcastCache = {}; + return true; + }; +}; + +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LAST_BROADCAST_HASH', [hash]]], console.log) +commands.SET_LAST_BROADCAST_HASH = makeBroadcastSetter('lastBroadcastHash'); + +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_SURVEY_URL', [url]]], console.log) +commands.SET_SURVEY_URL = makeBroadcastSetter('surveyURL'); + +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [{start: +Date, end: +Date}]]], console.log) +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [""]]], console.log) +commands.SET_MAINTENANCE = makeBroadcastSetter('maintenance', args_isMaintenance); + var Quota = require("./commands/quota"); var Keys = require("./keys"); var Util = require("./common-util"); diff --git a/lib/env.js b/lib/env.js index 97d3893f9..b879d102f 100644 --- a/lib/env.js +++ b/lib/env.js @@ -19,8 +19,10 @@ module.exports.create = function (config) { FRESH_MODE: true, DEV_MODE: false, configCache: {}, + broadcastCache: {}, flushCache: function () { Env.configCache = {}; + Env.broadcastCache = {}; Env.FRESH_KEY = +new Date(); if (!(Env.DEV_MODE || Env.FRESH_MODE)) { Env.FRESH_MODE = true; } if (!Env.Log) { return; } @@ -65,6 +67,11 @@ module.exports.create = function (config) { paths: {}, //msgStore: config.store, + // /api/broadcast + lastBroadcastHash: '', + surveyURL: undefined, + maintenance: undefined, + netfluxUsers: {}, pinStore: undefined, diff --git a/lib/hk-util.js b/lib/hk-util.js index 21ccb4875..7c244c174 100644 --- a/lib/hk-util.js +++ b/lib/hk-util.js @@ -34,6 +34,7 @@ const getHash = HK.getHash = function (msg, Log) { // historyKeeper should explicitly store any channel // with a 32 character id const STANDARD_CHANNEL_LENGTH = HK.STANDARD_CHANNEL_LENGTH = 32; +const ADMIN_CHANNEL_LENGTH = HK.ADMIN_CHANNEL_LENGTH = 33; // historyKeeper should not store messages sent to any channel // with a 34 character id @@ -685,7 +686,7 @@ const handleGetHistory = function (Env, Server, seq, userId, parsed) { // If we're asking for a specific version (lastKnownHash) but we receive an // ENOENT, this is not a pad creation so we need to abort. - if (err && err.code === 'ENOENT' && lastKnownHash) { // XXX && lastKnownHash !== -1 + if (err && err.code === 'ENOENT' && lastKnownHash) { /* This informs clients that the pad they're trying to load was deleted by its owner. The user in question might be reconnecting or might have loaded the document from their cache. @@ -902,6 +903,11 @@ HK.onChannelMessage = function (Env, Server, channel, msgStruct, cb) { // don't store messages if the channel id indicates that it's an ephemeral message if (!channel.id || channel.id.length === EPHEMERAL_CHANNEL_LENGTH) { return void cb(); } + // Admin channel. We can only write to this one from private message (RPC) + if (channel.id.length === ADMIN_CHANNEL_LENGTH && msgStruct[1] !== null) { + return void cb('ERESTRICTED_ADMIN'); + } + const isCp = /^cp\|/.test(msgStruct[4]); let id; if (isCp) { @@ -912,8 +918,9 @@ HK.onChannelMessage = function (Env, Server, channel, msgStruct, cb) { // more straightforward and reliable. if (Array.isArray(id) && id[2] && id[2] === channel.lastSavedCp) { // Reject duplicate checkpoints - // XXX not an error? the checkpoint is already here so we can assume it's stored - return void cb('DUPLICATE'); + return void cb(); + // not an error? the checkpoint is already here so we can assume it's stored + //return void cb('DUPLICATE'); } } diff --git a/lib/storage/file.js b/lib/storage/file.js index d890cb0b9..825f14066 100644 --- a/lib/storage/file.js +++ b/lib/storage/file.js @@ -567,7 +567,7 @@ var listChannels = function (root, handler, cb, fast) { var metadataName; // if the current file is not the channel data, then it must be metadata - if (!/^[0-9a-fA-F]{32}\.ndjson$/.test(item)) { + if (!/^[0-9a-fA-F]{32, 33}\.ndjson$/.test(item)) { metadataName = item; channelName = item.replace(/\.metadata/, ''); @@ -584,7 +584,7 @@ var listChannels = function (root, handler, cb, fast) { } var channel = metadataName.replace(/\.metadata.ndjson$/, ''); - if ([32, 34, 44].indexOf(channel.length) === -1) { return; } + if ([32, 33, 34, 44].indexOf(channel.length) === -1) { return; } // otherwise throw it on the pile sema.take(function (give) { diff --git a/lib/workers/db-worker.js b/lib/workers/db-worker.js index 5274445eb..65f13d23b 100644 --- a/lib/workers/db-worker.js +++ b/lib/workers/db-worker.js @@ -391,7 +391,8 @@ const getPinState = function (data, cb) { const _getFileSize = function (channel, _cb) { var cb = Util.once(Util.mkAsync(_cb)); if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); } - if (channel.length === 32) { + if (channel.length === HK.STANDARD_CHANNEL_LENGTH || + channel.length === HK.ADMIN_CHANNEL_LENGTH) { return void store.getChannelSize(channel, function (e, size) { if (e) { if (e.code === 'ENOENT') { return void cb(void 0, 0); } diff --git a/package-lock.json b/package-lock.json index 3b0286e25..7e80e9592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "cryptpad", - "version": "4.3.1", + "version": "4.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 3b540387f..b1a9a093a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cryptpad", "description": "realtime collaborative visual editor with zero knowlege server", - "version": "4.3.1", + "version": "4.4.0", "license": "AGPL-3.0+", "repository": { "type": "git", diff --git a/scripts/find-html-translations.js b/scripts/find-html-translations.js index 8c93a6f8f..dabdbac4b 100644 --- a/scripts/find-html-translations.js +++ b/scripts/find-html-translations.js @@ -3,6 +3,22 @@ var EN = require("../www/common/translations/messages.json"); var simpleTags = [ '
', '
', + '
', + '', + '', + + // FIXME + "", + '

', + '

', + + // FIXME register_notes + '', + '
  • ', + '
  • ', + '', + '', ]; ['a', 'b', 'em', 'p', 'i'].forEach(function (tag) { @@ -10,18 +26,71 @@ var simpleTags = [ simpleTags.push(''); }); -Object.keys(EN).forEach(function (k) { - var s = EN[k]; - if (typeof(s) !== 'string') { return; } - var usesHTML; +// these keys are known to be problematic +var KNOWN_ISSUES = [ // FIXME + //'newVersion', + //'fm_info_anonymous', + //'register_notes', +]; - s.replace(/<.*?>/g, function (html) { - if (simpleTags.indexOf(html) !== -1) { return; } - usesHTML = true; - console.log("{%s}", html); +var processLang = function (map, lang, primary) { + var announced = false; + var announce = function () { + if (announced) { return; } + announced = true; + console.log("NEXT LANGUAGE: ", lang); + }; + + Object.keys(map).forEach(function (k) { + if (!EN[k]) { return; } + if (KNOWN_ISSUES.indexOf(k) !== -1) { return; } + + var s = map[k]; + if (typeof(s) !== 'string') { return; } + var usesHTML; + + s.replace(/<.*?>/g, function (html) { + if (simpleTags.indexOf(html) !== -1) { return; } + announce(); + usesHTML = true; + if (!primary) { + console.log("{%s}", html); + } + }); + + if (usesHTML) { + announce(); + console.log("%s", s); + console.log("[%s]\n", k); + } }); +}; - if (usesHTML) { - console.log("[%s] %s\n", k, s); - } +processLang(EN, 'en', true); + +[ + 'ar', + 'bn_BD', + 'ca', + 'de', + 'es', + 'fi', + 'fr', + 'hi', + 'it', + 'ja', + 'nb', + 'nl', + 'pl', + 'pt-br', + 'ro', + 'ru', + 'sv', + 'te', + 'tr', + 'zh', +].forEach(function (lang) { + var map = require("../www/common/translations/messages." + lang + ".json"); + if (!Object.keys(map).length) { return; } + processLang(map, lang); }); diff --git a/server.js b/server.js index d2df3bba6..36ca1a425 100644 --- a/server.js +++ b/server.js @@ -113,11 +113,11 @@ var setHeaders = (function () { // Don't set CSP headers on /api/config because they aren't necessary and they cause problems // when duplicated by NGINX in production environments - if (/^\/api\/config/.test(req.url)) { return; } + if (/^\/api\/(broadcast|config)/.test(req.url)) { return; } // targeted CSP, generic policies, maybe custom headers const h = [ /^\/common\/onlyoffice\/.*\/index\.html.*/, - /^\/(sheet|ooslide|oodoc)\/inner\.html.*/, + /^\/(sheet|presentation|doc)\/inner\.html.*/, ].some((regex) => { return regex.test(req.url); }) ? padHeaders : headers; @@ -201,46 +201,14 @@ app.use("/customize.dist", Express.static(__dirname + '/customize.dist')); app.use(/^\/[^\/]*$/, Express.static('customize')); app.use(/^\/[^\/]*$/, Express.static('customize.dist')); -var serveConfig = (function () { - // if dev mode: never cache - var cacheString = function () { - return (Env.FRESH_KEY? '-' + Env.FRESH_KEY: '') + (Env.DEV_MODE? '-' + (+new Date()): ''); - }; - - var template = function (host) { - return [ - 'define(function(){', - 'var obj = ' + JSON.stringify({ - requireConf: { - waitSeconds: 600, - urlArgs: 'ver=' + Package.version + cacheString(), - }, - removeDonateButton: (config.removeDonateButton === true), - allowSubscriptions: (config.allowSubscriptions === true), - websocketPath: config.externalWebsocketURL, - httpUnsafeOrigin: config.httpUnsafeOrigin, - adminEmail: Env.adminEmail, - adminKeys: Env.admins, - inactiveTime: Env.inactiveTime, - supportMailbox: Env.supportMailbox, - defaultStorageLimit: Env.defaultStorageLimit, - maxUploadSize: Env.maxUploadSize, - premiumUploadSize: Env.premiumUploadSize, - }, null, '\t'), - 'obj.httpSafeOrigin = ' + (function () { - if (config.httpSafeOrigin) { return '"' + config.httpSafeOrigin + '"'; } - if (config.httpSafePort) { - return "(function () { return window.location.origin.replace(/\:[0-9]+$/, ':" + - config.httpSafePort + "'); }())"; - } - return 'window.location.origin'; - }()), - 'return obj', - '});' - ].join(';\n') - }; +// if dev mode: never cache +var cacheString = function () { + return (Env.FRESH_KEY? '-' + Env.FRESH_KEY: '') + (Env.DEV_MODE? '-' + (+new Date()): ''); +}; +var makeRouteCache = function (template, cacheName) { var cleanUp = {}; + var cache = Env[cacheName] = Env[cacheName] || {}; return function (req, res) { var host = req.headers.host.replace(/\:[0-9]+/, ''); @@ -255,24 +223,74 @@ var serveConfig = (function () { // FIXME mutable // we must be able to clear the cache when updating any mutable key // if there's nothing cached for that key... - if (!Env.configCache[cacheKey]) { + if (!cache[cacheKey]) { // generate the response and cache it in memory - Env.configCache[cacheKey] = template(host); + cache[cacheKey] = template(host); // and create a function to conditionally evict cache entries // which have not been accessed in the last 20 seconds cleanUp[cacheKey] = Util.throttle(function () { delete cleanUp[cacheKey]; - delete Env.configCache[cacheKey]; + delete cache[cacheKey]; }, 20000); } // successive calls to this function cleanUp[cacheKey](); - return void res.send(Env.configCache[cacheKey]); + return void res.send(cache[cacheKey]); }; -}()); +}; + +var serveConfig = makeRouteCache(function (host) { + return [ + 'define(function(){', + 'var obj = ' + JSON.stringify({ + requireConf: { + waitSeconds: 600, + urlArgs: 'ver=' + Package.version + cacheString(), + }, + removeDonateButton: (config.removeDonateButton === true), + allowSubscriptions: (config.allowSubscriptions === true), + websocketPath: config.externalWebsocketURL, + httpUnsafeOrigin: config.httpUnsafeOrigin, + adminEmail: Env.adminEmail, + adminKeys: Env.admins, + inactiveTime: Env.inactiveTime, + supportMailbox: Env.supportMailbox, + defaultStorageLimit: Env.defaultStorageLimit, + maxUploadSize: Env.maxUploadSize, + premiumUploadSize: Env.premiumUploadSize, + }, null, '\t'), + 'obj.httpSafeOrigin = ' + (function () { + if (config.httpSafeOrigin) { return '"' + config.httpSafeOrigin + '"'; } + if (config.httpSafePort) { + return "(function () { return window.location.origin.replace(/\:[0-9]+$/, ':" + + config.httpSafePort + "'); }())"; + } + return 'window.location.origin'; + }()), + 'return obj', + '});' + ].join(';\n') +}, 'configCache'); + +var serveBroadcast = makeRouteCache(function (host) { + var maintenance = Env.maintenance; + if (maintenance && maintenance.end && maintenance.end < (+new Date())) { + maintenance = undefined; + } + return [ + 'define(function(){', + 'return ' + JSON.stringify({ + lastBroadcastHash: Env.lastBroadcastHash, + surveyURL: Env.surveyURL, + maintenance: maintenance + }, null, '\t'), + '});' + ].join(';\n') +}, 'broadcastCache'); app.get('/api/config', serveConfig); +app.get('/api/broadcast', serveBroadcast); var four04_path = Path.resolve(__dirname + '/customize.dist/404.html'); var custom_four04_path = Path.resolve(__dirname + '/customize/404.html'); diff --git a/www/admin/app-admin.less b/www/admin/app-admin.less index c9b8bdc02..17450361c 100644 --- a/www/admin/app-admin.less +++ b/www/admin/app-admin.less @@ -14,8 +14,12 @@ display: flex; flex-flow: column; + a { + color: @cryptpad_color_link; + text-decoration: underline; + } - .cp-admin-setlimit-form { + .cp-admin-setlimit-form, .cp-admin-broadcast-form { label { font-weight: normal !important; } @@ -199,5 +203,85 @@ } } + .cp-admin-broadcast-form { + input.flatpickr-input { + width: 307.875px !important; // same width as flatpickr calendar + } + .cp-broadcast-active { + display: flex; + flex-flow: column; + align-items: start; + padding: 10px; + background-color: @cp_sidebar-left-bg; + color: @cp_sidebar-left-fg; + p { + margin: 0; + } + } + .cp-broadcast-form-submit { + margin-top: 30px; + button { + margin-bottom: 10px !important; + } + } + .cp-broadcast-container { + display: flex; + flex-flow: column; + } + .cp-broadcast-lang { + margin: 30px; + margin-bottom: 0; + display: flex; + flex-flow: column; + align-items: baseline; + .cp-checkmark { + margin: 5px 0; + } + } + div.cp-broadcast-languages { + & > label.cp-checkmark:not(:last-child) { + margin-right: 20px; + } + } + .cp-broadcast-preview { + vertical-align: bottom !important; + } + .cp-broadcast-delete { + width: 100%; + min-width: 600px; + tbody { + tr { + background-color: @cp_support-msg-bg; + padding: 5px; + td { + padding: 5px; + button { + margin: 0 !important; + } + } + } + } + .cp-notification { + display: flex; + align-items: center; + .cp-avatar, .cp-broadcast, .cp-notification-dismiss { + display: none; + } + p { + margin: 0 !important; + } + .cp-notification-content { + width: 100%; + padding: 10px; + } + .cp-clickable { + cursor: pointer; + &:hover { + background-color: @cp_dropdown-bg-hover; + } + } + } + } + } } diff --git a/www/admin/inner.js b/www/admin/inner.js index 9a97539a5..332ff1fe3 100644 --- a/www/admin/inner.js +++ b/www/admin/inner.js @@ -1,6 +1,7 @@ define([ 'jquery', '/api/config', + '/customize/application_config.js', '/bower_components/chainpad-crypto/crypto.js', '/common/toolbar.js', '/bower_components/nthen/index.js', @@ -14,12 +15,16 @@ define([ '/common/common-signing-keys.js', '/support/ui.js', + '/lib/datepicker/flatpickr.js', + + 'css!/lib/datepicker/flatpickr.min.css', 'css!/bower_components/bootstrap/dist/css/bootstrap.min.css', 'css!/bower_components/components-font-awesome/css/font-awesome.min.css', 'less!/admin/app-admin.less', ], function ( $, ApiConfig, + AppConfig, Crypto, Toolbar, nThen, @@ -31,7 +36,8 @@ define([ Util, Hash, Keys, - Support + Support, + Flatpickr ) { var APP = { @@ -67,6 +73,11 @@ define([ 'cp-admin-support-list', 'cp-admin-support-init' ], + 'broadcast': [ // Msg.admin_cat_broadcast + 'cp-admin-maintenance', + 'cp-admin-survey', + 'cp-admin-broadcast', + ], 'performance': [ // Msg.admin_cat_performance 'cp-admin-refresh-performance', 'cp-admin-performance-profiling', @@ -930,6 +941,542 @@ define([ return; }; + var getApi = function (cb) { + return function () { + require(['/api/broadcast?'+ (+new Date())], function (Broadcast) { + cb(Broadcast); + setTimeout(function () { + try { + var ctx = require.s.contexts._; + var defined = ctx.defined; + Object.keys(defined).forEach(function (href) { + if (/^\/api\/broadcast\?[0-9]{13}/.test(href)) { + delete defined[href]; + return; + } + }); + } catch (e) {} + }); + }); + }; + }; + + // Update the lastBroadcastHash in /api/broadcast if we can do it. + // To do so, find the last "BROADCAST_CUSTOM" in the current history and use the previous + // message's hash. + // If the last BROADCAST_CUSTOM has been deleted by an admin, we can use the most recent + // message's hash. + var checkLastBroadcastHash = function () { + var deleted = []; + + require(['/api/broadcast?'+ (+new Date())], function (BCast) { + var hash = BCast.lastBroadcastHash || '1'; // Truthy value if no lastKnownHash + common.mailbox.getNotificationsHistory('broadcast', null, hash, function (e, msgs) { + if (e) { return void console.error(e); } + + // No history, nothing to change + if (!Array.isArray(msgs)) { return; } + if (!msgs.length) { return; } + + var lastHash; + var next = false; + + // Start from the most recent messages until you find a CUSTOM message and + // check if it has been deleted + msgs.reverse().some(function (data) { + var c = data.content; + + // This is the hash we want to keep + if (next) { + if (!c || !c.hash) { return; } + lastHash = c.hash; + next = false; + return true; + } + + // initialize with the most recent hash + if (!lastHash && c && c.hash) { lastHash = c.hash; } + + var msg = c && c.msg; + if (!msg) { return; } + + // Remember all deleted messages + if (msg.type === "BROADCAST_DELETE") { + deleted.push(Util.find(msg, ['content', 'uid'])); + } + + // Only check custom messages + if (msg.type !== "BROADCAST_CUSTOM") { return; } + + // If the most recent CUSTOM message has been deleted, it means we don't + // need to keep any message and we can continue with lastHash as the most + // recent broadcast message. + if (deleted.indexOf(msg.uid) !== -1) { return true; } + + // We just found the oldest message we want to keep, move one iteration + // further into the loop to get the next message's hash. + // If this is the end of the loop, don't bump lastBroadcastHash at all. + next = true; + }); + + // If we don't have to bump our lastBroadcastHash, abort + if (next) { return; } + + // Otherwise, bump to lastHash + console.warn('Updating last broadcast hash to', lastHash); + sFrameChan.query('Q_ADMIN_RPC', { + cmd: 'ADMIN_DECREE', + data: ['SET_LAST_BROADCAST_HASH', [lastHash]] + }, function (e) { + if (e) { + console.error(e); + return; + } + console.log('lastBroadcastHash updated'); + }); + }); + }); + + }; + + create['broadcast'] = function () { + var key = 'broadcast'; + var $div = makeBlock(key); // Msg.admin_broadcastHint, admin_broadcastTitle + + var form = h('div.cp-admin-broadcast-form'); + var $form = $(form).appendTo($div); + + var refresh = getApi(function (Broadcast) { + var button = h('button.btn.btn-primary', Messages.admin_broadcastButton); + var $button = $(button); + var removeButton = h('button.btn.btn-danger', Messages.admin_broadcastCancel); + var active = h('div.cp-broadcast-active', h('p', Messages.admin_broadcastActive)); + var $active = $(active); + var activeUid; + var deleted = []; + + // Render active message (if there is one) + var hash = Broadcast.lastBroadcastHash || '1'; // Truthy value if no lastKnownHash + common.mailbox.getNotificationsHistory('broadcast', null, hash, function (e, msgs) { + if (e) { return void console.error(e); } + if (!Array.isArray(msgs)) { return; } + if (!msgs.length) { + $active.hide(); + } + msgs.reverse().some(function (data) { + var c = data.content; + var msg = c && c.msg; + if (!msg) { return; } + if (msg.type === "BROADCAST_DELETE") { + deleted.push(Util.find(msg, ['content', 'uid'])); + } + if (msg.type !== "BROADCAST_CUSTOM") { return; } + if (deleted.indexOf(msg.uid) !== -1) { return true; } + + // We found an active custom message, show it + var el = common.mailbox.createElement(data); + var table = h('table.cp-broadcast-delete'); + var $table = $(table); + var uid = Util.find(data, ['content', 'msg', 'uid']); + var time = Util.find(data, ['content', 'msg', 'content', 'time']); + var tr = h('tr', { 'data-uid': uid }, [ + h('td', 'ID: '+uid), + h('td', new Date(time || 0).toLocaleString()), + h('td', el), + h('td.delete', removeButton), + ]); + $table.append(tr); + $active.append(table); + activeUid = uid; + + return true; + }); + if (!activeUid) { $active.hide(); } + }); + + // Custom message + var container = h('div.cp-broadcast-container'); + var $container = $(container); + var languages = Messages._languages; + var keys = Object.keys(languages).sort(); + + // Always keep the textarea ordered by language code + var reorder = function () { + $container.find('.cp-broadcast-lang').each(function (i, el) { + var $el = $(el); + var l = $el.attr('data-lang'); + $el.css('order', keys.indexOf(l)); + }); + }; + + // Remove a textarea + var removeLang = function (l) { + $container.find('.cp-broadcast-lang[data-lang="'+l+'"]').remove(); + + var hasDefault = $container.find('.cp-broadcast-lang .cp-checkmark input:checked').length; + if (!hasDefault) { + $container.find('.cp-broadcast-lang').first().find('.cp-checkmark input').prop('checked', 'checked'); + } + }; + + var getData = function () { return false; }; + var onPreview = function (l) { + var data = getData(); + if (data === false) { return void UI.warn(Messages.error); } + + var msg = { + uid: Util.uid(), + type: 'BROADCAST_CUSTOM', + content: data + }; + common.mailbox.onMessage({ + lang: l, + type: 'broadcast', + content: { + msg: msg, + hash: 'LOCAL|' + JSON.stringify(msg).slice(0,58) + } + }, function () { + UI.log(Messages.saved); + }); + }; + + // Add a textarea + var addLang = function (l) { + if ($container.find('.cp-broadcast-lang[data-lang="'+l+'"]').length) { return; } + var preview = h('button.btn.btn-secondary', Messages.broadcast_preview); + $(preview).click(function () { + onPreview(l); + }); + var bcastDefault = Messages.broadcast_defaultLanguage; + var first = !$container.find('.cp-broadcast-lang').length; + var radio = UI.createRadio('broadcastDefault', null, bcastDefault, first, { + 'data-lang': l, + label: {class: 'noTitle'} + }); + $container.append(h('div.cp-broadcast-lang', { 'data-lang': l }, [ + h('h4', languages[l]), + h('label', Messages.kanban_body), + h('textarea'), + radio, + preview + ])); + reorder(); + }; + + // Checkboxes to select translations + var boxes = keys.map(function (l) { + var $cbox = $(UI.createCheckbox('cp-broadcast-custom-lang-'+l, + languages[l], false, { label: { class: 'noTitle' } })); + var $check = $cbox.find('input').on('change', function () { + var c = $check.is(':checked'); + if (c) { return void addLang(l); } + removeLang(l); + }); + if (l === 'en') { + setTimeout(function () { + $check.click(); + }); + } + return $cbox[0]; + }); + + // Extract form data + getData = function () { + var map = {}; + var defaultLanguage; + var error = false; + $container.find('.cp-broadcast-lang').each(function (i, el) { + var $el = $(el); + var l = $el.attr('data-lang'); + if (!l) { error = true; return; } + var text = $el.find('textarea').val(); + if (!text.trim()) { error = true; return; } + if ($el.find('.cp-checkmark input').is(':checked')) { + defaultLanguage = l; + } + map[l] = text; + }); + if (!Object.keys(map).length) { + console.error('You must select at least one language'); + return false; + } + if (error) { + console.error('One of the selected languages has no data'); + return false; + } + return { + defaultLanguage: defaultLanguage, + content: map + }; + }; + + var send = function (data) { + $button.prop('disabled', 'disabled'); + //data.time = +new Date(); // FIXME not used anymore? + common.mailbox.sendTo('BROADCAST_CUSTOM', data, {}, function (err) { + if (err) { + $button.prop('disabled', ''); + console.error(err); + return UI.warn(Messages.error); + } + UI.log(Messages.saved); + refresh(); + + checkLastBroadcastHash(); + }); + }; + + $button.click(function () { + var data = getData(); + if (data === false) { return void UI.warn(Messages.error); } + send(data); + }); + + UI.confirmButton(removeButton, { + classes: 'btn-danger', + }, function () { + if (!activeUid) { return; } + common.mailbox.sendTo('BROADCAST_DELETE', { + uid: activeUid + }, {}, function (err) { + if (err) { return UI.warn(Messages.error); } + UI.log(Messages.saved); + refresh(); + checkLastBroadcastHash(); + }); + }); + + // Make the form + $form.empty().append([ + active, + h('label', Messages.broadcast_translations), + h('div.cp-broadcast-languages', boxes), + container, + h('div.cp-broadcast-form-submit', [ + h('br'), + button + ]) + ]); + }); + refresh(); + + return $div; + }; + + create['maintenance'] = function () { + var key = 'maintenance'; + var $div = makeBlock(key); // Msg.admin_maintenanceHint, admin_maintenanceTitle + + var form = h('div.cp-admin-broadcast-form'); + var $form = $(form).appendTo($div); + + var refresh = getApi(function (Broadcast) { + var button = h('button.btn.btn-primary', Messages.admin_maintenanceButton); + var $button = $(button); + var removeButton = h('button.btn.btn-danger', Messages.admin_maintenanceCancel); + var active; + + if (Broadcast && Broadcast.maintenance) { + var m = Broadcast.maintenance; + if (m.start && m.end && m.end >= (+new Date())) { + active = h('div.cp-broadcast-active', [ + UI.setHTML(h('p'), Messages._getKey('broadcast_maintenance', [ + new Date(m.start).toLocaleString(), + new Date(m.end).toLocaleString(), + ])), + removeButton + ]); + } + } + + // Start and end date pickers + var start = h('input'); + var end = h('input'); + var $start = $(start); + var $end = $(end); + var is24h = false; + try { + is24h = !new Intl.DateTimeFormat(navigator.language, { hour: 'numeric' }).format(0).match(/AM/); + } catch (e) {} + + var endPickr = Flatpickr(end, { + enableTime: true, + time_24hr: is24h, + minDate: new Date() + }); + Flatpickr(start, { + enableTime: true, + time_24hr: is24h, + minDate: new Date(), + onChange: function () { + endPickr.set('minDate', new Date($start.val())); + } + }); + + // Extract form data + var getData = function () { + var start = +new Date($start.val()); + var end = +new Date($end.val()); + if (isNaN(start) || isNaN(end)) { + console.error('Invalid dates'); + return false; + } + return { + start: start, + end: end + }; + }; + + var send = function (data) { + $button.prop('disabled', 'disabled'); + sFrameChan.query('Q_ADMIN_RPC', { + cmd: 'ADMIN_DECREE', + data: ['SET_MAINTENANCE', [data]] + }, function (e) { + if (e) { + UI.warn(Messages.error); console.error(e); + $button.prop('disabled', ''); + return; + } + // Maintenance applied, send notification + common.mailbox.sendTo('BROADCAST_MAINTENANCE', {}, {}, function () { + refresh(); + checkLastBroadcastHash(); + }); + }); + + }; + $button.click(function () { + var data = getData(); + if (data === false) { return void UI.warn(Messages.error); } + send(data); + }); + UI.confirmButton(removeButton, { + classes: 'btn-danger', + }, function () { + send(""); + }); + + $form.empty().append([ + active, + h('label', Messages.broadcast_start), + start, + h('label', Messages.broadcast_end), + end, + h('br'), + h('div.cp-broadcast-form-submit', [ + button + ]) + ]); + }); + refresh(); + + common.makeUniversal('broadcast', { + onEvent: function (obj) { + var cmd = obj.ev; + if (cmd !== "MAINTENANCE") { return; } + refresh(); + } + }); + + return $div; + }; + create['survey'] = function () { + var key = 'survey'; + var $div = makeBlock(key); // Msg.admin_surveyHint, admin_surveyTitle + + var form = h('div.cp-admin-broadcast-form'); + var $form = $(form).appendTo($div); + + var refresh = getApi(function (Broadcast) { + var button = h('button.btn.btn-primary', Messages.admin_surveyButton); + var $button = $(button); + var removeButton = h('button.btn.btn-danger', Messages.admin_surveyCancel); + var active; + + if (Broadcast && Broadcast.surveyURL) { + var a = h('a', {href: Broadcast.surveyURL}, Messages.admin_surveyActive); + $(a).click(function (e) { + e.preventDefault(); + common.openUnsafeURL(Broadcast.surveyURL); + }); + active = h('div.cp-broadcast-active', [ + h('p', a), + removeButton + ]); + } + + // Survey form + var label = h('label', Messages.broadcast_surveyURL); + var input = h('input'); + var $input = $(input); + + // Extract form data + var getData = function () { + var url = $input.val(); + if (!Util.isValidURL(url)) { + console.error('Invalid URL'); + return false; + } + return url; + }; + + var send = function (data) { + $button.prop('disabled', 'disabled'); + sFrameChan.query('Q_ADMIN_RPC', { + cmd: 'ADMIN_DECREE', + data: ['SET_SURVEY_URL', [data]] + }, function (e) { + if (e) { + $button.prop('disabled', ''); + UI.warn(Messages.error); console.error(e); + return; + } + // Maintenance applied, send notification + common.mailbox.sendTo('BROADCAST_SURVEY', { + url: data + }, {}, function () { + refresh(); + checkLastBroadcastHash(); + }); + }); + + }; + $button.click(function () { + var data = getData(); + if (data === false) { return void UI.warn(Messages.error); } + send(data); + }); + UI.confirmButton(removeButton, { + classes: 'btn-danger', + }, function () { + send(""); + }); + + $form.empty().append([ + active, + label, + input, + h('br'), + h('div.cp-broadcast-form-submit', [ + button + ]) + ]); + }); + refresh(); + + common.makeUniversal('broadcast', { + onEvent: function (obj) { + var cmd = obj.ev; + if (cmd !== "SURVEY") { return; } + refresh(); + } + }); + + return $div; + }; + var onRefreshPerformance = Util.mkEvent(); create['refresh-performance'] = function () { @@ -1010,6 +1557,7 @@ define([ stats: 'fa fa-line-chart', quota: 'fa fa-hdd-o', support: 'fa fa-life-ring', + broadcast: 'fa fa-bullhorn', performance: 'fa fa-heartbeat', }; @@ -1094,8 +1642,7 @@ define([ var privateData = metadataMgr.getPrivateData(); common.setTabTitle(Messages.adminPage || 'Administration'); - if (!privateData.edPublic || !ApiConfig.adminKeys || !Array.isArray(ApiConfig.adminKeys) - || ApiConfig.adminKeys.indexOf(privateData.edPublic) === -1) { + if (!common.isAdmin()) { return void UI.errorLoadingScreen(Messages.admin_authError || '403 Forbidden'); } diff --git a/www/calendar/app-calendar.less b/www/calendar/app-calendar.less new file mode 100644 index 000000000..bc0f47a48 --- /dev/null +++ b/www/calendar/app-calendar.less @@ -0,0 +1,266 @@ +@import (reference) '../../customize/src/less2/include/framework.less'; +@import (reference) '../../customize/src/less2/include/sidebar-layout.less'; +@import (reference) '../../customize/src/less2/include/tools.less'; +@import (reference) '../../customize/src/less2/include/avatar.less'; + +&.cp-app-calendar { + + .framework_min_main(); + .sidebar-layout_main(); + + display: flex; + flex-flow: column; + + #cp-sidebarlayout-container #cp-sidebarlayout-rightside { + padding: 0; + & > div { + margin: 0; + } + + .cp-forcehide { + display: none !important; + } + + .tui-full-calendar-layout { + background-color: @cp_sidebar-right-bg !important; + color: @cryptpad_text_col; + .tui-full-calendar-month { + .tui-full-calendar-weekday-schedule-time .tui-full-calendar-weekday-schedule-title { + color: @cryptpad_text_col !important; // XXX + } + .tui-full-calendar-extra-date { + .tui-full-calendar-weekday-grid-date { + color: @cp_sidebar-hint !important; // XXX + opacity: 0.5; + } + } + .tui-full-calendar-weekday-grid-date { + color: @cryptpad_text_col !important; // XXX + } + .tui-full-calendar-month-dayname-item span { + color: @cryptpad_text_col !important; // XXX + } + } + .tui-full-calendar-dayname * { + color: @cryptpad_text_col !important; // XXX + } + .tui-full-calendar-month-more { + background-color: @cp_sidebar-right-bg !important; + color: @cryptpad_text_col; + span { + color: @cryptpad_text_col !important; + } + } + } + .tui-full-calendar-timegrid-timezone { + background-color: @cp_sidebar-right-bg !important; + .tui-full-calendar-timegrid-hour { + color: @cryptpad_text_col !important; + } + color: @cryptpad_text_col; + } + .tui-full-calendar-timegrid-gridline, .tui-full-calendar-time-date { + border-color: @cp_calendar-border !important; + } + .tui-full-calendar-splitter, .tui-full-calendar-left, .tui-full-calendar-dayname-container, .tui-full-calendar-weekday-grid-line { + border-color: @cp_calendar-border !important; + + } + .tui-full-calendar-popup-container { + background: @cp_flatpickr-bg; + color: @cryptpad_text_col; + .tui-full-calendar-icon:not(.tui-full-calendar-calendar-dot):not(.tui-full-calendar-dropdown-arrow):not(.tui-full-calendar-ic-checkbox) { + display: none; + } + } + li.tui-full-calendar-popup-section-item { + padding: 0 6px; + height: 32px; + } + .tui-full-calendar-popup-section-item { + height: auto; + margin: 0; + &:not(li):not(button) { + padding: 0; + margin-top: 5px; + } + #tui-full-calendar-schedule-calendar { + width: 179px; + top: 0; + } + &:not(button) { + border: none; + display: inline-flex; + align-items: center; + &:hover { + background-color: @cp_dropdown-bg-hover; + } + .tui-full-calendar-content { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font: @colortheme_app-font; + } + input { flex: 1; } + } + } + .tui-full-calendar-section-date-dash { + height: auto; + } + .tui-full-calendar-section-title, .tui-full-calendar-section-location { + width: 100%; + } + .tui-full-calendar-dropdown-menu { + top: 38px; + width: 221px; // same as button + background-color: @cp_dropdown-bg; + color: @cp_dropdown-fg; + } + .tui-full-calendar-section-state, #tui-full-calendar-schedule-private { + display: none !important; + } + + .tui-full-calendar-popup:not(.tui-full-calendar-popup-detail) { + .tui-full-calendar-section-calendar { + width: 221px; // 50% + } + .tui-full-calendar-popup-section { + display: flex; + align-items: center; + flex-wrap: wrap; + .tui-full-calendar-section-start-date, .tui-full-calendar-section-end-date { + flex: 1; + } + .tui-full-calendar-section-allday { + width: 100%; + height: 32px; + } + } + } + .tui-full-calendar-popup-detail { + font: @colortheme_app-font; + color: @cryptpad_text_col; + .tui-full-calendar-popup-container { + padding-bottom: 17px; + } + .tui-full-calendar-popup-detail-date { + font-size: 14px; + } + .tui-full-calendar-section-button { + border: 0; + display: flex; + align-items: center; + button { + flex: 1; + margin: 0; + } + } + .tui-full-calendar-popup-vertical-line { + visibility: hidden; + width: 10px; + } + } + + .cp-calendar-close { + height: auto; + line-height: initial; + border: 1px solid; + &:not(:hover) { + background: transparent; + } + } + } + + #cp-toolbar .cp-calendar-browse { + display: flex; + align-items: center; + } + + #cp-sidebarlayout-leftside { + & > div { + padding: 10px + } + .cp-calendar-new { + display: flex; + align-items: center; + justify-content: space-between; + } + .cp-calendar-list { + .cp-calendar-team { + height: 30px; + .avatar_main(30px); + .cp-avatar { + margin-right: 10px; + } + .cp-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + display: flex; + align-items: center; + justify-content: center; + margin: 5px 0; + } + .cp-calendar-entry { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px; + cursor: pointer; + &.cp-ghost { + padding: 0; + button { + .tools_unselectable(); + cursor: pointer; + width: 100%; + display: flex; + justify-content: space-between; + background: transparent; + border: 1px solid @cryptpad_text_col; + height: 36px; + font: @colortheme_app-font; + align-items: center; + color: @cryptpad_text_col; + &:hover { + background: @cp_sidebar-left-active; + } + } + } + &:not(:last-child) { + margin-bottom: 10px; + } + &:hover { + background: fade(@cryptpad_text_col, 10%); + } + &.cp-restricted { + color: @cp_drive-header-fg; + } + &.cp-active { + background: @cp_sidebar-left-active; + } + .tools_unselectable(); + .cp-calendar-color { + display: inline-block; + border-radius: 50%; + width: 15px; + height: 15px; + flex-shrink: 0; + } + .cp-calendar-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 5px; + } + } + } + } + .cp-calendar-colorpicker { + width: 100px; + height: 25px; + cursor: pointer; + border: 1px solid @cp_forms-border; + } +} + diff --git a/www/calendar/index.html b/www/calendar/index.html new file mode 100644 index 000000000..96a3cce86 --- /dev/null +++ b/www/calendar/index.html @@ -0,0 +1,12 @@ + + + + CryptPad + + + + + + + + diff --git a/www/calendar/inner.html b/www/calendar/inner.html new file mode 100644 index 000000000..8c77c4fd7 --- /dev/null +++ b/www/calendar/inner.html @@ -0,0 +1,18 @@ + + + + + + + + +
    +
    + + + diff --git a/www/calendar/inner.js b/www/calendar/inner.js new file mode 100644 index 000000000..6b9f95685 --- /dev/null +++ b/www/calendar/inner.js @@ -0,0 +1,1003 @@ +define([ + 'jquery', + '/bower_components/chainpad-crypto/crypto.js', + '/common/toolbar.js', + '/bower_components/nthen/index.js', + '/common/sframe-common.js', + '/common/common-util.js', + '/common/common-hash.js', + '/common/common-interface.js', + '/common/common-ui-elements.js', + '/common/common-realtime.js', + '/common/clipboard.js', + '/common/inner/common-mediatag.js', + '/common/hyperscript.js', + '/customize/messages.js', + '/customize/application_config.js', + '/lib/calendar/tui-calendar.min.js', + + '/common/inner/share.js', + '/common/inner/access.js', + '/common/inner/properties.js', + + '/common/jscolor.js', + 'css!/lib/calendar/tui-calendar.min.css', + 'css!/bower_components/components-font-awesome/css/font-awesome.min.css', + 'less!/calendar/app-calendar.less', +], function ( + $, + Crypto, + Toolbar, + nThen, + SFCommon, + Util, + Hash, + UI, + UIElements, + Realtime, + Clipboard, + MT, + h, + Messages, + AppConfig, + Calendar, + Share, Access, Properties + ) +{ + var APP = window.APP = { + calendars: {} + }; + + var common; + var metadataMgr; + var sframeChan; + +Messages.calendar = "Calendar"; // XXX +Messages.calendar_default = "My calendar"; // XXX +Messages.calendar_new = "New calendar"; // XXX +Messages.calendar_day = "Day"; +Messages.calendar_week = "Week"; +Messages.calendar_month = "Month"; +Messages.calendar_today = "Today"; +Messages.calendar_more = "{0} more"; +Messages.calendar_deleteConfirm = "Are you sure you want to delete this calendar from your account?"; +Messages.calendar_deleteTeamConfirm = "Are you sure you want to delete this calendar from this team?"; +Messages.calendar_deleteOwned = " It will still be visible for the users it has been shared with."; +Messages.calendar_errorNoCalendar = "No editable calendar selected!"; +Messages.calendar_myCalendars = "My calendars"; +Messages.calendar_tempCalendar = "Temp calendar"; +Messages.calendar_import = "Import to my calendars"; +Messages.calendar_newEvent = "New event"; +Messages.calendar_new = "New calendar"; +Messages.calendar_dateRange = "{0} - {1}"; +Messages.calendar_dateTimeRange = "{0} {1} - {2}"; +Messages.calendar_update = "Update"; +Messages.calendar_title = "Title"; +Messages.calendar_loc = "Location"; +Messages.calendar_location = "Location: {0}"; +Messages.calendar_allDay = "All day"; + + var onCalendarsUpdate = Util.mkEvent(); + + var newCalendar = function (data, cb) { + APP.module.execCommand('CREATE', data, function (obj) { + if (obj && obj.error) { return void cb(obj.error); } + cb(null, obj); + }); + }; + var updateCalendar = function (data, cb) { + APP.module.execCommand('UPDATE', data, function (obj) { + if (obj && obj.error) { return void cb(obj.error); } + cb(null, obj); + }); + }; + var deleteCalendar = function (data, cb) { + APP.module.execCommand('DELETE', data, function (obj) { + if (obj && obj.error) { return void cb(obj.error); } + cb(null, obj); + }); + }; + var importCalendar = function (data, cb) { + APP.module.execCommand('IMPORT', data, function (obj) { + if (obj && obj.error) { return void cb(obj.error); } + cb(null, obj); + }); + }; + var newEvent = function (data, cb) { + APP.module.execCommand('CREATE_EVENT', data, function (obj) { + if (obj && obj.error) { return void cb(obj.error); } + cb(null, obj); + }); + }; + var updateEvent = function (data, cb) { + APP.module.execCommand('UPDATE_EVENT', data, function (obj) { + if (obj && obj.error) { return void cb(obj.error); } + cb(null, obj); + }); + }; + var deleteEvent = function (data, cb) { + APP.module.execCommand('DELETE_EVENT', data, function (obj) { + if (obj && obj.error) { return void cb(obj.error); } + cb(null, obj); + }); + }; + + var getContrast = function (color) { + var rgb = Util.hexToRGB(color); + // http://www.w3.org/TR/AERT#color-contrast + var brightness = Math.round(((parseInt(rgb[0]) * 299) + + (parseInt(rgb[1]) * 587) + + (parseInt(rgb[2]) * 114)) / 1000); + return (brightness > 125) ? 'black' : 'white'; + }; + + var getWeekDays = function () { + var baseDate = new Date(Date.UTC(2017, 0, 1)); // just a Sunday + var weekDays = []; + for(var i = 0; i < 7; i++) { + weekDays.push(baseDate.toLocaleDateString(undefined, { weekday: 'long' })); + baseDate.setDate(baseDate.getDate() + 1); + } + return weekDays.map(function (day) { return day.replace(/^./, function (str) { return str.toUpperCase(); }); }); + }; + + + + var getCalendars = function () { + return Object.keys(APP.calendars).map(function (id) { + var c = APP.calendars[id]; + if (c.hidden || c.restricted || c.loading) { return; } + var md = Util.find(c, ['content', 'metadata']); + if (!md) { return void console.error('Ignore calendar without metadata'); } + return { + id: id, + name: md.title, + color: getContrast(md.color), + bgColor: md.color, + dragBgColor: md.color, + borderColor: md.color, + }; + }).filter(Boolean); + }; + var getSchedules = function () { + var s = []; + Object.keys(APP.calendars).forEach(function (id) { + var c = APP.calendars[id]; + if (c.hidden || c.restricted || c.loading) { return; } + var data = c.content || {}; + Object.keys(data.content || {}).forEach(function (uid) { + var obj = data.content[uid]; + obj.title = obj.title || ""; + obj.location = obj.location || ""; + if (obj.isAllDay && obj.startDay) { obj.start = +new Date(obj.startDay); } + if (obj.isAllDay && obj.endDay) { + var endDate = new Date(obj.endDay); + endDate.setHours(23); + endDate.setMinutes(59); + endDate.setSeconds(59); + obj.end = +endDate; + } + if (c.readOnly) { + obj.isReadOnly = true; + } + s.push(data.content[uid]); + }); + }); + return s; + }; + var renderCalendar = function () { + var cal = APP.calendar; + if (!cal) { return; } + + cal.clear(); + cal.setCalendars(getCalendars()); + cal.createSchedules(getSchedules(), true); + cal.render(); + }; + var onCalendarUpdate = function (data) { + var cal = APP.calendar; + + if (data.deleted) { + // Remove this calendar + delete APP.calendars[data.id]; + } else { + // Update local data + APP.calendars[data.id] = data; + } + + // If calendar if initialized, update it + if (!cal) { return; } + onCalendarsUpdate.fire(); + renderCalendar(); + }; + + var getTime = function (time) { + var d = new Date(); + d.setHours(time.hour); + d.setMinutes(time.minutes); + return d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + }; + + // If this browser doesn't support options to toLocaleTimeString, use default layout + if (!(function () { + // Modern browser will return a RangeError if the "locale" argument is invalid. + // Note: the "locale" argument has the same browser compatibility table as the "options" + try { + new Date().toLocaleTimeString('i'); + } catch (e) { + return e.name === 'RangeError'; + } + })()) { getTime = undefined; } + + var templates = { + monthGridHeaderExceed: function(hiddenSchedules) { + return '' + Messages._getKey('calendar_more', [hiddenSchedules]) + ''; + }, + popupSave: function () { return Messages.settings_save; }, + popupUpdate: function() { return Messages.calendar_update; }, + popupEdit: function() { return Messages.poll_edit; }, + popupDelete: function() { return Messages.kanban_delete; }, + popupDetailLocation: function(schedule) { + // TODO detect url and create 'a' tag + return Messages._getKey('calendar_location', [Util.fixHTML(schedule.location)]); + }, + popupIsAllDay: function() { return Messages.calendar_allDay; }, + titlePlaceholder: function() { return Messages.calendar_title; }, + locationPlaceholder: function() { return Messages.calendar_loc; }, + alldayTitle: function() { + return ''+Messages.calendar_allDay+''; + }, + timegridDisplayTime: getTime, + timegridDisplayPrimaryTime: getTime, + popupDetailDate: function(isAllDay, start, end) { + var startDate = start._date.toLocaleDateString(); + var endDate = end._date.toLocaleDateString(); + if (isAllDay) { + if (startDate === endDate) { return startDate; } + return Messages._getKey('calendar_dateRange', [startDate, endDate]); + } + + var startTime = getTime({ + hour: start._date.getHours(), + minutes: start._date.getMinutes(), + }); + var endTime = getTime({ + hour: end._date.getHours(), + minutes: end._date.getMinutes(), + }); + + if (startDate === endDate && startTime === endTime) { + return start._date.toLocaleString(); + } + if (startDate === endDate) { + return Messages._getKey('calendar_dateTimeRange', [startDate, startTime, endTime]); + } + return Messages._getKey('calendar_dateRange', [start._date.toLocaleString(), end._date.toLocaleString()]); + } + }; + + // XXX Note: always create calendars in your own proxy. If you want a team calendar, you can share it with the team later. + var editCalendar = function (id) { + var isNew = !id; + var data = APP.calendars[id]; + if (id && !data) { return; } + var md = {}; + if (!isNew) { md = Util.find(data, ['content', 'metadata']); } + if (!md) { return; } + + // Create form data + var labelTitle = h('label', Messages.kanban_title); + var title = h('input'); + var $title = $(title); + $title.val(md.title || Messages.calendar_new); + var labelColor = h('label', Messages.kanban_color); + + var $colorPicker = $(h('div.cp-calendar-colorpicker')); + var jscolorL = new window.jscolor($colorPicker[0], { showOnClick: false, valueElement: undefined, zIndex: 100000 }); + $colorPicker.click(function() { + jscolorL.show(); + }); + if (md.color) { jscolorL.fromString(md.color); } + else { jscolorL.fromString(Util.getRandomColor()); } + + var form = h('div', [ + labelTitle, + title, + labelColor, + $colorPicker[0] + ]); + + var send = function (obj) { + if (isNew) { + return void newCalendar(obj, function (err) { + if (err) { console.error(err); return void UI.warn(Messages.error); } + UI.log(Messages.saved); + }); + } + obj.id = id; + updateCalendar(obj, function (err) { + if (err) { console.error(err); return void UI.warn(Messages.error); } + UI.log(Messages.saved); + }); + }; + var m = UI.dialog.customModal(form, { + buttons: [{ + className: 'cancel', + name: Messages.cancel, + onClick: function () {}, + keys: [27] + }, { + className: 'primary', + name: Messages.settings_save, + onClick: function () { + var color = jscolorL.toHEXString(); + var title = $title.val(); + var obj = { + color: color, + title: title + }; + if (!title || !title.trim() ||!/^#[0-9a-fA-F]{6}$/.test(color)) { + return true; + } + send(obj); + }, + keys: [13] + }] + }); + UI.openCustomModal(m); + }; + + var isReadOnly = function (id, teamId) { + var data = APP.calendars[id]; + return data.readOnly || (data.roTeams && data.roTeams.indexOf(teamId) !== -1); + }; + var makeEditDropdown = function (id, teamId) { + var options = []; + var privateData = metadataMgr.getPrivateData(); + var cantRemove = teamId === 0 || (teamId !== 1 && privateData.teams[teamId].viewer); + var data = APP.calendars[id]; + if (!data.readOnly) { + options.push({ + tag: 'a', + attributes: { + 'class': 'fa fa-pencil', + }, + content: h('span', Messages.tag_edit), + action: function (e) { + e.stopPropagation(); + editCalendar(id); + return true; + } + }); + } + if (data.teams.indexOf(1) === -1 || teamId === 0) { + options.push({ + tag: 'a', + attributes: { + 'class': 'fa fa-clone', + }, + content: h('span', Messages.calendar_import), + action: function (e) { + e.stopPropagation(); + importCalendar({ + id: id, + teamId: teamId + }, function (obj) { + if (obj && obj.error) { + console.error(obj.error); + return void UI.warn(obj.error); + } + }); + return true; + } + }); + } + if (!data.restricted) { + options.push({ + tag: 'a', + attributes: { + 'class': 'fa fa-shhare-alt', + }, + content: h('span', Messages.shareButton), + action: function (e) { + e.stopPropagation(); + var friends = common.getFriends(); + var cal = APP.calendars[id]; + var title = Util.find(cal, ['content', 'metadata', 'title']); + var color = Util.find(cal, ['content', 'metadata', 'color']); + Share.getShareModal(common, { + teamId: teamId === 1 ? undefined : teamId, + origin: APP.origin, + pathname: "/calendar/", + friends: friends, + title: title, + password: cal.password, // XXX support passwords + calendar: { + title: title, + color: color, + channel: id, + }, + common: common, + hashes: cal.hashes + }); + return true; + } + }); + options.push({ + tag: 'a', + attributes: { + 'class': 'fa fa-lock', + }, + content: h('span', Messages.accessButton), + action: function (e) { + e.stopPropagation(); + var cal = APP.calendars[id]; + var title = Util.find(cal, ['content', 'metadata', 'title']); + var color = Util.find(cal, ['content', 'metadata', 'color']); + var h = cal.hashes || {}; + var href = Hash.hashToHref(h.editHash || h.viewHash, 'calendar'); + Access.getAccessModal(common, { + title: title, + password: cal.password, // XXX support passwords + calendar: { + title: title, + color: color, + channel: id, + }, + common: common, + noExpiration: true, + noEditPassword: true, + channel: id, + href: href + }); + return true; + } + }); + options.push({ + tag: 'a', + attributes: { + 'class': 'fa fa-info-circle', + }, + content: h('span', Messages.propertiesButton), + action: function (e) { + e.stopPropagation(); + var cal = APP.calendars[id]; + var title = Util.find(cal, ['content', 'metadata', 'title']); + var color = Util.find(cal, ['content', 'metadata', 'color']); + var h = cal.hashes || {}; + var href = Hash.hashToHref(h.editHash || h.viewHash, 'calendar'); + Properties.getPropertiesModal(common, { + calendar: { + title: title, + color: color, + channel: id, + }, + common: common, + channel: id, + href: href + }); + return true; + } + }); + } + if (!cantRemove) { + options.push({ + tag: 'a', + attributes: { + 'class': 'fa fa-trash-o', + }, + content: h('span', Messages.kanban_delete), + action: function (e) { + e.stopPropagation(); + var cal = APP.calendars[id]; + var key = Messages.calendar_deleteConfirm; + var teams = (cal && cal.teams) || []; + if (teams.length === 1 && teams[0] !== 1) { + key = Messages.calendar_deleteTeamConfirm; + } + if (cal.owned) { + key += Messages.calendar_deleteOwned; + } + UI.confirm(Messages.calendar_deleteConfirm, function (yes) { + if (!yes) { return; } + deleteCalendar({ + id: id, + teamId: teamId, + }, function (err) { + if (err) { + console.error(err); + UI.warn(Messages.error); + } + }); + }); + } + }); + } + var dropdownConfig = { + text: '', + options: options, // Entries displayed in the menu + common: common, + buttonCls: 'btn btn-cancel fa fa-ellipsis-h small' + }; + return UIElements.createDropdown(dropdownConfig)[0]; + }; + var makeCalendarEntry = function (id, teamId) { + // XXX handle RESTRICTED calendars (data.restricted) + var data = APP.calendars[id]; + var edit; + if (data.loading) { + edit = h('i.fa.fa-spinner.fa-spin'); + } else { + edit = makeEditDropdown(id, teamId); + } + var md = Util.find(data, ['content', 'metadata']); + if (!md) { return; } + var active = data.hidden ? '' : '.cp-active'; + var restricted = data.restricted ? '.cp-restricted' : ''; + var calendar = h('div.cp-calendar-entry'+active+restricted, { + 'data-uid': id + }, [ + h('span.cp-calendar-color', { + style: 'background-color: '+md.color+';' + }), + h('span.cp-calendar-title', md.title), + data.restricted ? h('i.fa.fa-ban', {title: Messages.fm_restricted}) : + (isReadOnly(id, teamId) ? h('i.fa.fa-eye', {title: Messages.readonly}) : undefined), + edit + ]); + $(calendar).click(function () { + data.hidden = !data.hidden; + if (APP.$calendars) { + APP.$calendars.find('[data-uid="'+id+'"]').toggleClass('cp-active', !data.hidden); + } else { + $(calendar).toggleClass('cp-active', !data.hidden); + } + + renderCalendar(); + }); + if (APP.$calendars) { APP.$calendars.append(calendar); } + return calendar; + }; + var makeLeftside = function (calendar, $container) { + // Show calendars + var calendars = h('div.cp-calendar-list'); + var $calendars = APP.$calendars = $(calendars).appendTo($container); + onCalendarsUpdate.reg(function () { + $calendars.empty(); + var privateData = metadataMgr.getPrivateData(); + var filter = function (teamId) { + return Object.keys(APP.calendars || {}).filter(function (id) { + var cal = APP.calendars[id] || {}; + var teams = cal.teams || []; + return teams.indexOf(typeof(teamId) !== "undefined" ? teamId : 1) !== -1; + }); + }; + var tempCalendars = filter(0); + if (tempCalendars.length) { + APP.$calendars.append(h('div.cp-calendar-team', [ + h('span', Messages.calendar_tempCalendar) + ])); + makeCalendarEntry(tempCalendars[0], 0); + } + var myCalendars = filter(1); + if (myCalendars.length) { + APP.$calendars.append(h('div.cp-calendar-team', [ + h('span', Messages.calendar_myCalendars) + ])); + } + myCalendars.forEach(function (id) { + makeCalendarEntry(id, 1); + }); + Object.keys(privateData.teams).forEach(function (teamId) { + var calendars = filter(teamId); + if (!calendars.length) { return; } + var team = privateData.teams[teamId]; + var avatar = h('span.cp-avatar'); + common.displayAvatar($(avatar), team.avatar, team.displayName); + APP.$calendars.append(h('div.cp-calendar-team', [ + avatar, + h('span.cp-name', {title: team.name}, team.name) + ])); + calendars.forEach(function (id) { + makeCalendarEntry(id, teamId); + }); + }); + + // Add new button + var $newContainer = $(h('div.cp-calendar-entry.cp-ghost')).appendTo($calendars); + var newButton = h('button', [ + h('i.fa.fa-plus'), + h('span', Messages.calendar_new), + h('span') + ]); + $(newButton).click(function () { + editCalendar(); + }).appendTo($newContainer); + }); + onCalendarsUpdate.fire(); + + }; + var updateDateRange = function () { + var range = APP.calendar._renderRange; + var start = range.start._date.toLocaleDateString(); + var end = range.end._date.toLocaleDateString(); + var date = [ + h('b', start), + h('span', ' - '), + h('b', end), + ]; + if (APP.calendar._viewName === "day") { + date = h('b', start); + } else if (APP.calendar._viewName === "month") { + var month; + var mid = new Date(Math.floor(((+range.start._date) + (+range.end._date)) / 2)); + try { + month = mid.toLocaleString('default', { + month: 'long', + year:'numeric' + }); + month = month.replace(/^./, function (str) { return str.toUpperCase(); }); + date = h('b', month); + } catch (e) { + // Use same as week range: first day of month to last day of month + } + } + APP.toolbar.$bottomM.empty().append(h('div', date)); + }; + var makeCalendar = function (view) { + var store = window.cryptpadStore; + + var $container = $('#cp-sidebarlayout-container'); + var leftside; + $container.append([ + leftside = h('div#cp-sidebarlayout-leftside'), + h('div#cp-sidebarlayout-rightside') + ]); + + var cal = APP.calendar = new Calendar('#cp-sidebarlayout-rightside', { + defaultView: view || 'week', // weekly view option + taskView: false, + useCreationPopup: true, + useDetailPopup: true, + usageStatistics: false, + calendars: getCalendars(), + template: templates, + month: { + daynames: getWeekDays(), + startDayOfWeek: 1, + }, + week: { + daynames: getWeekDays(), + startDayOfWeek: 1, + } + }); + + makeLeftside(cal, $(leftside)); + + cal.on('beforeCreateSchedule', function(event) { + // XXX Recurrence (later) + // On creation, select a recurrence rule (daily / weekly / monthly / more weird rules) + // then mark it under recurrence rule with a uid (the same for all the recurring events) + // ie: recurrenceRule: DAILY|{uid} + // Use template to hide "recurrenceRule" from the detailPopup or at least to use + // a non technical value + + var startDate = event.start._date; + var endDate = event.end._date; + + var schedule = { + id: Util.uid(), + calendarId: event.calendarId, + title: Util.fixHTML(event.title), + category: "time", + location: Util.fixHTML(event.location), + start: +startDate, + isAllDay: event.isAllDay, + end: +endDate, + }; + + newEvent(schedule, function (err) { + if (err) { + console.error(err); + return void UI.warn(err); + } + cal.createSchedules([schedule]); + }); + }); + cal.on('beforeUpdateSchedule', function(event) { + var changes = event.changes || {}; + delete changes.state; + if (changes.end) { changes.end = +new Date(changes.end._date); } + if (changes.start) { changes.start = +new Date(changes.start._date); } + var old = event.schedule; + + updateEvent({ + ev: old, + changes: changes + }, function (err) { + if (err) { + console.error(err); + return void UI.warn(err); + } + cal.updateSchedule(old.id, old.calendarId, changes); + }); + }); + cal.on('beforeDeleteSchedule', function(event) { + var data = event.schedule; + deleteEvent(event.schedule, function (err) { + if (err) { + console.error(err); + return void UI.warn(err); + } + cal.deleteSchedule(data.id, data.calendarId); + }); + }); + + updateDateRange(); + + renderCalendar(); + + // Toolbar + + // Change view mode + var options = ['day', 'week', 'month'].map(function (k) { + return { + tag: 'a', + attributes: { + 'class': 'cp-calendar-view', + 'data-value': k, + 'href': '#', + }, + content: Messages['calendar_'+k] + // Messages.calendar_day + // Messages.calendar_week + // Messages.calendar_month + }; + }); + var dropdownConfig = { + text: Messages.calendar_week, + options: options, // Entries displayed in the menu + isSelect: true, + common: common, + caretDown: true, + left: true, + }; + var $block = UIElements.createDropdown(dropdownConfig); + $block.setValue(view || 'week'); + var $views = $block.find('a'); + $views.click(function () { + var mode = $(this).attr('data-value'); + cal.changeView(mode); + updateDateRange(); + store.put('calendarView', mode, function () {}); + }); + APP.toolbar.$bottomR.append($block); + + // New event button + var newEventBtn = h('button', [ + h('i.fa.fa-plus'), + h('span', Messages.calendar_newEvent) + ]); + $(newEventBtn).click(function (e) { + e.preventDefault(); + cal.openCreationPopup({isAllDay:false}); + }).appendTo(APP.toolbar.$bottomL); + + // Change page + var goLeft = h('button.fa.fa-chevron-left'); + var goRight = h('button.fa.fa-chevron-right'); + var goToday = h('button', Messages.calendar_today); + $(goLeft).click(function () { + cal.prev(); + updateDateRange(); + }); + $(goRight).click(function () { + cal.next(); + updateDateRange(); + }); + $(goToday).click(function () { + cal.today(); + updateDateRange(); + }); + APP.toolbar.$bottomL.append(h('div.cp-calendar-browse', [ + goLeft, goToday, goRight + ])); + + }; + + var createToolbar = function () { + var displayed = ['useradmin', 'newpad', 'limit', 'pageTitle', 'notifications']; + var configTb = { + displayed: displayed, + sfCommon: common, + $container: APP.$toolbar, + pageTitle: Messages.calendar, + metadataMgr: common.getMetadataMgr(), + }; + APP.toolbar = Toolbar.create(configTb); + APP.toolbar.$rightside.hide(); + }; + + var onEvent = function (obj) { + var ev = obj.ev; + var data = obj.data; + if (ev === 'UPDATE') { + onCalendarUpdate(data); + return; + } + }; + + nThen(function (waitFor) { + $(waitFor(UI.addLoadingScreen)); + SFCommon.create(waitFor(function (c) { APP.common = common = c; })); + }).nThen(function (waitFor) { + APP.$toolbar = $('#cp-toolbar'); + sframeChan = common.getSframeChannel(); + sframeChan.onReady(waitFor()); + }).nThen(function (/*waitFor*/) { + createToolbar(); + metadataMgr = common.getMetadataMgr(); + var privateData = metadataMgr.getPrivateData(); + var user = metadataMgr.getUserData(); + + // Fix flatpickr selection + var MutationObserver = window.MutationObserver; + var onFlatPickr = function (el) { + // Don't close event creation popup when clicking on flatpickr + $(el).mousedown(function (e) { + e.stopPropagation(); + }); + }; + var observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + var node; + for (var i = 0; i < mutation.addedNodes.length; i++) { + node = mutation.addedNodes[i]; + if (node.classList && node.classList.contains('flatpickr-calendar')) { + onFlatPickr(node); + } + } + }); + }); + observer.observe($('body')[0], { + childList: true, + subtree: false + }); + + // Customize creation/update popup + var onCalendarPopup = function (el) { + var $el = $(el); + $el.find('.tui-full-calendar-confirm').addClass('btn btn-primary').prepend(h('i.fa.fa-floppy-o')); + $el.find('input').attr('autocomplete', 'off'); + $el.find('.tui-full-calendar-dropdown-button').addClass('btn btn-secondary'); + $el.find('.tui-full-calendar-popup-close').addClass('btn btn-cancel fa fa-times cp-calendar-close').empty(); + + var calendars = APP.calendars || {}; + var show = false; + $el.find('.tui-full-calendar-dropdown-menu li').each(function (i, li) { + var $li = $(li); + var id = $li.attr('data-calendar-id'); + var c = calendars[id]; + if (!c || c.readOnly) { + return void $li.remove(); + } + // If at least one calendar is editable, show the popup + show = true; + }); + if ($el.find('.tui-full-calendar-hide.tui-full-calendar-dropdown').length || !show) { + $el.hide(); + UI.warn(Messages.calendar_errorNoCalendar); + return; + } + var isUpdate = Boolean($el.find('#tui-full-calendar-schedule-title').val()); + if (!isUpdate) { $el.find('.tui-full-calendar-dropdown-menu li').first().click(); } + + var $cbox = $el.find('#tui-full-calendar-schedule-allday'); + var $start = $el.find('.tui-full-calendar-section-start-date'); + var $dash = $el.find('.tui-full-calendar-section-date-dash'); + var $end = $el.find('.tui-full-calendar-section-end-date'); + var allDay = $cbox.is(':checked'); + if (allDay) { + $start.hide(); + $dash.hide(); + $end.hide(); + } + $el.find('.tui-full-calendar-section-allday').click(function () { + setTimeout(function () { + var allDay = $cbox.is(':checked'); + if (allDay) { + $start.hide(); + $dash.hide(); + $end.hide(); + return; + } + $start.show(); + $dash.show(); + $end.show(); + }); + }); + }; + var onCalendarEditPopup = function (el) { + var $el = $(el); + $el.find('.tui-full-calendar-popup-edit').addClass('btn btn-primary'); + $el.find('.tui-full-calendar-popup-edit .tui-full-calendar-icon').addClass('fa fa-pencil').removeClass('tui-full-calendar-icon'); + $el.find('.tui-full-calendar-popup-delete').addClass('btn btn-danger'); + $el.find('.tui-full-calendar-popup-delete .tui-full-calendar-icon').addClass('fa fa-trash').removeClass('tui-full-calendar-icon'); + $el.find('.tui-full-calendar-content').removeClass('tui-full-calendar-content'); + }; + var onPopupRemoved = function () { + var start, end; + if (window.CP_startPickr) { start = window.CP_startPickr.calendarContainer; } + if (window.CP_endPickr) { end = window.CP_endPickr.calendarContainer; } + $('.flatpickr-calendar').each(function (i, el) { + if (el === start || el === end) { return; } + $(el).remove(); + }); + }; + var observer2 = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + var node, _node; + for (var i = 0; i < mutation.addedNodes.length; i++) { + node = mutation.addedNodes[i]; + try { + if (node.classList && node.classList.contains('tui-full-calendar-popup') + && !node.classList.contains('tui-full-calendar-popup-detail')) { + onCalendarPopup(node); + } + if (node.classList && node.classList.contains('tui-full-calendar-popup') + && node.classList.contains('tui-full-calendar-popup-detail')) { + onCalendarEditPopup(node); + } + } catch (e) {} + } + for (var j = 0; j < mutation.removedNodes.length; j++) { + _node = mutation.addedNodes[j]; + try { + if (_node.classList && _node.classList.contains('tui-full-calendar-popup')) { + onPopupRemoved(); + } + } catch (e) {} + } + }); + }); + observer2.observe($('body')[0], { + childList: true, + subtree: true + }); + + APP.module = common.makeUniversal('calendar', { + onEvent: onEvent + }); + var store = window.cryptpadStore; + APP.module.execCommand('SUBSCRIBE', null, function (obj) { + if (obj.empty && !privateData.calendarHash) { + // No calendar yet, create one + newCalendar({ + teamId: 1, + initialCalendar: true, + color: user.color, + title: Messages.calendar_default + }, function (err) { + if (err) { return void UI.errorLoadingScreen(Messages.error); } // XXX + store.get('calendarView', makeCalendar); + UI.removeLoadingScreen(); + }); + return; + } + if (privateData.calendarHash) { + APP.module.execCommand('OPEN', { + hash: privateData.hashes.editHash || privateData.hashes.viewHash, + password: privateData.password + }, function (obj) { + if (obj && obj.error) { console.error(obj.error); } + }); + } + store.get('calendarView', makeCalendar); + UI.removeLoadingScreen(); + }); + + APP.origin = privateData.origin; + + + }); +}); diff --git a/www/calendar/main.js b/www/calendar/main.js new file mode 100644 index 000000000..11b1ba4bf --- /dev/null +++ b/www/calendar/main.js @@ -0,0 +1,24 @@ +// Load #1, load as little as possible because we are in a race to get the loading screen up. +define([ + '/bower_components/nthen/index.js', + '/api/config', + '/common/dom-ready.js', + '/common/sframe-common-outer.js', +], function (nThen, ApiConfig, DomReady, SFCommonO) { + + // Loaded in load #2 + nThen(function (waitFor) { + DomReady.onReady(waitFor()); + }).nThen(function (waitFor) { + SFCommonO.initIframe(waitFor); + }).nThen(function (/*waitFor*/) { + var addData = function (meta) { + meta.calendarHash = Boolean(window.location.hash); + }; + SFCommonO.start({ + addData: addData, + noRealtime: true, + cache: true, + }); + }); +}); diff --git a/www/checkup/main.js b/www/checkup/main.js index 5dcd1e8cf..7afa06abf 100644 --- a/www/checkup/main.js +++ b/www/checkup/main.js @@ -202,6 +202,35 @@ define([ }, _alert("Login block is not working (write/read/remove)")); + assert(function (cb) { + var url = '/common/onlyoffice/v4/web-apps/apps/spreadsheeteditor/main/index.html'; + var expect = { + 'cross-origin-resource-policy': 'cross-origin', + 'cross-origin-embedder-policy': 'require-corp', + }; + + $.ajax(url, { + complete: function (xhr) { + cb(!Object.keys(expect).some(function (k) { + var response = xhr.getResponseHeader(k); + console.log(k, response); + return response !== expect[k]; + })); + }, + }); + }, _alert("Missing HTTP headers required for XLSX export")); + + assert(function (cb) { + cb(true); + $.ajax('/api/broadcast', { + dataType: 'text', + complete: function (xhr) { + console.log(xhr); + cb(xhr.status === 200); + }, + }); + }, _alert("/api/broadcast is not available")); + var row = function (cells) { return h('tr', cells.map(function (cell) { return h('td', cell); diff --git a/www/common/application_config_internal.js b/www/common/application_config_internal.js index 0f91ea447..4cfe4e00a 100644 --- a/www/common/application_config_internal.js +++ b/www/common/application_config_internal.js @@ -12,7 +12,7 @@ define(function() { * You should never remove the drive from this list. */ config.availablePadTypes = ['drive', 'teams', 'pad', 'sheet', 'code', 'slide', 'poll', 'kanban', 'whiteboard', - /*'oodoc', 'ooslide',*/ 'file', /*'todo',*/ 'contacts']; + /*'doc', 'presentation',*/ 'file', /*'todo',*/ 'contacts' /*, 'calendar' */]; /* The registered only types are apps restricted to registered users. * You should never remove apps from this list unless you know what you're doing. The apps * listed here by default can't work without a user account. @@ -20,7 +20,7 @@ define(function() { * users and these users will be redirected to the login page if they still try to access * the app */ - config.registeredOnlyTypes = ['file', 'contacts', 'oodoc', 'ooslide', 'notifications', 'support']; + config.registeredOnlyTypes = ['file', 'contacts', 'notifications', 'support', 'calendar']; /* CryptPad is available is multiple languages, but only English and French are maintained * by the developers. The other languages may be outdated, and any missing string for a langauge @@ -43,7 +43,6 @@ define(function() { /* You can display a link to your own privacy policy in the static pages footer. * To do so, set the following value to the absolute URL of your privacy policy. */ - config.privacy = '/privacy.html'; // config.privacy = 'https://xwiki.com/en/company/PrivacyPolicy'; /* Cryptpad apps use a common API to display notifications to users @@ -115,8 +114,8 @@ define(function() { todo: 'cptools-todo', contacts: 'fa-address-book', kanban: 'cptools-kanban', - oodoc: 'fa-file-word-o', - ooslide: 'fa-file-powerpoint-o', + doc: 'fa-file-word-o', + presentation: 'fa-file-powerpoint-o', sheet: 'cptools-sheet', drive: 'fa-hdd-o', teams: 'fa-users', @@ -162,8 +161,6 @@ define(function() { // making it much faster to open new tabs. config.disableWorkers = false; - //config.surveyURL = ""; - // Teams are always loaded during the initial loading screen (for the first tab only if // SharedWorkers are available). Allowing users to be members of multiple teams can // make them have a very slow loading time. To avoid impacting the user experience diff --git a/www/common/common-constants.js b/www/common/common-constants.js index fdae2b5de..0a1c1d7f6 100644 --- a/www/common/common-constants.js +++ b/www/common/common-constants.js @@ -15,6 +15,6 @@ define(['/customize/application_config.js'], function (AppConfig) { MAX_TEAMS_SLOTS: AppConfig.maxTeamsSlots || 5, MAX_TEAMS_OWNED: AppConfig.maxOwnedTeams || 5, // Apps - criticalApps: ['profile', 'settings', 'debug', 'admin', 'support', 'notifications'] + criticalApps: ['profile', 'settings', 'debug', 'admin', 'support', 'notifications', 'calendar'] }; }); diff --git a/www/common/common-hash.js b/www/common/common-hash.js index afdecbf57..cf3ca76a4 100644 --- a/www/common/common-hash.js +++ b/www/common/common-hash.js @@ -644,6 +644,10 @@ Version 4: Data URL when not a realtime link yet (new pad or "static" app) '/' + curvePublic.replace(/\//g, '-') + '/'; }; + Hash.isValidChannel = function (channelId) { + return /^[a-zA-Z0-9]{32,48}$/.test(channelId); + }; + Hash.isValidHref = function (href) { // Non-empty href? if (!href) { return; } diff --git a/www/common/common-interface.js b/www/common/common-interface.js index efd505ad4..9711de3fa 100644 --- a/www/common/common-interface.js +++ b/www/common/common-interface.js @@ -739,6 +739,7 @@ define([ } }); }; + // TODO: make it such that the confirmButton's width does not change UI.confirmButton = function (originalBtn, config, _cb) { config = config || {}; var cb = Util.mkAsync(_cb); @@ -1257,10 +1258,13 @@ define([ Messages.dontShowAgain ]); + var footerSel = 'div.cp-corner-footer'; var popup = h('div.cp-corner-container', [ setHTML(h('div.cp-corner-text'), text), h('div.cp-corner-actions', actions), - setHTML(h('div.cp-corner-footer'), footer), + (typeof(footer) === 'string'? + setHTML(h(footerSel), footer): + h(footerSel, footer)), opts.dontShowAgain ? dontShowAgain : undefined ]); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index ecfd0eb25..602c52100 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -1,6 +1,7 @@ define([ 'jquery', '/api/config', + '/api/broadcast', '/common/common-util.js', '/common/common-hash.js', '/common/common-language.js', @@ -17,7 +18,7 @@ define([ '/common/visible.js', 'css!/customize/fonts/cptools/style.css', -], function ($, Config, Util, Hash, Language, UI, Constants, Feedback, h, Clipboard, +], function ($, Config, Broadcast, Util, Hash, Language, UI, Constants, Feedback, h, Clipboard, Messages, AppConfig, Pages, NThen, InviteInner, Visible) { var UIElements = {}; var urlArgs = Config.requireConf.urlArgs; @@ -369,7 +370,7 @@ define([ h('div.cp-teams-invite-block', [ h('span', Messages.team_inviteLinkSetPassword), h('a.cp-teams-help.fa.fa-question-circle', { - href: origin + 'https://docs.cryptpad.fr/en/user_guide/security.html#passwords-for-documents-and-folders', + href: origin + Pages.localizeDocsLink('https://docs.cryptpad.fr/en/user_guide/security.html#passwords-for-documents-and-folders'), target: "_blank", 'data-tippy-placement': "right" }) @@ -714,8 +715,14 @@ define([ callback(err); return void UI.warn(Messages.fm_forbidden); } - var cMsg = common.isLoggedIn() ? Messages.movedToTrash : Messages.deleted; - var msg = common.fixLinks($('
    ').html(cMsg)); + var msg; + if (common.isLoggedIn()) { + msg = Pages.setHTML(h('div'), Messages.movedToTrash); + $(msg).find('a').attr('href', '/drive/'); + common.fixLinks(msg); + } else { + msg = h('div', Messages.deleted); + } UI.alert(msg); callback(); return; @@ -1105,6 +1112,10 @@ define([ if (apps[type]) { href = "https://docs.cryptpad.fr/en/user_guide/apps/" + apps[type] + ".html"; } + if (type === 'drive') { + href = "https://docs.cryptpad.fr/en/user_guide/drive.html"; + } + href = Pages.localizeDocsLink(href); var content = setHTML(h('p'), Messages.help_genericMore); $(content).find('a').attr({ @@ -1332,7 +1343,7 @@ define([ // Button var $button = $('