aboutsummaryrefslogtreecommitdiffstats
path: root/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'core/src')
-rw-r--r--core/src/OC/admin.js24
-rw-r--r--core/src/OC/appconfig.js24
-rw-r--r--core/src/OC/apps.js22
-rw-r--r--core/src/OC/appsettings.js93
-rw-r--r--core/src/OC/appswebroots.js22
-rw-r--r--core/src/OC/backbone-webdav.js56
-rw-r--r--core/src/OC/backbone.js22
-rw-r--r--core/src/OC/capabilities.js28
-rw-r--r--core/src/OC/config.js22
-rw-r--r--core/src/OC/constants.js20
-rw-r--r--core/src/OC/contactsmenu.js492
-rw-r--r--core/src/OC/contactsmenu/contact.handlebars34
-rw-r--r--core/src/OC/contactsmenu/error.handlebars4
-rw-r--r--core/src/OC/contactsmenu/list.handlebars12
-rw-r--r--core/src/OC/contactsmenu/loading.handlebars4
-rw-r--r--core/src/OC/contactsmenu/menu.handlebars4
-rw-r--r--core/src/OC/currentuser.js22
-rw-r--r--core/src/OC/debug.js22
-rw-r--r--core/src/OC/dialogs.js1058
-rw-r--r--core/src/OC/eventsource.js40
-rw-r--r--core/src/OC/get_set.js29
-rw-r--r--core/src/OC/host.js39
-rw-r--r--core/src/OC/index.js124
-rw-r--r--core/src/OC/l10n-registry.js88
-rw-r--r--core/src/OC/l10n.js336
-rw-r--r--core/src/OC/legacy-loader.js73
-rw-r--r--core/src/OC/menu.js34
-rw-r--r--core/src/OC/msg.js42
-rw-r--r--core/src/OC/navigation.js26
-rw-r--r--core/src/OC/notification.js46
-rw-r--r--core/src/OC/password-confirmation.js115
-rw-r--r--core/src/OC/plugins.js45
-rw-r--r--core/src/OC/query-string.js28
-rw-r--r--core/src/OC/requesttoken.js55
-rw-r--r--core/src/OC/requesttoken.ts49
-rw-r--r--core/src/OC/routing.js23
-rw-r--r--core/src/OC/theme.js22
-rw-r--r--core/src/OC/util-history.js35
-rw-r--r--core/src/OC/util.js68
-rw-r--r--core/src/OC/webroot.js22
-rw-r--r--core/src/OC/xhr-error.js39
-rw-r--r--core/src/OCA/index.js32
-rw-r--r--core/src/OCA/search.js34
-rw-r--r--core/src/OCP/accessibility.js28
-rw-r--r--core/src/OCP/appconfig.js64
-rw-r--r--core/src/OCP/collaboration.js30
-rw-r--r--core/src/OCP/comments.js26
-rw-r--r--core/src/OCP/index.js21
-rw-r--r--core/src/OCP/loader.js31
-rw-r--r--core/src/OCP/toast.js35
-rw-r--r--core/src/OCP/whatsnew.js39
-rw-r--r--core/src/Polyfill/closest.js19
-rw-r--r--core/src/Polyfill/console.js32
-rw-r--r--core/src/Polyfill/index.js24
-rw-r--r--core/src/Polyfill/tooltip.js41
-rw-r--r--core/src/Polyfill/windows-phone.js29
-rw-r--r--core/src/Util/a11y.js21
-rw-r--r--core/src/Util/get-url-parameter.js27
-rw-r--r--core/src/ajax-cron.ts18
-rw-r--r--core/src/components/AccountMenu/AccountMenuEntry.vue117
-rw-r--r--core/src/components/AccountMenu/AccountMenuProfileEntry.vue100
-rw-r--r--core/src/components/AppMenu.vue161
-rw-r--r--core/src/components/AppMenuEntry.vue189
-rw-r--r--core/src/components/AppMenuIcon.vue67
-rw-r--r--core/src/components/ContactsMenu.js39
-rw-r--r--core/src/components/ContactsMenu/Contact.vue193
-rw-r--r--core/src/components/HeaderMenu.vue215
-rw-r--r--core/src/components/LegacyDialogPrompt.vue111
-rw-r--r--core/src/components/MainMenu.js109
-rw-r--r--core/src/components/Profile/PrimaryActionButton.vue64
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue36
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuEntry.vue51
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue90
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue36
-rw-r--r--core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue51
-rw-r--r--core/src/components/UnifiedSearch/CustomDateRangeModal.vue107
-rw-r--r--core/src/components/UnifiedSearch/LegacySearchResult.vue242
-rw-r--r--core/src/components/UnifiedSearch/SearchFilterChip.vue79
-rw-r--r--core/src/components/UnifiedSearch/SearchResult.vue226
-rw-r--r--core/src/components/UnifiedSearch/SearchResultPlaceholders.vue7
-rw-r--r--core/src/components/UnifiedSearch/SearchableList.vue157
-rw-r--r--core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue166
-rw-r--r--core/src/components/UnifiedSearch/UnifiedSearchModal.vue838
-rw-r--r--core/src/components/UserMenu.js61
-rw-r--r--core/src/components/login/LoginButton.vue69
-rw-r--r--core/src/components/login/LoginForm.cy.ts76
-rw-r--r--core/src/components/login/LoginForm.vue316
-rw-r--r--core/src/components/login/PasswordLessLoginForm.vue238
-rw-r--r--core/src/components/login/ResetPassword.vue185
-rw-r--r--core/src/components/login/UpdatePassword.vue51
-rw-r--r--core/src/components/setup/RecommendedApps.vue217
-rw-r--r--core/src/eventbus.d.ts14
-rw-r--r--core/src/files/client.js48
-rw-r--r--core/src/files/fileinfo.js30
-rw-r--r--core/src/files/iedavclient.js162
-rw-r--r--core/src/globals.js60
-rw-r--r--core/src/icons.js357
-rw-r--r--core/src/init.js173
-rw-r--r--core/src/install.js142
-rw-r--r--core/src/install.ts43
-rw-r--r--core/src/jquery/avatar.js32
-rw-r--r--core/src/jquery/contactsmenu.js33
-rw-r--r--core/src/jquery/css/jquery-ui-fixes.scss46
-rw-r--r--core/src/jquery/css/jquery.ocdialog.scss27
-rw-r--r--core/src/jquery/exists.js25
-rw-r--r--core/src/jquery/filterattr.js22
-rw-r--r--core/src/jquery/index.js42
-rw-r--r--core/src/jquery/ocdialog.js95
-rw-r--r--core/src/jquery/octemplate.js23
-rw-r--r--core/src/jquery/placeholder.js27
-rw-r--r--core/src/jquery/requesttoken.js26
-rw-r--r--core/src/jquery/selectrange.js29
-rw-r--r--core/src/jquery/showpassword.js50
-rw-r--r--core/src/jquery/ui-fixes.js5
-rw-r--r--core/src/legacy-unified-search.js38
-rw-r--r--core/src/logger.js25
-rw-r--r--core/src/login.js65
-rw-r--r--core/src/main.js61
-rw-r--r--core/src/maintenance.js20
-rw-r--r--core/src/mixins/Nextcloud.js26
-rw-r--r--core/src/mixins/auth.js19
-rw-r--r--core/src/public-page-menu.ts15
-rw-r--r--core/src/public-page-user-menu.ts15
-rw-r--r--core/src/public.ts26
-rw-r--r--core/src/recommendedapps.js31
-rw-r--r--core/src/services/BrowserStorageService.js11
-rw-r--r--core/src/services/BrowsersListService.js13
-rw-r--r--core/src/services/LegacyUnifiedSearchService.js76
-rw-r--r--core/src/services/UnifiedSearchService.js87
-rw-r--r--core/src/services/WebAuthnAuthenticationService.js37
-rw-r--r--core/src/services/WebAuthnAuthenticationService.ts42
-rw-r--r--core/src/session-heartbeat.js179
-rw-r--r--core/src/session-heartbeat.ts158
-rw-r--r--core/src/store/unified-search-external-filters.js17
-rw-r--r--core/src/systemtags/merged-systemtags.js6
-rw-r--r--core/src/systemtags/systemtagmodel.js100
-rw-r--r--core/src/systemtags/systemtags.js37
-rw-r--r--core/src/systemtags/systemtagscollection.js21
-rw-r--r--core/src/systemtags/systemtagsinputfield.js28
-rw-r--r--core/src/systemtags/systemtagsmappingcollection.js48
-rw-r--r--core/src/tests/.eslintrc.js13
-rw-r--r--core/src/tests/OC/requesttoken.spec.js73
-rw-r--r--core/src/tests/OC/requesttoken.spec.ts147
-rw-r--r--core/src/tests/OC/session-heartbeat.spec.ts123
-rw-r--r--core/src/tests/components/ContactsMenu/Contact.spec.js44
-rw-r--r--core/src/tests/setup.js36
-rw-r--r--core/src/tests/views/ContactsMenu.spec.js143
-rw-r--r--core/src/twofactor-request-token.ts25
-rw-r--r--core/src/types/navigation.d.ts30
-rw-r--r--core/src/unified-search.js58
-rw-r--r--core/src/unified-search.ts63
-rw-r--r--core/src/unsupported-browser-redirect.js16
-rw-r--r--core/src/unsupported-browser.js23
-rw-r--r--core/src/utils/ClipboardFallback.ts47
-rw-r--r--core/src/utils/RedirectUnsupportedBrowsers.js40
-rw-r--r--core/src/utils/xhr-request.js133
-rw-r--r--core/src/views/AccountMenu.vue247
-rw-r--r--core/src/views/ContactsMenu.vue233
-rw-r--r--core/src/views/LegacyUnifiedSearch.vue848
-rw-r--r--core/src/views/Login.vue294
-rw-r--r--core/src/views/PublicPageMenu.vue131
-rw-r--r--core/src/views/PublicPageUserMenu.vue138
-rw-r--r--core/src/views/Setup.cy.ts369
-rw-r--r--core/src/views/Setup.vue460
-rw-r--r--core/src/views/UnifiedSearch.vue812
-rw-r--r--core/src/views/UnsupportedBrowser.vue187
166 files changed, 9514 insertions, 6509 deletions
diff --git a/core/src/OC/admin.js b/core/src/OC/admin.js
index 008016645d9..d29e4cf676b 100644
--- a/core/src/OC/admin.js
+++ b/core/src/OC/admin.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const isAdmin = !!window._oc_isadmin
@@ -24,7 +8,7 @@ const isAdmin = !!window._oc_isadmin
/**
* Returns whether the current user is an administrator
*
- * @returns {bool} true if the user is an admin, false otherwise
+ * @return {boolean} true if the user is an admin, false otherwise
* @since 9.0.0
*/
export const isUserAdmin = () => isAdmin
diff --git a/core/src/OC/appconfig.js b/core/src/OC/appconfig.js
index 37fc3ca420d..350ffc3f21c 100644
--- a/core/src/OC/appconfig.js
+++ b/core/src/OC/appconfig.js
@@ -1,25 +1,11 @@
-/* eslint-disable */
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2014 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
- import { getValue, setValue, getApps, getKeys, deleteKey } from '../OCP/appconfig'
+/* eslint-disable */
+ import { getValue, setValue, getApps, getKeys, deleteKey } from '../OCP/appconfig.js'
export const appConfig = window.oc_appconfig || {}
diff --git a/core/src/OC/apps.js b/core/src/OC/apps.js
index c7b2c4f5e17..dec2b94bfbb 100644
--- a/core/src/OC/apps.js
+++ b/core/src/OC/apps.js
@@ -1,11 +1,7 @@
/**
- * ownCloud - core
- *
- * This file is licensed under the Affero General Public License version 3 or
- * later. See the COPYING file.
- *
- * @author Bernhard Posselt <dev@bernhard-posselt.com>
- * @copyright Bernhard Posselt 2014
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2014 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -21,7 +17,7 @@ const Apps = {
/**
* Shows the #app-sidebar and add .with-app-sidebar to subsequent siblings
*
- * @param {Object} [$el] sidebar element to show, defaults to $('#app-sidebar')
+ * @param {object} [$el] sidebar element to show, defaults to $('#app-sidebar')
*/
Apps.showAppSidebar = function($el) {
const $appSidebar = $el || $('#app-sidebar')
@@ -33,7 +29,7 @@ Apps.showAppSidebar = function($el) {
* Shows the #app-sidebar and removes .with-app-sidebar from subsequent
* siblings
*
- * @param {Object} [$el] sidebar element to hide, defaults to $('#app-sidebar')
+ * @param {object} [$el] sidebar element to hide, defaults to $('#app-sidebar')
*/
Apps.hideAppSidebar = function($el) {
const $appSidebar = $el || $('#app-sidebar')
@@ -68,20 +64,28 @@ export const registerAppsSlideToggle = () => {
const areaSelector = $(button).data('apps-slide-toggle')
const area = $(areaSelector)
+ /**
+ *
+ */
function hideArea() {
area.slideUp(OC.menuSpeed * 4, function() {
area.trigger(new $.Event('hide'))
})
area.removeClass('opened')
$(button).removeClass('opened')
+ $(button).attr('aria-expanded', 'false')
}
+ /**
+ *
+ */
function showArea() {
area.slideDown(OC.menuSpeed * 4, function() {
area.trigger(new $.Event('show'))
})
area.addClass('opened')
$(button).addClass('opened')
+ $(button).attr('aria-expanded', 'true')
const input = $(areaSelector + ' [autofocus]')
if (input.length === 1) {
input.focus()
diff --git a/core/src/OC/appsettings.js b/core/src/OC/appsettings.js
deleted file mode 100644
index 7665d93fb7c..00000000000
--- a/core/src/OC/appsettings.js
+++ /dev/null
@@ -1,93 +0,0 @@
-/* eslint-disable */
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-import $ from 'jquery'
-import { filePath } from './routing'
-
-/**
- * Opens a popup with the setting for an app.
- * @param {string} appid The ID of the app e.g. 'calendar', 'contacts' or 'files'.
- * @param {boolean|string} loadJS If true 'js/settings.js' is loaded. If it's a string
- * it will attempt to load a script by that name in the 'js' directory.
- * @param {boolean} [cache] If true the javascript file won't be forced refreshed. Defaults to true.
- * @param {string} [scriptName] The name of the PHP file to load. Defaults to 'settings.php' in
- * the root of the app directory hierarchy.
- *
- * @deprecated 17.0.0 this method is unused and will be removed with Nextcloud 18
- */
-export const appSettings = args => {
- console.warn('OC.appSettings is deprecated and will be removed with Nextcloud 18')
-
- if (typeof args === 'undefined' || typeof args.appid === 'undefined') {
- throw {
- name: 'MissingParameter',
- message: 'The parameter appid is missing'
- }
- }
- var props = { scriptName: 'settings.php', cache: true }
- $.extend(props, args)
- var settings = $('#appsettings')
- if (settings.length === 0) {
- throw {
- name: 'MissingDOMElement',
- message: 'There has be be an element with id "appsettings" for the popup to show.'
- }
- }
- var popup = $('#appsettings_popup')
- if (popup.length === 0) {
- $('body').prepend('<div class="popup hidden" id="appsettings_popup"></div>')
- popup = $('#appsettings_popup')
- popup.addClass(settings.hasClass('topright') ? 'topright' : 'bottomleft')
- }
- if (popup.is(':visible')) {
- popup.hide().remove()
- } else {
- const arrowclass = settings.hasClass('topright') ? 'up' : 'left'
- $.get(filePath(props.appid, '', props.scriptName), function(data) {
- popup.html(data).ready(function() {
- popup.prepend('<span class="arrow ' + arrowclass + '"></span><h2>' + t('core', 'Settings') + '</h2><a class="close"></a>').show()
- popup.find('.close').bind('click', function() {
- popup.remove()
- })
- if (typeof props.loadJS !== 'undefined') {
- var scriptname
- if (props.loadJS === true) {
- scriptname = 'settings.js'
- } else if (typeof props.loadJS === 'string') {
- scriptname = props.loadJS
- } else {
- throw {
- name: 'InvalidParameter',
- message: 'The "loadJS" parameter must be either boolean or a string.'
- }
- }
- if (props.cache) {
- $.ajaxSetup({ cache: true })
- }
- $.getScript(filePath(props.appid, 'js', scriptname))
- .fail(function(jqxhr, settings, e) {
- throw e
- })
- }
- }).show()
- }, 'html')
- }
-}
diff --git a/core/src/OC/appswebroots.js b/core/src/OC/appswebroots.js
index f4a82384fe6..debbd2084bf 100644
--- a/core/src/OC/appswebroots.js
+++ b/core/src/OC/appswebroots.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const appswebroots = (window._oc_appswebroots !== undefined) ? window._oc_appswebroots : false
diff --git a/core/src/OC/backbone-webdav.js b/core/src/OC/backbone-webdav.js
index 4175b386b21..318c50e8ee5 100644
--- a/core/src/OC/backbone-webdav.js
+++ b/core/src/OC/backbone-webdav.js
@@ -1,59 +1,9 @@
-/* eslint-disable */
-/*
- * Copyright (c) 2015
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
- */
-
/**
- * Webdav transport for Backbone.
- *
- * This makes it possible to use Webdav endpoints when
- * working with Backbone models and collections.
- *
- * Requires the davclient.js library.
- *
- * Usage example:
- *
- * var PersonModel = OC.Backbone.Model.extend({
- * // make it use the DAV transport
- * sync: OC.Backbone.davSync,
- *
- * // DAV properties mapping
- * davProperties: {
- * 'id': '{http://example.com/ns}id',
- * 'firstName': '{http://example.com/ns}first-name',
- * 'lastName': '{http://example.com/ns}last-name',
- * 'age': '{http://example.com/ns}age'
- * },
- *
- * // additional parsing, if needed
- * parse: function(props) {
- * // additional parsing (DAV property values are always strings)
- * props.age = parseInt(props.age, 10);
- * return props;
- * }
- * });
- *
- * var PersonCollection = OC.Backbone.Collection.extend({
- * // make it use the DAV transport
- * sync: OC.Backbone.davSync,
- *
- * // use person model
- * // note that davProperties will be inherited
- * model: PersonModel,
- *
- * // DAV collection URL
- * url: function() {
- * return OC.linkToRemote('dav') + '/person/';
- * },
- * });
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable */
import _ from 'underscore'
import { dav } from 'davclient.js'
diff --git a/core/src/OC/backbone.js b/core/src/OC/backbone.js
index 86e98ec1b41..08520e278f6 100644
--- a/core/src/OC/backbone.js
+++ b/core/src/OC/backbone.js
@@ -1,26 +1,10 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import VendorBackbone from 'backbone'
-import { davCall, davSync } from './backbone-webdav'
+import { davCall, davSync } from './backbone-webdav.js'
const Backbone = VendorBackbone.noConflict()
diff --git a/core/src/OC/capabilities.js b/core/src/OC/capabilities.js
index 6e51abce60e..10623229625 100644
--- a/core/src/OC/capabilities.js
+++ b/core/src/OC/capabilities.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities'
@@ -24,11 +8,11 @@ import { getCapabilities as realGetCapabilities } from '@nextcloud/capabilities'
/**
* Returns the capabilities
*
- * @returns {Array} capabilities
+ * @return {Array} capabilities
*
- * @since 14.0
+ * @since 14.0.0
*/
export const getCapabilities = () => {
- console.warn('OC.getCapabilities is deprecated and will be removed in Nextcloud 21. See @nextcloud/capabilities')
+ OC.debug && console.warn('OC.getCapabilities is deprecated and will be removed in Nextcloud 21. See @nextcloud/capabilities')
return realGetCapabilities()
}
diff --git a/core/src/OC/config.js b/core/src/OC/config.js
index d1a3211cf62..c47df61f6e6 100644
--- a/core/src/OC/config.js
+++ b/core/src/OC/config.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const config = window._oc_config || {}
diff --git a/core/src/OC/constants.js b/core/src/OC/constants.js
index 972848997ae..5298107e94d 100644
--- a/core/src/OC/constants.js
+++ b/core/src/OC/constants.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software = you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation either version 3 of the
- * License or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const coreApps = ['', 'admin', 'log', 'core/search', 'core', '3rdparty']
diff --git a/core/src/OC/contactsmenu.js b/core/src/OC/contactsmenu.js
deleted file mode 100644
index e986c46ec20..00000000000
--- a/core/src/OC/contactsmenu.js
+++ /dev/null
@@ -1,492 +0,0 @@
-/* eslint-disable */
-
-/**
- * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2017 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
-import $ from 'jquery'
-import { Collection, Model, View } from 'backbone'
-
-import OC from './index'
-
-/**
- * @class Contact
- */
-const Contact = Model.extend({
- defaults: {
- fullName: '',
- lastMessage: '',
- actions: [],
- hasOneAction: false,
- hasTwoActions: false,
- hasManyActions: false
- },
-
- /**
- * @returns {undefined}
- */
- initialize: function() {
- // Add needed property for easier template rendering
- if (this.get('actions').length === 0) {
- this.set('hasOneAction', true)
- } else if (this.get('actions').length === 1) {
- this.set('hasTwoActions', true)
- this.set('secondAction', this.get('actions')[0])
- } else {
- this.set('hasManyActions', true)
- }
- }
-})
-
-/**
- * @class ContactCollection
- * @private
- */
-const ContactCollection = Collection.extend({
- model: Contact
-})
-
-/**
- * @class ContactsListView
- * @private
- */
-const ContactsListView = View.extend({
-
- /** @type {ContactCollection} */
- _collection: undefined,
-
- /** @type {array} */
- _subViews: [],
-
- /**
- * @param {object} options
- * @returns {undefined}
- */
- initialize: function(options) {
- this._collection = options.collection
- },
-
- /**
- * @returns {self}
- */
- render: function() {
- var self = this
- self.$el.html('')
- self._subViews = []
-
- self._collection.forEach(function(contact) {
- var item = new ContactsListItemView({
- model: contact
- })
- item.render()
- self.$el.append(item.$el)
- item.on('toggle:actionmenu', self._onChildActionMenuToggle, self)
- self._subViews.push(item)
- })
-
- return self
- },
-
- /**
- * Event callback to propagate opening (another) entry's action menu
- *
- * @param {type} $src
- * @returns {undefined}
- */
- _onChildActionMenuToggle: function($src) {
- this._subViews.forEach(function(view) {
- view.trigger('parent:toggle:actionmenu', $src)
- })
- }
-})
-
-/**
- * @class ContactsListItemView
- * @private
- */
-const ContactsListItemView = View.extend({
-
- /** @type {string} */
- className: 'contact',
-
- /** @type {undefined|function} */
- _template: undefined,
-
- /** @type {Contact} */
- _model: undefined,
-
- /** @type {boolean} */
- _actionMenuShown: false,
-
- events: {
- 'click .icon-more': '_onToggleActionsMenu'
- },
-
- contactTemplate: require('./contactsmenu/contact.handlebars'),
-
- /**
- * @param {object} data
- * @returns {undefined}
- */
- template: function(data) {
- return this.contactTemplate(data)
- },
-
- /**
- * @param {object} options
- * @returns {undefined}
- */
- initialize: function(options) {
- this._model = options.model
- this.on('parent:toggle:actionmenu', this._onOtherActionMenuOpened, this)
- },
-
- /**
- * @returns {self}
- */
- render: function() {
- this.$el.html(this.template({
- contact: this._model.toJSON()
- }))
- this.delegateEvents()
-
- // Show placeholder if no avatar is available (avatar is rendered as img, not div)
- this.$('div.avatar').imageplaceholder(this._model.get('fullName'))
-
- // Show tooltip for top action
- this.$('.top-action').tooltip({ placement: 'left' })
- // Show tooltip for second action
- this.$('.second-action').tooltip({ placement: 'left' })
-
- return this
- },
-
- /**
- * Toggle the visibility of the action popover menu
- *
- * @private
- * @returns {undefined}
- */
- _onToggleActionsMenu: function() {
- this._actionMenuShown = !this._actionMenuShown
- if (this._actionMenuShown) {
- this.$('.menu').show()
- } else {
- this.$('.menu').hide()
- }
- this.trigger('toggle:actionmenu', this.$el)
- },
-
- /**
- * @private
- * @argument {jQuery} $src
- * @returns {undefined}
- */
- _onOtherActionMenuOpened: function($src) {
- if (this.$el.is($src)) {
- // Ignore
- return
- }
- this._actionMenuShown = false
- this.$('.menu').hide()
- }
-})
-
-/**
- * @class ContactsMenuView
- * @private
- */
-const ContactsMenuView = View.extend({
-
- /** @type {undefined|function} */
- _loadingTemplate: undefined,
-
- /** @type {undefined|function} */
- _errorTemplate: undefined,
-
- /** @type {undefined|function} */
- _contentTemplate: undefined,
-
- /** @type {undefined|function} */
- _contactsTemplate: undefined,
-
- /** @type {undefined|ContactCollection} */
- _contacts: undefined,
-
- /** @type {string} */
- _searchTerm: '',
-
- events: {
- 'input #contactsmenu-search': '_onSearch'
- },
-
- templates: {
- loading: require('./contactsmenu/loading.handlebars'),
- error: require('./contactsmenu/error.handlebars'),
- menu: require('./contactsmenu/menu.handlebars'),
- list: require('./contactsmenu/list.handlebars')
- },
-
- /**
- * @returns {undefined}
- */
- _onSearch: _.debounce(function(e) {
- var searchTerm = this.$('#contactsmenu-search').val()
- // IE11 triggers an 'input' event after the view has been rendered
- // resulting in an endless loading loop. To prevent this, we remember
- // the last search term to savely ignore some events
- // See https://github.com/nextcloud/server/issues/5281
- if (searchTerm !== this._searchTerm) {
- this.trigger('search', this.$('#contactsmenu-search').val())
- this._searchTerm = searchTerm
- }
- }, 700),
-
- /**
- * @param {object} data
- * @returns {string}
- */
- loadingTemplate: function(data) {
- return this.templates.loading(data)
- },
-
- /**
- * @param {object} data
- * @returns {string}
- */
- errorTemplate: function(data) {
- return this.templates.error(
- _.extend({
- couldNotLoadText: t('core', 'Could not load your contacts')
- }, data)
- )
- },
-
- /**
- * @param {object} data
- * @returns {string}
- */
- contentTemplate: function(data) {
- return this.templates.menu(
- _.extend({
- searchContactsText: t('core', 'Search contacts …')
- }, data)
- )
- },
-
- /**
- * @param {object} data
- * @returns {string}
- */
- contactsTemplate: function(data) {
- return this.templates.list(
- _.extend({
- noContactsFoundText: t('core', 'No contacts found'),
- showAllContactsText: t('core', 'Show all contacts …'),
- contactsAppMgmtText: t('core', 'Install the Contacts app')
- }, data)
- )
- },
-
- /**
- * @param {object} options
- * @returns {undefined}
- */
- initialize: function(options) {
- this.options = options
- },
-
- /**
- * @param {string} text
- * @returns {undefined}
- */
- showLoading: function(text) {
- this.render()
- this._contacts = undefined
- this.$('.content').html(this.loadingTemplate({
- loadingText: text
- }))
- },
-
- /**
- * @returns {undefined}
- */
- showError: function() {
- this.render()
- this._contacts = undefined
- this.$('.content').html(this.errorTemplate())
- },
-
- /**
- * @param {object} viewData
- * @param {string} searchTerm
- * @returns {undefined}
- */
- showContacts: function(viewData, searchTerm) {
- this._contacts = viewData.contacts
- this.render({
- contacts: viewData.contacts
- })
-
- var list = new ContactsListView({
- collection: viewData.contacts
- })
- list.render()
- this.$('.content').html(this.contactsTemplate({
- contacts: viewData.contacts,
- searchTerm: searchTerm,
- contactsAppEnabled: viewData.contactsAppEnabled,
- contactsAppURL: OC.generateUrl('/apps/contacts'),
- canInstallApp: OC.isUserAdmin(),
- contactsAppMgmtURL: OC.generateUrl('/settings/apps/social/contacts')
- }))
- this.$('#contactsmenu-contacts').html(list.$el)
- },
-
- /**
- * @param {object} data
- * @returns {self}
- */
- render: function(data) {
- var searchVal = this.$('#contactsmenu-search').val()
- this.$el.html(this.contentTemplate(data))
-
- // Focus search
- this.$('#contactsmenu-search').val(searchVal)
- this.$('#contactsmenu-search').focus()
- return this
- }
-
-})
-
-/**
- * @param {Object} options
- * @param {jQuery} options.el
- * @param {jQuery} options.trigger
- * @class ContactsMenu
- * @memberOf OC
- */
-const ContactsMenu = function(options) {
- this.initialize(options)
-}
-
-ContactsMenu.prototype = {
- /** @type {jQuery} */
- $el: undefined,
-
- /** @type {jQuery} */
- _$trigger: undefined,
-
- /** @type {ContactsMenuView} */
- _view: undefined,
-
- /** @type {Promise} */
- _contactsPromise: undefined,
-
- /**
- * @param {Object} options
- * @param {jQuery} options.el - the element to render the menu in
- * @param {jQuery} options.trigger - the element to click on to open the menu
- * @returns {undefined}
- */
- initialize: function(options) {
- this.$el = options.el
- this._$trigger = options.trigger
-
- this._view = new ContactsMenuView({
- el: this.$el
- })
- this._view.on('search', function(searchTerm) {
- this._loadContacts(searchTerm)
- }, this)
-
- OC.registerMenu(this._$trigger, this.$el, function() {
- this._toggleVisibility(true)
- }.bind(this), true)
- this.$el.on('beforeHide', function() {
- this._toggleVisibility(false)
- }.bind(this))
- },
-
- /**
- * @private
- * @param {boolean} show
- * @returns {Promise}
- */
- _toggleVisibility: function(show) {
- if (show) {
- return this._loadContacts()
- } else {
- this.$el.html('')
- return Promise.resolve()
- }
- },
-
- /**
- * @private
- * @param {string|undefined} searchTerm
- * @returns {Promise}
- */
- _getContacts: function(searchTerm) {
- var url = OC.generateUrl('/contactsmenu/contacts')
- return Promise.resolve($.ajax(url, {
- method: 'POST',
- data: {
- filter: searchTerm
- }
- }))
- },
-
- /**
- * @param {string|undefined} searchTerm
- * @returns {undefined}
- */
- _loadContacts: function(searchTerm) {
- var self = this
-
- if (!self._contactsPromise) {
- self._contactsPromise = self._getContacts(searchTerm)
- }
-
- if (_.isUndefined(searchTerm) || searchTerm === '') {
- self._view.showLoading(t('core', 'Loading your contacts …'))
- } else {
- self._view.showLoading(t('core', 'Looking for {term} …', {
- term: searchTerm
- }))
- }
- return self._contactsPromise.then(function(data) {
- // Convert contact entries to Backbone collection
- data.contacts = new ContactCollection(data.contacts)
-
- self._view.showContacts(data, searchTerm)
- }, function(e) {
- self._view.showError()
- console.error('There was an error loading your contacts', e)
- }).then(function() {
- // Delete promise, so that contacts are fetched again when the
- // menu is opened the next time.
- delete self._contactsPromise
- }).catch(console.error.bind(this))
- }
-}
-
-export default ContactsMenu
diff --git a/core/src/OC/contactsmenu/contact.handlebars b/core/src/OC/contactsmenu/contact.handlebars
deleted file mode 100644
index a30b11462c4..00000000000
--- a/core/src/OC/contactsmenu/contact.handlebars
+++ /dev/null
@@ -1,34 +0,0 @@
-{{#if contact.avatar}}
-<img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="">
-{{else}}
-<div class="avatar"></div>
-{{/if}}
-<div class="body">
- <div class="full-name">{{contact.fullName}}</div>
- <div class="last-message">{{contact.lastMessage}}</div>
-</div>
-{{#if contact.topAction}}
-<a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}">
- <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}">
-</a>
-{{/if}}
-{{#if contact.hasTwoActions}}
-<a class="second-action" href="{{contact.secondAction.hyperlink}}" title="{{contact.secondAction.title}}">
- <img src="{{contact.secondAction.icon}}" alt="{{contact.secondAction.title}}">
-</a>
-{{/if}}
-{{#if contact.hasManyActions}}
- <span class="other-actions icon-more"></span>
- <div class="menu popovermenu">
- <ul>
- {{#each contact.actions}}
- <li>
- <a href="{{hyperlink}}">
- <img src="{{icon}}" alt="">
- <span>{{title}}</span>
- </a>
- </li>
- {{/each}}
- </ul>
- </div>
-{{/if}}
diff --git a/core/src/OC/contactsmenu/error.handlebars b/core/src/OC/contactsmenu/error.handlebars
deleted file mode 100644
index 5115595b4e1..00000000000
--- a/core/src/OC/contactsmenu/error.handlebars
+++ /dev/null
@@ -1,4 +0,0 @@
-<div class="emptycontent">
- <div class="icon-search"></div>
- <h2>{{couldNotLoadText}}</h2>
-</div>
diff --git a/core/src/OC/contactsmenu/list.handlebars b/core/src/OC/contactsmenu/list.handlebars
deleted file mode 100644
index 0bcff7d1a85..00000000000
--- a/core/src/OC/contactsmenu/list.handlebars
+++ /dev/null
@@ -1,12 +0,0 @@
-{{#unless contacts.length}}
-<div class="emptycontent">
- <div class="icon-search"></div>
- <h2>{{noContactsFoundText}}</h2>
-</div>
-{{/unless}}
-<div id="contactsmenu-contacts"></div>
-{{#if contactsAppEnabled}}
-<div class="footer"><a href="{{contactsAppURL}}">{{showAllContactsText}}</a></div>
-{{else if canInstallApp}}
-<div class="footer"><a href="{{contactsAppMgmtURL}}">{{contactsAppMgmtText}}</a></div>
-{{/if}}
diff --git a/core/src/OC/contactsmenu/loading.handlebars b/core/src/OC/contactsmenu/loading.handlebars
deleted file mode 100644
index 7fb22a6ed8e..00000000000
--- a/core/src/OC/contactsmenu/loading.handlebars
+++ /dev/null
@@ -1,4 +0,0 @@
-<div class="emptycontent">
- <div class="icon-loading"></div>
- <h2>{{loadingText}}</h2>
-</div>
diff --git a/core/src/OC/contactsmenu/menu.handlebars b/core/src/OC/contactsmenu/menu.handlebars
deleted file mode 100644
index 7d7697e780c..00000000000
--- a/core/src/OC/contactsmenu/menu.handlebars
+++ /dev/null
@@ -1,4 +0,0 @@
-<label class="hidden-visually" for="contactsmenu-search">{{searchContactsText}}</label>
-<input id="contactsmenu-search" type="search" placeholder="{{searchContactsText}}" value="{{searchTerm}}">
-<div class="content">
-</div>
diff --git a/core/src/OC/currentuser.js b/core/src/OC/currentuser.js
index 061abba89d6..a022698eab0 100644
--- a/core/src/OC/currentuser.js
+++ b/core/src/OC/currentuser.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const rawUid = document
diff --git a/core/src/OC/debug.js b/core/src/OC/debug.js
index 15a66c44aed..52a9ef28145 100644
--- a/core/src/OC/debug.js
+++ b/core/src/OC/debug.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
const base = window._oc_debug
diff --git a/core/src/OC/dialogs.js b/core/src/OC/dialogs.js
index 38b7b42751b..5c6934e67a2 100644
--- a/core/src/OC/dialogs.js
+++ b/core/src/OC/dialogs.js
@@ -1,56 +1,51 @@
-/* eslint-disable */
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- * @copyright Copyright (c) 2019 Gary Kim <gary@garykim.dev>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Gary Kim <gary@garykim.dev>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2015 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable */
import _ from 'underscore'
import $ from 'jquery'
-import OC from './index'
-import OCA from '../OCA/index'
+import IconMove from '@mdi/svg/svg/folder-move.svg?raw'
+import IconCopy from '@mdi/svg/svg/folder-multiple-outline.svg?raw'
+
+import OC from './index.js'
+import { DialogBuilder, FilePickerType, getFilePickerBuilder, spawnDialog } from '@nextcloud/dialogs'
+import { translate as t } from '@nextcloud/l10n'
+import { basename } from 'path'
+import { defineAsyncComponent } from 'vue'
/**
* this class to ease the usage of jquery dialogs
*/
const Dialogs = {
// dialog button types
+ /** @deprecated use `@nextcloud/dialogs` */
YES_NO_BUTTONS: 70,
+ /** @deprecated use `@nextcloud/dialogs` */
OK_BUTTONS: 71,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_CHOOSE: 1,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_MOVE: 2,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_COPY: 3,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_COPY_MOVE: 4,
+ /** @deprecated use FilePickerType from `@nextcloud/dialogs` */
FILEPICKER_TYPE_CUSTOM: 5,
- // used to name each dialog
- dialogsCounter: 0,
-
/**
* displays alert dialog
* @param {string} text content of dialog
* @param {string} title dialog title
* @param {function} callback which will be triggered when user presses OK
* @param {boolean} [modal] make the dialog modal
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
alert: function(text, title, callback, modal) {
this.message(
@@ -62,12 +57,15 @@ const Dialogs = {
modal
)
},
+
/**
* displays info dialog
* @param {string} text content of dialog
* @param {string} title dialog title
* @param {function} callback which will be triggered when user presses OK
* @param {boolean} [modal] make the dialog modal
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
info: function(text, title, callback, modal) {
this.message(text, title, 'info', Dialogs.OK_BUTTON, callback, modal)
@@ -80,6 +78,8 @@ const Dialogs = {
* @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively)
* @param {boolean} [modal] make the dialog modal
* @returns {Promise}
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
confirm: function(text, title, callback, modal) {
return this.message(
@@ -95,20 +95,38 @@ const Dialogs = {
* displays confirmation dialog
* @param {string} text content of dialog
* @param {string} title dialog title
- * @param {{type: Int, confirm: String, cancel: String, confirmClasses: String}} buttons text content of buttons
+ * @param {(number|{type: number, confirm: string, cancel: string, confirmClasses: string})} buttons text content of buttons
* @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively)
* @param {boolean} [modal] make the dialog modal
* @returns {Promise}
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
- confirmDestructive: function(text, title, buttons, callback, modal) {
- return this.message(
- text,
- title,
- 'none',
- buttons,
- callback,
- modal === undefined ? true : modal
- )
+ confirmDestructive: function(text, title, buttons = Dialogs.OK_BUTTONS, callback = () => {}, modal) {
+ return (new DialogBuilder())
+ .setName(title)
+ .setText(text)
+ .setButtons(
+ buttons === Dialogs.OK_BUTTONS
+ ? [
+ {
+ label: t('core', 'Yes'),
+ type: 'error',
+ callback: () => {
+ callback.clicked = true
+ callback(true)
+ },
+ }
+ ]
+ : Dialogs._getLegacyButtons(buttons, callback)
+ )
+ .build()
+ .show()
+ .then(() => {
+ if (!callback.clicked) {
+ callback(false)
+ }
+ })
},
/**
* displays confirmation dialog
@@ -117,17 +135,35 @@ const Dialogs = {
* @param {function} callback which will be triggered when user presses OK (true or false would be passed to callback respectively)
* @param {boolean} [modal] make the dialog modal
* @returns {Promise}
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
*/
confirmHtml: function(text, title, callback, modal) {
- return this.message(
- text,
- title,
- 'notice',
- Dialogs.YES_NO_BUTTONS,
- callback,
- modal,
- true
- )
+ return (new DialogBuilder())
+ .setName(title)
+ .setText('')
+ .setButtons([
+ {
+ label: t('core', 'No'),
+ callback: () => {},
+ },
+ {
+ label: t('core', 'Yes'),
+ type: 'primary',
+ callback: () => {
+ callback.clicked = true
+ callback(true)
+ },
+ },
+ ])
+ .build()
+ .setHTML(text)
+ .show()
+ .then(() => {
+ if (!callback.clicked) {
+ callback(false)
+ }
+ })
},
/**
* displays prompt dialog
@@ -138,73 +174,32 @@ const Dialogs = {
* @param {string} name name of the input field
* @param {boolean} password whether the input should be a password input
* @returns {Promise}
+ *
+ * @deprecated Use NcDialog from `@nextcloud/vue` instead
*/
prompt: function(text, title, callback, modal, name, password) {
- return $.when(this._getMessageTemplate()).then(function($tmpl) {
- var dialogName = 'oc-dialog-' + Dialogs.dialogsCounter + '-content'
- var dialogId = '#' + dialogName
- var $dlg = $tmpl.octemplate({
- dialog_name: dialogName,
- title: title,
- message: text,
- type: 'notice'
- })
- var input = $('<input/>')
- input.attr('type', password ? 'password' : 'text').attr('id', dialogName + '-input').attr('placeholder', name)
- var label = $('<label/>').attr('for', dialogName + '-input').text(name + ': ')
- $dlg.append(label)
- $dlg.append(input)
- if (modal === undefined) {
- modal = false
- }
- $('body').append($dlg)
-
- // wrap callback in _.once():
- // only call callback once and not twice (button handler and close
- // event) but call it for the close event, if ESC or the x is hit
- if (callback !== undefined) {
- callback = _.once(callback)
- }
-
- var buttonlist = [{
- text: t('core', 'No'),
- click: function() {
- if (callback !== undefined) {
- // eslint-disable-next-line standard/no-callback-literal
- callback(false, input.val())
- }
- $(dialogId).ocdialog('close')
- }
- }, {
- text: t('core', 'Yes'),
- click: function() {
- if (callback !== undefined) {
- // eslint-disable-next-line standard/no-callback-literal
- callback(true, input.val())
- }
- $(dialogId).ocdialog('close')
+ return new Promise((resolve) => {
+ spawnDialog(
+ defineAsyncComponent(() => import('../components/LegacyDialogPrompt.vue')),
+ {
+ text,
+ name: title,
+ callback,
+ inputName: name,
+ isPassword: !!password
},
- defaultButton: true
- }]
-
- $(dialogId).ocdialog({
- closeOnEscape: true,
- modal: modal,
- buttons: buttonlist,
- close: function() {
- // callback is already fired if Yes/No is clicked directly
- if (callback !== undefined) {
- // eslint-disable-next-line standard/no-callback-literal
- callback(false, input.val())
- }
- }
- })
- input.focus()
- Dialogs.dialogsCounter++
+ (...args) => {
+ callback(...args)
+ resolve()
+ },
+ )
})
},
+
/**
- * show a file picker to pick a file from
+ * Legacy wrapper to the new Vue based filepicker from `@nextcloud/dialogs`
+ *
+ * Prefer to use the Vue filepicker directly instead.
*
* In order to pick several types of mime types they need to be passed as an
* array of strings.
@@ -214,433 +209,196 @@ const Dialogs = {
* should be used instead.
*
* @param {string} title dialog title
- * @param {function} callback which will be triggered when user presses Choose
+ * @param {Function} callback which will be triggered when user presses Choose
* @param {boolean} [multiselect] whether it should be possible to select multiple files
- * @param {string[]} [mimetypeFilter] mimetype to filter by - directories will always be included
- * @param {boolean} [modal] make the dialog modal
+ * @param {string[]} [mimetype] mimetype to filter by - directories will always be included
+ * @param {boolean} [_modal] do not use
* @param {string} [type] Type of file picker : Choose, copy, move, copy and move
* @param {string} [path] path to the folder that the the file can be picket from
- * @param {Object} [options] additonal options that need to be set
+ * @param {object} [options] additonal options that need to be set
+ * @param {Function} [options.filter] filter function for advanced filtering
+ * @param {boolean} [options.allowDirectoryChooser] Allow to select directories
+ * @deprecated since 27.1.0 use the filepicker from `@nextcloud/dialogs` instead
*/
- filepicker: function(title, callback, multiselect, mimetypeFilter, modal, type, path, options) {
- var self = this
-
- this.filepicker.sortField = 'name'
- this.filepicker.sortOrder = 'asc'
- // avoid opening the picker twice
- if (this.filepicker.loading) {
- return
- }
-
- if (type === undefined) {
- type = this.FILEPICKER_TYPE_CHOOSE
- }
-
- var emptyText = t('core', 'No files in here')
- var newText = t('files', 'New folder')
- if (type === this.FILEPICKER_TYPE_COPY || type === this.FILEPICKER_TYPE_MOVE || type === this.FILEPICKER_TYPE_COPY_MOVE) {
- emptyText = t('core', 'No more subfolders in here')
- }
-
- this.filepicker.loading = true
- this.filepicker.filesClient = (OCA.Sharing && OCA.Sharing.PublicApp && OCA.Sharing.PublicApp.fileList) ? OCA.Sharing.PublicApp.fileList.filesClient : OC.Files.getClient()
+ filepicker(title, callback, multiselect = false, mimetype = undefined, _modal = undefined, type = FilePickerType.Choose, path = undefined, options = undefined) {
- this.filelist = null
- path = path || ''
- options = Object.assign({
- allowDirectoryChooser: false
- }, options)
-
- $.when(this._getFilePickerTemplate()).then(function($tmpl) {
- self.filepicker.loading = false
- var dialogName = 'oc-dialog-filepicker-content'
- if (self.$filePicker) {
- self.$filePicker.ocdialog('close')
- }
-
- if (mimetypeFilter === undefined || mimetypeFilter === null) {
- mimetypeFilter = []
- }
- if (typeof (mimetypeFilter) === 'string') {
- mimetypeFilter = [mimetypeFilter]
- }
-
- self.$filePicker = $tmpl.octemplate({
- dialog_name: dialogName,
- title: title,
- emptytext: emptyText,
- newtext: newText,
- nameCol: t('core', 'Name'),
- sizeCol: t('core', 'Size'),
- modifiedCol: t('core', 'Modified')
- }).data('path', path).data('multiselect', multiselect).data('mimetype', mimetypeFilter).data('allowDirectoryChooser', options.allowDirectoryChooser)
-
- if (modal === undefined) {
- modal = false
- }
- if (multiselect === undefined) {
- multiselect = false
+ /**
+ * Create legacy callback wrapper to support old filepicker syntax
+ * @param fn The original callback
+ * @param type The file picker type which was used to pick the file(s)
+ */
+ const legacyCallback = (fn, type) => {
+ const getPath = (node) => {
+ const root = node?.root || ''
+ let path = node?.path || ''
+ // TODO: Fix this in @nextcloud/files
+ if (path.startsWith(root)) {
+ path = path.slice(root.length) || '/'
+ }
+ return path
}
- // No grid for IE!
- if (OC.Util.isIE()) {
- self.$filePicker.find('#picker-view-toggle').remove()
- self.$filePicker.find('#picker-filestable').removeClass('view-grid')
+ if (multiselect) {
+ return (nodes) => fn(nodes.map(getPath), type)
+ } else {
+ return (nodes) => fn(getPath(nodes[0]), type)
}
+ }
- $('body').append(self.$filePicker)
-
- self.$showGridView = $('input#picker-showgridview')
- self.$showGridView.on('change', _.bind(self._onGridviewChange, self))
-
- if (!OC.Util.isIE()) {
- self._getGridSettings()
- }
+ /**
+ * Coverting a Node into a legacy file info to support the OC.dialogs.filepicker filter function
+ * @param node The node to convert
+ */
+ const nodeToLegacyFile = (node) => ({
+ id: node.fileid || null,
+ path: node.path,
+ mimetype: node.mime || null,
+ mtime: node.mtime?.getTime() || null,
+ permissions: node.permissions,
+ name: node.attributes?.displayName || node.basename,
+ etag: node.attributes?.etag || null,
+ hasPreview: node.attributes?.hasPreview || null,
+ mountType: node.attributes?.mountType || null,
+ quotaAvailableBytes: node.attributes?.quotaAvailableBytes || null,
+ icon: null,
+ sharePermissions: null,
+ })
- var newButton = self.$filePicker.find('.actions.creatable .button-add')
- if (type === self.FILEPICKER_TYPE_CHOOSE && !options.allowDirectoryChooser) {
- newButton.hide()
- }
- newButton.on('focus', function() {
- self.$filePicker.ocdialog('setEnterCallback', function() {
- event.stopImmediatePropagation()
- event.preventDefault()
- newButton.click()
- })
- })
- newButton.on('blur', function() {
- self.$filePicker.ocdialog('unsetEnterCallback')
- })
+ const builder = getFilePickerBuilder(title)
- OC.registerMenu(newButton, self.$filePicker.find('.menu'), function() {
- $input.focus()
- self.$filePicker.ocdialog('setEnterCallback', function() {
- event.stopImmediatePropagation()
- event.preventDefault()
- self.$filePicker.submit()
+ // Setup buttons
+ if (type === this.FILEPICKER_TYPE_CUSTOM) {
+ (options.buttons || []).forEach((button) => {
+ builder.addButton({
+ callback: legacyCallback(callback, button.type),
+ label: button.text,
+ type: button.defaultButton ? 'primary' : 'secondary',
})
- var newName = $input.val()
- var lastPos = newName.lastIndexOf('.')
- if (lastPos === -1) {
- lastPos = newName.length
- }
- $input.selectRange(0, lastPos)
- })
- var $form = self.$filePicker.find('.filenameform')
- var $input = $form.find('input[type=\'text\']')
- var $submit = $form.find('input[type=\'submit\']')
- $submit.on('click', function(event) {
- event.stopImmediatePropagation()
- event.preventDefault()
- $form.submit()
})
-
-
- /**
- * Checks whether the given file name is valid.
- *
- * @param name file name to check
- * @return true if the file name is valid.
- * @throws a string exception with an error message if
- * the file name is not valid
- *
- * NOTE: This function is duplicated in the files app:
- * https://github.com/nextcloud/server/blob/b9bc2417e7a8dc81feb0abe20359bedaf864f790/apps/files/js/files.js#L127-L148
- */
- var isFileNameValid = function (name) {
- var trimmedName = name.trim();
- if (trimmedName === '.' || trimmedName === '..')
- {
- throw t('files', '"{name}" is an invalid file name.', {name: name})
- } else if (trimmedName.length === 0) {
- throw t('files', 'File name cannot be empty.')
- } else if (trimmedName.indexOf('/') !== -1) {
- throw t('files', '"/" is not allowed inside a file name.')
- } else if (!!(trimmedName.match(OC.config.blacklist_files_regex))) {
- throw t('files', '"{name}" is not an allowed filetype', {name: name})
- }
-
- return true
- }
-
- var checkInput = function() {
- var filename = $input.val()
- try {
- if (!isFileNameValid(filename)) {
- // isFileNameValid(filename) throws an exception itself
- } else if (self.filelist.find(function(file) {
- return file.name === this
- }, filename)) {
- throw t('files', '{newName} already exists', { newName: filename }, undefined, {
- escape: false
- })
- } else {
- return true
- }
- } catch (error) {
- $input.attr('title', error)
- $input.tooltip({
- placement: 'right',
- trigger: 'manual',
- 'container': '.newFolderMenu'
+ } else {
+ builder.setButtonFactory((nodes, path) => {
+ const buttons = []
+ const [node] = nodes
+ const target = node?.displayname || node?.basename || basename(path)
+
+ if (type === FilePickerType.Choose) {
+ buttons.push({
+ callback: legacyCallback(callback, FilePickerType.Choose),
+ label: node && !this.multiSelect ? t('core', 'Choose {file}', { file: target }) : t('core', 'Choose'),
+ type: 'primary',
})
- $input.tooltip('fixTitle')
- $input.tooltip('show')
- $input.addClass('error')
}
- return false
- }
-
- $form.on('submit', function(event) {
- event.stopPropagation()
- event.preventDefault()
-
- if (checkInput()) {
- var newname = $input.val()
- self.filepicker.filesClient.createDirectory(self.$filePicker.data('path') + "/" + newname).always(function (status) {
- self._fillFilePicker(self.$filePicker.data('path') + "/" + newname)
+ if (type === FilePickerType.CopyMove || type === FilePickerType.Copy) {
+ buttons.push({
+ callback: legacyCallback(callback, FilePickerType.Copy),
+ label: target ? t('core', 'Copy to {target}', { target }) : t('core', 'Copy'),
+ type: 'primary',
+ icon: IconCopy,
})
- OC.hideMenus()
- self.$filePicker.ocdialog('unsetEnterCallback')
- self.$filePicker.click()
- $input.val(newText)
}
- })
- $input.keypress(function(event) {
- if (event.keyCode === 13 || event.which === 13) {
- event.stopImmediatePropagation()
- event.preventDefault()
- $form.submit()
+ if (type === FilePickerType.Move || type === FilePickerType.CopyMove) {
+ buttons.push({
+ callback: legacyCallback(callback, FilePickerType.Move),
+ label: target ? t('core', 'Move to {target}', { target }) : t('core', 'Move'),
+ type: type === FilePickerType.Move ? 'primary' : 'secondary',
+ icon: IconMove,
+ })
}
+ return buttons
})
+ }
- self.$filePicker.ready(function() {
- self.$fileListHeader = self.$filePicker.find('.filelist thead tr')
- self.$filelist = self.$filePicker.find('.filelist tbody')
- self.$filelistContainer = self.$filePicker.find('.filelist-container')
- self.$dirTree = self.$filePicker.find('.dirtree')
- self.$dirTree.on('click', 'div:not(:last-child)', self, function(event) {
- self._handleTreeListSelect(event, type)
- })
- self.$filelist.on('click', 'tr', function(event) {
- self._handlePickerClick(event, $(this), type)
- })
- self.$fileListHeader.on('click', 'a', function(event) {
- var dir = self.$filePicker.data('path')
- self.filepicker.sortField = $(event.currentTarget).data('sort')
- self.filepicker.sortOrder = self.filepicker.sortOrder === 'asc' ? 'desc' : 'asc'
- self._fillFilePicker(dir)
- })
- self._fillFilePicker(path)
- })
-
- // build buttons
- var functionToCall = function(returnType) {
- if (callback !== undefined) {
- var datapath
- if (multiselect === true) {
- datapath = []
- self.$filelist.find('tr.filepicker_element_selected').each(function(index, element) {
- datapath.push(self.$filePicker.data('path') + '/' + $(element).data('entryname'))
- })
- } else {
- datapath = self.$filePicker.data('path')
- var selectedName = self.$filelist.find('tr.filepicker_element_selected').data('entryname')
- if (selectedName) {
- datapath += '/' + selectedName
- }
- }
- callback(datapath, returnType)
- self.$filePicker.ocdialog('close')
- }
- }
-
- var chooseCallback = function() {
- functionToCall(Dialogs.FILEPICKER_TYPE_CHOOSE)
- }
-
- var copyCallback = function() {
- functionToCall(Dialogs.FILEPICKER_TYPE_COPY)
- }
-
- var moveCallback = function() {
- functionToCall(Dialogs.FILEPICKER_TYPE_MOVE)
- }
+ if (mimetype) {
+ builder.setMimeTypeFilter(typeof mimetype === 'string' ? [mimetype] : (mimetype || []))
+ }
+ if (typeof options?.filter === 'function') {
+ builder.setFilter((node) => options.filter(nodeToLegacyFile(node)))
+ }
+ builder.allowDirectories(options?.allowDirectoryChooser === true || mimetype?.includes('httpd/unix-directory') || false)
+ .setMultiSelect(multiselect)
+ .startAt(path)
+ .build()
+ .pick()
+ },
- var buttonlist = []
- if (type === Dialogs.FILEPICKER_TYPE_CHOOSE) {
- buttonlist.push({
- text: t('core', 'Choose'),
- click: chooseCallback,
- defaultButton: true
- })
- } else if (type === Dialogs.FILEPICKER_TYPE_CUSTOM) {
- options.buttons.forEach(function(button) {
- buttonlist.push({
- text: button.text,
- click: function() {
- functionToCall(button.type)
- },
- defaultButton: button.defaultButton
- })
- })
- } else {
- if (type === Dialogs.FILEPICKER_TYPE_COPY || type === Dialogs.FILEPICKER_TYPE_COPY_MOVE) {
- buttonlist.push({
- text: t('core', 'Copy'),
- click: copyCallback,
- defaultButton: false
- })
- }
- if (type === Dialogs.FILEPICKER_TYPE_MOVE || type === Dialogs.FILEPICKER_TYPE_COPY_MOVE) {
- buttonlist.push({
- text: t('core', 'Move'),
- click: moveCallback,
- defaultButton: true
- })
- }
- }
+ /**
+ * Displays raw dialog
+ * You better use a wrapper instead ...
+ *
+ * @deprecated 30.0.0 Use `@nextcloud/dialogs` instead or build your own with `@nextcloud/vue` NcDialog
+ */
+ message: function(content, title, dialogType, buttons, callback = () => {}, modal, allowHtml) {
+ const builder = (new DialogBuilder())
+ .setName(title)
+ .setText(allowHtml ? '' : content)
+ .setButtons(Dialogs._getLegacyButtons(buttons, callback))
+
+ switch (dialogType) {
+ case 'alert':
+ builder.setSeverity('warning')
+ break
+ case 'notice':
+ builder.setSeverity('info')
+ break
+ default:
+ break
+ }
- self.$filePicker.ocdialog({
- closeOnEscape: true,
- // max-width of 600
- width: 600,
- height: 500,
- modal: modal,
- buttons: buttonlist,
- style: {
- buttons: 'aside'
- },
- close: function() {
- try {
- $(this).ocdialog('destroy').remove()
- } catch (e) {
- }
- self.$filePicker = null
- }
- })
+ const dialog = builder.build()
+
+ if (allowHtml) {
+ dialog.setHTML(content)
+ }
- // We can access primary class only from oc-dialog.
- // Hence this is one of the approach to get the choose button.
- var getOcDialog = self.$filePicker.closest('.oc-dialog')
- var buttonEnableDisable = getOcDialog.find('.primary')
- if (self.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || self.$filePicker.data('allowDirectoryChooser')) {
- buttonEnableDisable.prop('disabled', false)
- } else {
- buttonEnableDisable.prop('disabled', true)
+ return dialog.show().then(() => {
+ if(!callback._clicked) {
+ callback(false)
}
})
- .fail(function(status, error) {
- // If the method is called while navigating away
- // from the page, it is probably not needed ;)
- self.filepicker.loading = false
- if (status !== 0) {
- alert(t('core', 'Error loading file picker template: {error}', { error: error }))
- }
- })
},
+
/**
- * Displays raw dialog
- * You better use a wrapper instead ...
+ * Helper for legacy API
+ * @deprecated
*/
- message: function(content, title, dialogType, buttons, callback, modal, allowHtml) {
- return $.when(this._getMessageTemplate()).then(function($tmpl) {
- var dialogName = 'oc-dialog-' + Dialogs.dialogsCounter + '-content'
- var dialogId = '#' + dialogName
- var $dlg = $tmpl.octemplate({
- dialog_name: dialogName,
- title: title,
- message: content,
- type: dialogType
- }, allowHtml ? { escapeFunction: '' } : {})
- if (modal === undefined) {
- modal = false
- }
- $('body').append($dlg)
- var buttonlist = []
- switch (buttons) {
+ _getLegacyButtons(buttons, callback) {
+ const buttonList = []
+
+ switch (typeof buttons === 'object' ? buttons.type : buttons) {
case Dialogs.YES_NO_BUTTONS:
- buttonlist = [{
- text: t('core', 'No'),
- click: function() {
- if (callback !== undefined) {
- callback(false)
- }
- $(dialogId).ocdialog('close')
- }
- },
- {
- text: t('core', 'Yes'),
- click: function() {
- if (callback !== undefined) {
- callback(true)
- }
- $(dialogId).ocdialog('close')
+ buttonList.push({
+ label: buttons?.cancel ?? t('core', 'No'),
+ callback: () => {
+ callback._clicked = true
+ callback(false)
},
- defaultButton: true
- }]
+ })
+ buttonList.push({
+ label: buttons?.confirm ?? t('core', 'Yes'),
+ type: 'primary',
+ callback: () => {
+ callback._clicked = true
+ callback(true)
+ },
+ })
break
- case Dialogs.OK_BUTTON:
- var functionToCall = function() {
- $(dialogId).ocdialog('close')
- if (callback !== undefined) {
- callback()
- }
- }
- buttonlist[0] = {
- text: t('core', 'OK'),
- click: functionToCall,
- defaultButton: true
- }
+ case Dialogs.OK_BUTTONS:
+ buttonList.push({
+ label: buttons?.confirm ?? t('core', 'OK'),
+ type: 'primary',
+ callback: () => {
+ callback._clicked = true
+ callback(true)
+ },
+ })
break
default:
- if (typeof(buttons) === 'object') {
- switch (buttons.type) {
- case Dialogs.YES_NO_BUTTONS:
- buttonlist = [{
- text: buttons.cancel || t('core', 'No'),
- click: function() {
- if (callback !== undefined) {
- callback(false)
- }
- $(dialogId).ocdialog('close')
- }
- },
- {
- text: buttons.confirm || t('core', 'Yes'),
- click: function() {
- if (callback !== undefined) {
- callback(true)
- }
- $(dialogId).ocdialog('close')
- },
- defaultButton: true,
- classes: buttons.confirmClasses
- }]
- break
- }
- }
+ console.error('Invalid call to OC.dialogs')
break
- }
-
- $(dialogId).ocdialog({
- closeOnEscape: true,
- closeCallback: () => { callback && callback(false) },
- modal: modal,
- buttons: buttonlist
- })
- Dialogs.dialogsCounter++
- })
- .fail(function(status, error) {
- // If the method is called while navigating away from
- // the page, we still want to deliver the message.
- if (status === 0) {
- alert(title + ': ' + content)
- } else {
- alert(t('core', 'Error loading message template: {error}', { error: error }))
- }
- })
+ }
+ return buttonList
},
+
_fileexistsshown: false,
/**
* Displays file exists dialog
@@ -649,6 +407,8 @@ const Dialogs = {
* @param {object} replacement file with name, size and mtime
* @param {object} controller with onCancel, onSkip, onReplace and onRename methods
* @returns {Promise} jquery promise that resolves after the dialog template was loaded
+ *
+ * @deprecated 29.0.0 Use openConflictPicker from the @nextcloud/upload package instead
*/
fileexists: function(data, original, replacement, controller) {
var self = this
@@ -1007,68 +767,12 @@ const Dialogs = {
// }
return dialogDeferred.promise()
},
- // get the gridview setting and set the input accordingly
- _getGridSettings: function() {
- var self = this
- $.get(OC.generateUrl('/apps/files/api/v1/showgridview'), function(response) {
- self.$showGridView.get(0).checked = response.gridview
- self.$showGridView.next('#picker-view-toggle')
- .removeClass('icon-toggle-filelist icon-toggle-pictures')
- .addClass(response.gridview ? 'icon-toggle-filelist' : 'icon-toggle-pictures')
- $('.list-container').toggleClass('view-grid', response.gridview)
- })
- },
- _onGridviewChange: function() {
- var show = this.$showGridView.is(':checked')
- // only save state if user is logged in
- if (OC.currentUser) {
- $.post(OC.generateUrl('/apps/files/api/v1/showgridview'), {
- show: show
- })
- }
- this.$showGridView.next('#picker-view-toggle')
- .removeClass('icon-toggle-filelist icon-toggle-pictures')
- .addClass(show ? 'icon-toggle-filelist' : 'icon-toggle-pictures')
- $('.list-container').toggleClass('view-grid', show)
- },
- _getFilePickerTemplate: function() {
- var defer = $.Deferred()
- if (!this.$filePickerTemplate) {
- var self = this
- $.get(OC.filePath('core', 'templates', 'filepicker.html'), function(tmpl) {
- self.$filePickerTemplate = $(tmpl)
- self.$listTmpl = self.$filePickerTemplate.find('.filelist tbody tr:first-child').detach()
- defer.resolve(self.$filePickerTemplate)
- })
- .fail(function(jqXHR, textStatus, errorThrown) {
- defer.reject(jqXHR.status, errorThrown)
- })
- } else {
- defer.resolve(this.$filePickerTemplate)
- }
- return defer.promise()
- },
- _getMessageTemplate: function() {
- var defer = $.Deferred()
- if (!this.$messageTemplate) {
- var self = this
- $.get(OC.filePath('core', 'templates', 'message.html'), function(tmpl) {
- self.$messageTemplate = $(tmpl)
- defer.resolve(self.$messageTemplate)
- })
- .fail(function(jqXHR, textStatus, errorThrown) {
- defer.reject(jqXHR.status, errorThrown)
- })
- } else {
- defer.resolve(this.$messageTemplate)
- }
- return defer.promise()
- },
+
_getFileExistsTemplate: function() {
var defer = $.Deferred()
if (!this.$fileexistsTemplate) {
var self = this
- $.get(OC.filePath('files', 'templates', 'fileexists.html'), function(tmpl) {
+ $.get(OC.filePath('core', 'templates/legacy', 'fileexists.html'), function(tmpl) {
self.$fileexistsTemplate = $(tmpl)
defer.resolve(self.$fileexistsTemplate)
})
@@ -1080,246 +784,6 @@ const Dialogs = {
}
return defer.promise()
},
- _getFileList: function(dir, mimeType) { // this is only used by the spreedme app atm
- if (typeof (mimeType) === 'string') {
- mimeType = [mimeType]
- }
-
- return $.getJSON(
- OC.filePath('files', 'ajax', 'list.php'),
- {
- dir: dir,
- mimetypes: JSON.stringify(mimeType)
- }
- )
- },
-
- /**
- * fills the filepicker with files
- */
- _fillFilePicker: function(dir) {
- var self = this
- this.$filelist.empty()
- this.$filePicker.find('.emptycontent').hide()
- this.$filelistContainer.addClass('icon-loading')
- this.$filePicker.data('path', dir)
- var filter = this.$filePicker.data('mimetype')
- if (typeof (filter) === 'string') {
- filter = [filter]
- }
- self.$fileListHeader.find('.sort-indicator').addClass('hidden').removeClass('icon-triangle-n').removeClass('icon-triangle-s')
- self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').removeClass('hidden')
- if (self.filepicker.sortOrder === 'asc') {
- self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').addClass('icon-triangle-n')
- } else {
- self.$fileListHeader.find('[data-sort=' + self.filepicker.sortField + '] .sort-indicator').addClass('icon-triangle-s')
- }
- self.filepicker.filesClient.getFolderContents(dir).then(function(status, files) {
- self.filelist = files
- if (filter && filter.length > 0 && filter.indexOf('*') === -1) {
- files = files.filter(function(file) {
- return file.type === 'dir' || filter.indexOf(file.mimetype) !== -1
- })
- }
-
- var Comparators = {
- name: function(fileInfo1, fileInfo2) {
- if (fileInfo1.type === 'dir' && fileInfo2.type !== 'dir') {
- return -1
- }
- if (fileInfo1.type !== 'dir' && fileInfo2.type === 'dir') {
- return 1
- }
- return OC.Util.naturalSortCompare(fileInfo1.name, fileInfo2.name)
- },
- size: function(fileInfo1, fileInfo2) {
- return fileInfo1.size - fileInfo2.size
- },
- mtime: function(fileInfo1, fileInfo2) {
- return fileInfo1.mtime - fileInfo2.mtime
- }
- }
- var comparator = Comparators[self.filepicker.sortField] || Comparators.name
- files = files.sort(function(file1, file2) {
- var isFavorite = function(fileInfo) {
- return fileInfo.tags && fileInfo.tags.indexOf(OC.TAG_FAVORITE) >= 0
- }
-
- if (isFavorite(file1) && !isFavorite(file2)) {
- return -1
- } else if (!isFavorite(file1) && isFavorite(file2)) {
- return 1
- }
-
- return self.filepicker.sortOrder === 'asc' ? comparator(file1, file2) : -comparator(file1, file2)
- })
-
- self._fillSlug()
-
- if (files.length === 0) {
- self.$filePicker.find('.emptycontent').show()
- self.$fileListHeader.hide()
- } else {
- self.$filePicker.find('.emptycontent').hide()
- self.$fileListHeader.show()
- }
-
- self.$filelist.empty();
-
- $.each(files, function(idx, entry) {
- entry.icon = OC.MimeType.getIconUrl(entry.mimetype)
- var simpleSize, sizeColor
- if (typeof (entry.size) !== 'undefined' && entry.size >= 0) {
- simpleSize = OC.Util.humanFileSize(parseInt(entry.size, 10), true)
- sizeColor = Math.round(160 - Math.pow((entry.size / (1024 * 1024)), 2))
- } else {
- simpleSize = t('files', 'Pending')
- sizeColor = 80
- }
-
- // split the filename in half if the size is bigger than 20 char
- // for ellipsis
- if (entry.name.length >= 10) {
- // leave maximum 10 letters
- var split = Math.min(Math.floor(entry.name.length / 2), 10)
- var filename1 = entry.name.substr(0, entry.name.length - split)
- var filename2 = entry.name.substr(entry.name.length - split)
- } else {
- var filename1 = entry.name
- var filename2 = ''
- }
-
- var $row = self.$listTmpl.octemplate({
- type: entry.type,
- dir: dir,
- filename: entry.name,
- filename1: filename1,
- filename2: filename2,
- date: OC.Util.relativeModifiedDate(entry.mtime),
- size: simpleSize,
- sizeColor: sizeColor,
- icon: entry.icon
- })
- if (entry.type === 'file') {
- var urlSpec = {
- file: dir + '/' + entry.name,
- x: 100,
- y: 100
- }
- var img = new Image()
- var previewUrl = OC.generateUrl('/core/preview.png?') + $.param(urlSpec)
- img.onload = function() {
- if (img.width > 5) {
- $row.find('td.filename').attr('style', 'background-image:url(' + previewUrl + ')')
- }
- }
- img.src = previewUrl
- }
- self.$filelist.append($row)
- })
-
- self.$filelistContainer.removeClass('icon-loading')
- })
- },
- /**
- * fills the tree list with directories
- */
- _fillSlug: function() {
- var addButton = this.$dirTree.find('.actions.creatable').detach()
- this.$dirTree.empty()
- var self = this
-
- self.$dirTree.append(addButton)
-
- var dir
- var path = this.$filePicker.data('path')
- var $template = $('<div data-dir="{dir}"><a>{name}</a></div>').addClass('crumb')
- if (path) {
- var paths = path.split('/')
- $.each(paths, function(index, dir) {
- dir = paths.pop()
- if (dir === '') {
- return false
- }
- self.$dirTree.prepend($template.octemplate({
- dir: paths.join('/') + '/' + dir,
- name: dir
- }))
- })
- }
-
- $template.octemplate({
- dir: '',
- name: '' // Ugly but works ;)
- }, { escapeFunction: null }).prependTo(this.$dirTree)
-
- },
- /**
- * handle selection made in the tree list
- */
- _handleTreeListSelect: function(event, type) {
- var self = event.data
- var dir = $(event.target).closest('.crumb').data('dir')
- self._fillFilePicker(dir)
- var getOcDialog = (event.target).closest('.oc-dialog')
- var buttonEnableDisable = $('.primary', getOcDialog)
- this._changeButtonsText(type, dir.split(/[/]+/).pop())
- if (this.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || this.$filePicker.data('allowDirectoryChooser')) {
- buttonEnableDisable.prop('disabled', false)
- } else {
- buttonEnableDisable.prop('disabled', true)
- }
- },
- /**
- * handle clicks made in the filepicker
- */
- _handlePickerClick: function(event, $element, type) {
- var getOcDialog = this.$filePicker.closest('.oc-dialog')
- var buttonEnableDisable = getOcDialog.find('.primary')
- if ($element.data('type') === 'file') {
- if (this.$filePicker.data('multiselect') !== true || !event.ctrlKey) {
- this.$filelist.find('.filepicker_element_selected').removeClass('filepicker_element_selected')
- }
- $element.toggleClass('filepicker_element_selected')
- buttonEnableDisable.prop('disabled', false)
- } else if ($element.data('type') === 'dir') {
- this._fillFilePicker(this.$filePicker.data('path') + '/' + $element.data('entryname'))
- this._changeButtonsText(type, $element.data('entryname'))
- if (this.$filePicker.data('mimetype').indexOf('httpd/unix-directory') !== -1 || this.$filePicker.data('allowDirectoryChooser')) {
- buttonEnableDisable.prop('disabled', false)
- } else {
- buttonEnableDisable.prop('disabled', true)
- }
- }
- },
-
- /**
- * Handle
- * @param type of action
- * @param dir on which to change buttons text
- * @private
- */
- _changeButtonsText: function(type, dir) {
- var copyText = dir === '' ? t('core', 'Copy') : t('core', 'Copy to {folder}', { folder: dir })
- var moveText = dir === '' ? t('core', 'Move') : t('core', 'Move to {folder}', { folder: dir })
- var buttons = $('.oc-dialog-buttonrow button')
- switch (type) {
- case this.FILEPICKER_TYPE_CHOOSE:
- break
- case this.FILEPICKER_TYPE_CUSTOM:
- break
- case this.FILEPICKER_TYPE_COPY:
- buttons.text(copyText)
- break
- case this.FILEPICKER_TYPE_MOVE:
- buttons.text(moveText)
- break
- case this.FILEPICKER_TYPE_COPY_MOVE:
- buttons.eq(0).text(copyText)
- buttons.eq(1).text(moveText)
- break
- }
- }
}
export default Dialogs
diff --git a/core/src/OC/eventsource.js b/core/src/OC/eventsource.js
index cc576d8655a..090c351c057 100644
--- a/core/src/OC/eventsource.js
+++ b/core/src/OC/eventsource.js
@@ -1,39 +1,13 @@
-/* eslint-disable */
-/**
- * ownCloud
- *
- * @author Robin Appelman
- * @copyright 2012 Robin Appelman icewind1991@gmail.com
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this library. If not, see <http://www.gnu.org/licenses/>.
- *
- */
-
/**
- * Wrapper for server side events
- * (http://en.wikipedia.org/wiki/Server-sent_events)
- * includes a fallback for older browsers and IE
- *
- * use server side events with caution, too many open requests can hang the
- * server
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2015 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-/* global EventSource */
-
+/* eslint-disable */
import $ from 'jquery'
-import { getToken } from './requesttoken'
+import { getRequestToken } from './requesttoken.ts'
/**
* Create a new event source
@@ -54,7 +28,7 @@ const OCEventSource = function(src, data) {
dataStr += name + '=' + encodeURIComponent(data[name]) + '&'
}
}
- dataStr += 'requesttoken=' + encodeURIComponent(getToken())
+ dataStr += 'requesttoken=' + encodeURIComponent(getRequestToken())
if (!this.useFallBack && typeof EventSource !== 'undefined') {
joinChar = '&'
if (src.indexOf('?') === -1) {
@@ -69,7 +43,7 @@ const OCEventSource = function(src, data) {
} else {
var iframeId = 'oc_eventsource_iframe_' + OCEventSource.iframeCount
OCEventSource.fallBackSources[OCEventSource.iframeCount] = this
- this.iframe = $('<iframe/>')
+ this.iframe = $('<iframe></iframe>')
this.iframe.attr('id', iframeId)
this.iframe.hide()
diff --git a/core/src/OC/get_set.js b/core/src/OC/get_set.js
index b4dcd563eff..0c909ad04fd 100644
--- a/core/src/OC/get_set.js
+++ b/core/src/OC/get_set.js
@@ -1,30 +1,8 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-/**
- * Get a variable by name
- * @param {string} context context
- * @returns {Function} getter
- * @deprecated 19.0.0 use https://lodash.com/docs#get
- */
export const get = context => name => {
const namespaces = name.split('.')
const tail = namespaces.pop()
@@ -40,8 +18,9 @@ export const get = context => name => {
/**
* Set a variable by name
+ *
* @param {string} context context
- * @returns {Function} setter
+ * @return {Function} setter
* @deprecated 19.0.0 use https://lodash.com/docs#set
*/
export const set = context => (name, value) => {
diff --git a/core/src/OC/host.js b/core/src/OC/host.js
index f90ca65b4da..75c7d63804b 100644
--- a/core/src/OC/host.js
+++ b/core/src/OC/host.js
@@ -1,29 +1,8 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
/**
- * Protocol that is used to access this Nextcloud instance
- * @returns {string} Used protocol
- * @deprecated 17.0.0 use window.location.protocol directly
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+
export const getProtocol = () => window.location.protocol.split(':')[0]
/**
@@ -35,9 +14,9 @@ export const getProtocol = () => window.location.protocol.split(':')[0]
* https://example.com => example.com
* http://example.com:8080 => example.com:8080
*
- * @returns {string} host
+ * @return {string} host
*
- * @since 8.2
+ * @since 8.2.0
* @deprecated 17.0.0 use window.location.host directly
*/
export const getHost = () => window.location.host
@@ -46,8 +25,8 @@ export const getHost = () => window.location.host
* Returns the hostname used to access this Nextcloud instance
* The hostname is always stripped of the port
*
- * @returns {string} hostname
- * @since 9.0
+ * @return {string} hostname
+ * @since 9.0.0
* @deprecated 17.0.0 use window.location.hostname directly
*/
export const getHostName = () => window.location.hostname
@@ -55,9 +34,9 @@ export const getHostName = () => window.location.hostname
/**
* Returns the port number used to access this Nextcloud instance
*
- * @returns {int} port number
+ * @return {number} port number
*
- * @since 8.2
+ * @since 8.2.0
* @deprecated 17.0.0 use window.location.port directly
*/
export const getPort = () => window.location.port
diff --git a/core/src/OC/index.js b/core/src/OC/index.js
index 036e640be39..5afc941b396 100644
--- a/core/src/OC/index.js
+++ b/core/src/OC/index.js
@@ -1,37 +1,19 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { subscribe } from '@nextcloud/event-bus'
-import { addScript, addStyle } from './legacy-loader'
import {
ajaxConnectionLostHandler,
processAjaxError,
registerXHRForErrorProcessing,
-} from './xhr-error'
-import Apps from './apps'
-import { AppConfig, appConfig } from './appconfig'
-import { appSettings } from './appsettings'
-import appswebroots from './appswebroots'
-import Backbone from './backbone'
+} from './xhr-error.js'
+import Apps from './apps.js'
+import { AppConfig, appConfig } from './appconfig.js'
+import appswebroots from './appswebroots.js'
+import Backbone from './backbone.js'
import {
basename,
dirname,
@@ -42,8 +24,8 @@ import {
import {
build as buildQueryString,
parse as parseQueryString,
-} from './query-string'
-import Config from './config'
+} from './query-string.js'
+import Config from './config.js'
import {
coreApps,
menuSpeed,
@@ -55,35 +37,31 @@ import {
PERMISSION_SHARE,
PERMISSION_UPDATE,
TAG_FAVORITE,
-} from './constants'
-import ContactsMenu from './contactsmenu'
-import { currentUser, getCurrentUser } from './currentuser'
-import Dialogs from './dialogs'
-import EventSource from './eventsource'
-import { get, set } from './get_set'
-import { getCapabilities } from './capabilities'
+} from './constants.js'
+import { currentUser, getCurrentUser } from './currentuser.js'
+import Dialogs from './dialogs.js'
+import EventSource from './eventsource.js'
+import { get, set } from './get_set.js'
+import { getCapabilities } from './capabilities.js'
import {
getHost,
getHostName,
getPort,
getProtocol,
-} from './host'
-import {
- getToken as getRequestToken,
-} from './requesttoken'
+} from './host.js'
+import { getRequestToken } from './requesttoken.ts'
import {
hideMenus,
registerMenu,
showMenu,
unregisterMenu,
-} from './menu'
-import { isUserAdmin } from './admin'
-import L10N, {
- getLanguage,
- getLocale,
-} from './l10n'
+} from './menu.js'
+import { isUserAdmin } from './admin.js'
+import L10N from './l10n.js'
import {
getCanonicalLocale,
+ getLanguage,
+ getLocale,
} from '@nextcloud/l10n'
import {
@@ -98,16 +76,16 @@ import {
import {
linkToRemoteBase,
-} from './routing'
-import msg from './msg'
-import Notification from './notification'
-import PasswordConfirmation from './password-confirmation'
-import Plugins from './plugins'
-import { theme } from './theme'
-import Util from './util'
-import { debug } from './debug'
-import { redirect, reload } from './navigation'
-import webroot from './webroot'
+} from './routing.js'
+import msg from './msg.js'
+import Notification from './notification.js'
+import PasswordConfirmation from './password-confirmation.js'
+import Plugins from './plugins.js'
+import { theme } from './theme.js'
+import Util from './util.js'
+import { debug } from './debug.js'
+import { redirect, reload } from './navigation.js'
+import webroot from './webroot.js'
/** @namespace OC */
export default {
@@ -130,26 +108,22 @@ export default {
*/
/**
* Check if a user file is allowed to be handled.
+ *
* @param {string} file to check
- * @returns {Boolean}
+ * @return {boolean}
* @deprecated 17.0.0
*/
fileIsBlacklisted: file => !!(file.match(Config.blacklist_files_regex)),
-
- addScript,
- addStyle,
Apps,
AppConfig,
appConfig,
- appSettings,
appswebroots,
Backbone,
- ContactsMenu,
config: Config,
/**
* Currently logged in user or null if none
*
- * @type String
+ * @type {string}
* @deprecated use `getCurrentUser` from https://www.npmjs.com/package/@nextcloud/auth
*/
currentUser,
@@ -168,6 +142,7 @@ export default {
/**
* Ajax error handlers
+ *
* @todo remove from here and keep internally -> requires new tests
*/
_ajaxConnectionLostHandler: ajaxConnectionLostHandler,
@@ -226,17 +201,14 @@ export default {
* @deprecated 20.0.0 use `getCanonicalLocale` from https://www.npmjs.com/package/@nextcloud/l10n
*/
getCanonicalLocale,
+ /**
+ * @deprecated 26.0.0 use `getLocale` from https://www.npmjs.com/package/@nextcloud/l10n
+ */
getLocale,
- getLanguage,
/**
- * Loads translations for the given app asynchronously.
- *
- * @param {String} app app name
- * @param {Function} callback callback to call after loading
- * @return {Promise}
- * @deprecated 17.0.0 use OC.L10N.load instead
+ * @deprecated 26.0.0 use `getLanguage` from https://www.npmjs.com/package/@nextcloud/l10n
*/
- addTranslations: L10N.load,
+ getLanguage,
/**
* Query string helpers
@@ -246,6 +218,9 @@ export default {
msg,
Notification,
+ /**
+ * @deprecated 28.0.0 use methods from '@nextcloud/password-confirmation'
+ */
PasswordConfirmation,
Plugins,
theme,
@@ -283,9 +258,16 @@ export default {
*/
linkTo,
/**
+ * @param {string} service service name
+ * @param {number} version OCS API version
+ * @return {string} OCS API base path
* @deprecated 19.0.0 use `generateOcsUrl` from https://www.npmjs.com/package/@nextcloud/router
*/
- linkToOCS: generateOcsUrl,
+ linkToOCS: (service, version) => {
+ return generateOcsUrl(service, {}, {
+ ocsVersion: version || 1,
+ }) + '/'
+ },
/**
* @deprecated 19.0.0 use `generateRemoteUrl` from https://www.npmjs.com/package/@nextcloud/router
*/
@@ -295,7 +277,7 @@ export default {
* Relative path to Nextcloud root.
* For example: "/nextcloud"
*
- * @type string
+ * @type {string}
*
* @deprecated 19.0.0 use `getRootUrl` from https://www.npmjs.com/package/@nextcloud/router
* @see OC#getRootPath
diff --git a/core/src/OC/l10n-registry.js b/core/src/OC/l10n-registry.js
deleted file mode 100644
index dc353902337..00000000000
--- a/core/src/OC/l10n-registry.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-// This var is global because it's shared across webpack bundles
-window._oc_l10n_registry_translations = window._oc_l10n_registry_translations || {}
-window._oc_l10n_registry_plural_functions = window._oc_l10n_registry_plural_functions || {}
-
-/**
- * @param {String} appId the app id
- * @param {Object} translations the translations list
- * @param {Function} pluralFunction the translations list
- */
-const register = (appId, translations, pluralFunction) => {
- window._oc_l10n_registry_translations[appId] = translations
- window._oc_l10n_registry_plural_functions[appId] = pluralFunction
-}
-
-/**
- * @param {String} appId the app id
- * @param {Object} translations the translations list
- * @param {Function} pluralFunction the translations list
- */
-const extend = (appId, translations, pluralFunction) => {
- window._oc_l10n_registry_translations[appId] = Object.assign(
- window._oc_l10n_registry_translations[appId],
- translations
- )
- window._oc_l10n_registry_plural_functions[appId] = pluralFunction
-}
-
-/**
- * @param {String} appId the app id
- * @param {Object} translations the translations list
- * @param {Function} pluralFunction the translations list
- */
-export const registerAppTranslations = (appId, translations, pluralFunction) => {
- if (!hasAppTranslations(appId)) {
- register(appId, translations, pluralFunction)
- } else {
- extend(appId, translations, pluralFunction)
- }
-}
-
-/**
- * @param {String} appId the app id
- */
-export const unregisterAppTranslations = appId => {
- delete window._oc_l10n_registry_translations[appId]
- delete window._oc_l10n_registry_plural_functions[appId]
-}
-
-/**
- * @param {String} appId the app id
- * @returns {Boolean}
- */
-export const hasAppTranslations = appId => {
- return window._oc_l10n_registry_translations[appId] !== undefined
- && window._oc_l10n_registry_plural_functions[appId] !== undefined
-}
-
-/**
- * @param {String} appId the app id
- * @returns {Object}
- */
-export const getAppTranslations = appId => {
- return {
- translations: window._oc_l10n_registry_translations[appId] || {},
- pluralFunction: window._oc_l10n_registry_plural_functions[appId],
- }
-}
diff --git a/core/src/OC/l10n.js b/core/src/OC/l10n.js
index 2eeb342aa13..02f912d6a99 100644
--- a/core/src/OC/l10n.js
+++ b/core/src/OC/l10n.js
@@ -1,342 +1,90 @@
/**
- * Copyright (c) 2014 Vincent Petry <pvince81@owncloud.com>
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2014 ownCloud, Inc.
+ * SPDX-FileCopyrightText: 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import _ from 'underscore'
-import $ from 'jquery'
-import DOMPurify from 'dompurify'
import Handlebars from 'handlebars'
-import identity from 'lodash/fp/identity'
-import escapeHTML from 'escape-html'
-import { generateFilePath } from '@nextcloud/router'
-
-import OC from './index'
import {
- getAppTranslations,
- hasAppTranslations,
- registerAppTranslations,
- unregisterAppTranslations,
-} from './l10n-registry'
+ loadTranslations,
+ translate,
+ translatePlural,
+ register,
+ unregister,
+} from '@nextcloud/l10n'
/**
* L10N namespace with localization functions.
*
* @namespace OC.L10n
+ * @deprecated 26.0.0 use https://www.npmjs.com/package/@nextcloud/l10n
*/
const L10n = {
/**
* Load an app's translation bundle if not loaded already.
*
- * @param {String} appName name of the app
+ * @deprecated 26.0.0 use `loadTranslations` from https://www.npmjs.com/package/@nextcloud/l10n
+ *
+ * @param {string} appName name of the app
* @param {Function} callback callback to be called when
* the translations are loaded
- * @returns {Promise} promise
+ * @return {Promise} promise
*/
- load(appName, callback) {
- // already available ?
- if (hasAppTranslations(appName) || OC.getLocale() === 'en') {
- const deferred = $.Deferred()
- const promise = deferred.promise()
- promise.then(callback)
- deferred.resolve()
- return promise
- }
-
- const self = this
- const url = generateFilePath(appName, 'l10n', OC.getLocale() + '.json')
-
- // load JSON translation bundle per AJAX
- return $.get(url)
- .then(
- function(result) {
- if (result.translations) {
- self.register(appName, result.translations, result.pluralForm)
- }
- })
- .then(callback)
- },
+ load: loadTranslations,
/**
* Register an app's translation bundle.
*
- * @param {String} appName name of the app
- * @param {Object<String,String>} bundle bundle
+ * @deprecated 26.0.0 use `register` from https://www.npmjs.com/package/@nextcloud/l10
+ *
+ * @param {string} appName name of the app
+ * @param {Record<string, string>} bundle bundle
*/
- register(appName, bundle) {
- registerAppTranslations(appName, bundle, this._getPlural)
- },
+ register,
/**
- * @private do not use this
+ * @private
+ * @deprecated 26.0.0 use `unregister` from https://www.npmjs.com/package/@nextcloud/l10n
*/
- _unregister: unregisterAppTranslations,
+ _unregister: unregister,
/**
* Translate a string
+ *
+ * @deprecated 26.0.0 use `translate` from https://www.npmjs.com/package/@nextcloud/l10n
+ *
* @param {string} app the id of the app for which to translate the string
* @param {string} text the string to translate
- * @param {Object} [vars] map of placeholder key to value
+ * @param {object} [vars] map of placeholder key to value
* @param {number} [count] number to replace %n with
- * @param {array} [options] options array
- * @param {bool} [options.escape=true] enable/disable auto escape of placeholders (by default enabled)
- * @param {bool} [options.sanitize=true] enable/disable sanitization (by default enabled)
- * @returns {string}
+ * @param {Array} [options] options array
+ * @param {boolean} [options.escape=true] enable/disable auto escape of placeholders (by default enabled)
+ * @param {boolean} [options.sanitize=true] enable/disable sanitization (by default enabled)
+ * @return {string}
*/
- translate(app, text, vars, count, options) {
- const defaultOptions = {
- escape: true,
- sanitize: true,
- }
- const allOptions = options || {}
- _.defaults(allOptions, defaultOptions)
-
- const optSanitize = allOptions.sanitize ? DOMPurify.sanitize : identity
- const optEscape = allOptions.escape ? escapeHTML : identity
-
- // TODO: cache this function to avoid inline recreation
- // of the same function over and over again in case
- // translate() is used in a loop
- const _build = function(text, vars, count) {
- return text.replace(/%n/g, count).replace(/{([^{}]*)}/g,
- function(a, b) {
- const r = vars[b]
- if (typeof r === 'string' || typeof r === 'number') {
- return optSanitize(optEscape(r))
- } else {
- return optSanitize(a)
- }
- }
- )
- }
- let translation = text
- const bundle = getAppTranslations(app)
- const value = bundle.translations[text]
- if (typeof (value) !== 'undefined') {
- translation = value
- }
-
- if (typeof vars === 'object' || count !== undefined) {
- return optSanitize(_build(translation, vars, count))
- } else {
- return optSanitize(translation)
- }
- },
+ translate,
/**
* Translate a plural string
+ *
+ * @deprecated 26.0.0 use `translatePlural` from https://www.npmjs.com/package/@nextcloud/l10n
+ *
* @param {string} app the id of the app for which to translate the string
* @param {string} textSingular the string to translate for exactly one object
* @param {string} textPlural the string to translate for n objects
* @param {number} count number to determine whether to use singular or plural
- * @param {Object} [vars] map of placeholder key to value
- * @param {array} [options] options array
- * @param {bool} [options.escape=true] enable/disable auto escape of placeholders (by default enabled)
- * @returns {string} Translated string
- */
- translatePlural(app, textSingular, textPlural, count, vars, options) {
- const identifier = '_' + textSingular + '_::_' + textPlural + '_'
- const bundle = getAppTranslations(app)
- const value = bundle.translations[identifier]
- if (typeof (value) !== 'undefined') {
- const translation = value
- if ($.isArray(translation)) {
- const plural = bundle.pluralFunction(count)
- return this.translate(app, translation[plural], vars, count, options)
- }
- }
-
- if (count === 1) {
- return this.translate(app, textSingular, vars, count, options)
- } else {
- return this.translate(app, textPlural, vars, count, options)
- }
- },
-
- /**
- * The plural function taken from symfony
- *
- * @param {number} number the number of elements
- * @returns {number}
- * @private
+ * @param {object} [vars] map of placeholder key to value
+ * @param {Array} [options] options array
+ * @param {boolean} [options.escape=true] enable/disable auto escape of placeholders (by default enabled)
+ * @return {string} Translated string
*/
- _getPlural(number) {
- let language = OC.getLanguage()
- if (language === 'pt-BR') {
- // temporary set a locale for brazilian
- language = 'xbr'
- }
-
- if (typeof language === 'undefined' || language === '') {
- return (number === 1) ? 0 : 1
- }
-
- if (language.length > 3) {
- language = language.substring(0, language.lastIndexOf('-'))
- }
-
- /*
- * The plural rules are derived from code of the Zend Framework (2010-09-25),
- * which is subject to the new BSD license (http://framework.zend.com/license/new-bsd).
- * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com)
- */
- switch (language) {
- case 'az':
- case 'bo':
- case 'dz':
- case 'id':
- case 'ja':
- case 'jv':
- case 'ka':
- case 'km':
- case 'kn':
- case 'ko':
- case 'ms':
- case 'th':
- case 'tr':
- case 'vi':
- case 'zh':
- return 0
-
- case 'af':
- case 'bn':
- case 'bg':
- case 'ca':
- case 'da':
- case 'de':
- case 'el':
- case 'en':
- case 'eo':
- case 'es':
- case 'et':
- case 'eu':
- case 'fa':
- case 'fi':
- case 'fo':
- case 'fur':
- case 'fy':
- case 'gl':
- case 'gu':
- case 'ha':
- case 'he':
- case 'hu':
- case 'is':
- case 'it':
- case 'ku':
- case 'lb':
- case 'ml':
- case 'mn':
- case 'mr':
- case 'nah':
- case 'nb':
- case 'ne':
- case 'nl':
- case 'nn':
- case 'no':
- case 'oc':
- case 'om':
- case 'or':
- case 'pa':
- case 'pap':
- case 'ps':
- case 'pt':
- case 'so':
- case 'sq':
- case 'sv':
- case 'sw':
- case 'ta':
- case 'te':
- case 'tk':
- case 'ur':
- case 'zu':
- return (number === 1) ? 0 : 1
-
- case 'am':
- case 'bh':
- case 'fil':
- case 'fr':
- case 'gun':
- case 'hi':
- case 'hy':
- case 'ln':
- case 'mg':
- case 'nso':
- case 'xbr':
- case 'ti':
- case 'wa':
- return ((number === 0) || (number === 1)) ? 0 : 1
-
- case 'be':
- case 'bs':
- case 'hr':
- case 'ru':
- case 'sh':
- case 'sr':
- case 'uk':
- return ((number % 10 === 1) && (number % 100 !== 11)) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2)
-
- case 'cs':
- case 'sk':
- return (number === 1) ? 0 : (((number >= 2) && (number <= 4)) ? 1 : 2)
-
- case 'ga':
- return (number === 1) ? 0 : ((number === 2) ? 1 : 2)
-
- case 'lt':
- return ((number % 10 === 1) && (number % 100 !== 11)) ? 0 : (((number % 10 >= 2) && ((number % 100 < 10) || (number % 100 >= 20))) ? 1 : 2)
-
- case 'sl':
- return (number % 100 === 1) ? 0 : ((number % 100 === 2) ? 1 : (((number % 100 === 3) || (number % 100 === 4)) ? 2 : 3))
-
- case 'mk':
- return (number % 10 === 1) ? 0 : 1
-
- case 'mt':
- return (number === 1) ? 0 : (((number === 0) || ((number % 100 > 1) && (number % 100 < 11))) ? 1 : (((number % 100 > 10) && (number % 100 < 20)) ? 2 : 3))
-
- case 'lv':
- return (number === 0) ? 0 : (((number % 10 === 1) && (number % 100 !== 11)) ? 1 : 2)
-
- case 'pl':
- return (number === 1) ? 0 : (((number % 10 >= 2) && (number % 10 <= 4) && ((number % 100 < 12) || (number % 100 > 14))) ? 1 : 2)
-
- case 'cy':
- return (number === 1) ? 0 : ((number === 2) ? 1 : (((number === 8) || (number === 11)) ? 2 : 3))
-
- case 'ro':
- return (number === 1) ? 0 : (((number === 0) || ((number % 100 > 0) && (number % 100 < 20))) ? 1 : 2)
-
- case 'ar':
- return (number === 0) ? 0 : ((number === 1) ? 1 : ((number === 2) ? 2 : (((number % 100 >= 3) && (number % 100 <= 10)) ? 3 : (((number % 100 >= 11) && (number % 100 <= 99)) ? 4 : 5))))
-
- default:
- return 0
- }
- },
+ translatePlural,
}
export default L10n
-/**
- * Returns the user's locale
- *
- * @returns {String} locale string
- */
-export const getLocale = () => $('html').data('locale') ?? 'en'
-
-/**
- * Returns the user's language
- *
- * @returns {String} language string
- */
-export const getLanguage = () => $('html').prop('lang')
-
Handlebars.registerHelper('t', function(app, text) {
- return L10n.translate(app, text)
+ return translate(app, text)
})
diff --git a/core/src/OC/legacy-loader.js b/core/src/OC/legacy-loader.js
deleted file mode 100644
index 273ba3e359f..00000000000
--- a/core/src/OC/legacy-loader.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-import $ from 'jquery'
-import { generateFilePath } from '@nextcloud/router'
-
-const loadedScripts = {}
-const loadedStyles = []
-
-/**
- * Load a script for the server and load it. If the script is already loaded,
- * the event handler will be called directly
- * @param {string} app the app id to which the script belongs
- * @param {string} script the filename of the script
- * @param {Function} ready event handler to be called when the script is loaded
- * @returns {jQuery.Deferred}
- * @deprecated 16.0.0 Use OCP.Loader.loadScript
- */
-export const addScript = (app, script, ready) => {
- console.warn('OC.addScript is deprecated, use OCP.Loader.loadScript instead')
-
- let deferred
- const path = generateFilePath(app, 'js', script + '.js')
- if (!loadedScripts[path]) {
- deferred = $.Deferred()
- $.getScript(path, () => deferred.resolve())
- loadedScripts[path] = deferred
- } else {
- if (ready) {
- ready()
- }
- }
- return loadedScripts[path]
-}
-
-/**
- * Loads a CSS file
- * @param {string} app the app id to which the css style belongs
- * @param {string} style the filename of the css file
- * @deprecated 16.0.0 Use OCP.Loader.loadStylesheet
- */
-export const addStyle = (app, style) => {
- console.warn('OC.addStyle is deprecated, use OCP.Loader.loadStylesheet instead')
-
- const path = generateFilePath(app, 'css', style + '.css')
- if (loadedStyles.indexOf(path) === -1) {
- loadedStyles.push(path)
- if (document.createStyleSheet) {
- document.createStyleSheet(path)
- } else {
- style = $('<link rel="stylesheet" type="text/css" href="' + path + '"/>')
- $('head').append(style)
- }
- }
-}
diff --git a/core/src/OC/menu.js b/core/src/OC/menu.js
index 82cde9e862d..4b4eb658592 100644
--- a/core/src/OC/menu.js
+++ b/core/src/OC/menu.js
@@ -1,28 +1,13 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
+/** @typedef {import('jquery')} jQuery */
import $ from 'jquery'
-import { menuSpeed } from './constants'
+import { menuSpeed } from './constants.js'
export let currentMenu = null
export let currentMenuToggle = null
@@ -32,9 +17,9 @@ export let currentMenuToggle = null
*
* @param {jQuery} $toggle the toggle element
* @param {jQuery} $menuEl the menu container element
- * @param {function|undefined} toggle callback invoked everytime the menu is opened
+ * @param {Function | undefined} toggle callback invoked everytime the menu is opened
* @param {boolean} headerMenu is this a top right header menu?
- * @returns {undefined}
+ * @return {void}
*/
export const registerMenu = function($toggle, $menuEl, toggle, headerMenu) {
$menuEl.addClass('menu')
@@ -107,6 +92,9 @@ export const hideMenus = function(complete) {
// Set menu to closed
$('.menutoggle').attr('aria-expanded', false)
+ if (currentMenuToggle) {
+ currentMenuToggle.attr('aria-expanded', false)
+ }
$('.openedMenu').removeClass('openedMenu')
currentMenu = null
@@ -116,8 +104,8 @@ export const hideMenus = function(complete) {
/**
* Shows a given element as menu
*
- * @param {Object} [$toggle=null] menu toggle
- * @param {Object} $menuEl menu element
+ * @param {object} [$toggle] menu toggle
+ * @param {object} $menuEl menu element
* @param {Function} complete callback when the showing animation is done
*/
export const showMenu = ($toggle, $menuEl, complete) => {
diff --git a/core/src/OC/msg.js b/core/src/OC/msg.js
index 9be2b7fd322..655631a03ff 100644
--- a/core/src/OC/msg.js
+++ b/core/src/OC/msg.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -32,7 +16,7 @@ export default {
/**
* Displayes a "Saving..." message in the given message placeholder
*
- * @param {Object} selector Placeholder to display the message in
+ * @param {object} selector Placeholder to display the message in
*/
startSaving(selector) {
this.startAction(selector, t('core', 'Saving …'))
@@ -41,7 +25,7 @@ export default {
/**
* Displayes a custom message in the given message placeholder
*
- * @param {Object} selector Placeholder to display the message in
+ * @param {object} selector Placeholder to display the message in
* @param {string} message Plain text message to display (no HTML allowed)
*/
startAction(selector, message) {
@@ -55,9 +39,9 @@ export default {
/**
* Displayes an success/error message in the given selector
*
- * @param {Object} selector Placeholder to display the message in
- * @param {Object} response Response of the server
- * @param {Object} response.data Data of the servers response
+ * @param {object} selector Placeholder to display the message in
+ * @param {object} response Response of the server
+ * @param {object} response.data Data of the servers response
* @param {string} response.data.message Plain text message to display (no HTML allowed)
* @param {string} response.status is being used to decide whether the message
* is displayed as an error/success
@@ -69,9 +53,9 @@ export default {
/**
* Displayes an success/error message in the given selector
*
- * @param {Object} selector Placeholder to display the message in
- * @param {Object} response Response of the server
- * @param {Object} response.data Data of the servers response
+ * @param {object} selector Placeholder to display the message in
+ * @param {object} response Response of the server
+ * @param {object} response.data Data of the servers response
* @param {string} response.data.message Plain text message to display (no HTML allowed)
* @param {string} response.status is being used to decide whether the message
* is displayed as an error/success
@@ -87,7 +71,7 @@ export default {
/**
* Displayes an success message in the given selector
*
- * @param {Object} selector Placeholder to display the message in
+ * @param {object} selector Placeholder to display the message in
* @param {string} message Plain text success message to display (no HTML allowed)
*/
finishedSuccess(selector, message) {
@@ -103,7 +87,7 @@ export default {
/**
* Displayes an error message in the given selector
*
- * @param {Object} selector Placeholder to display the message in
+ * @param {object} selector Placeholder to display the message in
* @param {string} message Plain text error message to display (no HTML allowed)
*/
finishedError(selector, message) {
diff --git a/core/src/OC/navigation.js b/core/src/OC/navigation.js
index f9e6789950a..b279b9a60f3 100644
--- a/core/src/OC/navigation.js
+++ b/core/src/OC/navigation.js
@@ -1,33 +1,13 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-/**
- * Redirect to the target URL, can also be used for downloads.
- * @param {string} targetURL URL to redirect to
- * @deprecated 17.0.0 use window.location directly
- */
export const redirect = targetURL => { window.location = targetURL }
/**
* Reloads the current page
+ *
* @deprecated 17.0.0 use window.location.reload directly
*/
export const reload = () => { window.location.reload() }
diff --git a/core/src/OC/notification.js b/core/src/OC/notification.js
index 8b7e43373a6..b658f4163bb 100644
--- a/core/src/OC/notification.js
+++ b/core/src/OC/notification.js
@@ -1,25 +1,10 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
+/** @typedef {import('jquery')} jQuery */
import $ from 'jquery'
import { showMessage, TOAST_DEFAULT_TIMEOUT, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
@@ -89,10 +74,10 @@ export default {
* Consider using show() instead of showHTML()
*
* @param {string} html Message to display
- * @param {Object} [options] options
+ * @param {object} [options] options
* @param {string} [options.type] notification type
- * @param {int} [options.timeout=0] timeout value, defaults to 0 (permanent)
- * @returns {jQuery} jQuery element for notification row
+ * @param {number} [options.timeout] timeout value, defaults to 0 (permanent)
+ * @return {jQuery} jQuery element for notification row
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
showHtml(html, options) {
@@ -108,10 +93,10 @@ export default {
* Shows a sanitized notification
*
* @param {string} text Message to display
- * @param {Object} [options] options
+ * @param {object} [options] options
* @param {string} [options.type] notification type
- * @param {int} [options.timeout=0] timeout value, defaults to 0 (permanent)
- * @returns {jQuery} jQuery element for notification row
+ * @param {number} [options.timeout] timeout value, defaults to 0 (permanent)
+ * @return {jQuery} jQuery element for notification row
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
show(text, options) {
@@ -135,7 +120,7 @@ export default {
* Updates (replaces) a sanitized notification.
*
* @param {string} text Message to display
- * @returns {jQuery} JQuery element for notificaiton row
+ * @return {jQuery} JQuery element for notification row
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
showUpdate(text) {
@@ -152,11 +137,11 @@ export default {
* 7 seconds
*
* @param {string} text Message to show
- * @param {array} [options] options array
- * @param {int} [options.timeout=7] timeout in seconds, if this is 0 it will show the message permanently
- * @param {boolean} [options.isHTML=false] an indicator for HTML notifications (true) or text (false)
+ * @param {Array} [options] options array
+ * @param {number} [options.timeout] timeout in seconds, if this is 0 it will show the message permanently
+ * @param {boolean} [options.isHTML] an indicator for HTML notifications (true) or text (false)
* @param {string} [options.type] notification type
- * @returns {JQuery<any>} the toast element
+ * @return {jQuery} the toast element
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
showTemporary(text, options) {
@@ -169,7 +154,8 @@ export default {
/**
* Returns whether a notification is hidden.
- * @returns {boolean}
+ *
+ * @return {boolean}
* @deprecated 17.0.0 use the `@nextcloud/dialogs` package
*/
isHidden() {
diff --git a/core/src/OC/password-confirmation.js b/core/src/OC/password-confirmation.js
index 77977f1efbd..621f7a0695f 100644
--- a/core/src/OC/password-confirmation.js
+++ b/core/src/OC/password-confirmation.js
@@ -1,127 +1,26 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import _ from 'underscore'
-import $ from 'jquery'
-import moment from 'moment'
-import { generateUrl } from '@nextcloud/router'
-
-import OC from './index'
+import { confirmPassword, isPasswordConfirmationRequired } from '@nextcloud/password-confirmation'
+import '@nextcloud/password-confirmation/dist/style.css'
/**
* @namespace OC.PasswordConfirmation
*/
export default {
- callback: null,
-
- pageLoadTime: null,
-
- init() {
- $('.password-confirm-required').on('click', _.bind(this.requirePasswordConfirmation, this))
- this.pageLoadTime = moment.now()
- },
requiresPasswordConfirmation() {
- const serverTimeDiff = this.pageLoadTime - (window.nc_pageLoad * 1000)
- const timeSinceLogin = moment.now() - (serverTimeDiff + (window.nc_lastLogin * 1000))
-
- // if timeSinceLogin > 30 minutes and user backend allows password confirmation
- return (window.backendAllowsPasswordConfirmation && timeSinceLogin > 30 * 60 * 1000)
+ return isPasswordConfirmationRequired()
},
/**
* @param {Function} callback success callback function
- * @param {Object} options options
+ * @param {object} options options currently not used by confirmPassword
* @param {Function} rejectCallback error callback function
*/
requirePasswordConfirmation(callback, options, rejectCallback) {
- options = typeof options !== 'undefined' ? options : {}
- const defaults = {
- title: t('core', 'Authentication required'),
- text: t(
- 'core',
- 'This action requires you to confirm your password'
- ),
- confirm: t('core', 'Confirm'),
- label: t('core', 'Password'),
- error: '',
- }
-
- const config = _.extend(defaults, options)
-
- const self = this
-
- if (this.requiresPasswordConfirmation()) {
- OC.dialogs.prompt(
- config.text,
- config.title,
- function(result, password) {
- if (result && password !== '') {
- self._confirmPassword(password, config)
- } else if (_.isFunction(rejectCallback)) {
- rejectCallback()
- }
- },
- true,
- config.label,
- true
- ).then(function() {
- const $dialog = $('.oc-dialog:visible')
- $dialog.find('.ui-icon').remove()
- $dialog.addClass('password-confirmation')
- if (config.error !== '') {
- const $error = $('<p></p>').addClass('msg warning').text(config.error)
- $dialog.find('.oc-dialog-content').append($error)
- }
- $dialog.find('.oc-dialog-buttonrow').addClass('aside')
-
- const $buttons = $dialog.find('button')
- $buttons.eq(0).hide()
- $buttons.eq(1).text(config.confirm)
- })
- }
-
- this.callback = callback
- },
-
- _confirmPassword(password, config) {
- const self = this
-
- $.ajax({
- url: generateUrl('/login/confirm'),
- data: {
- password,
- },
- type: 'POST',
- success(response) {
- window.nc_lastLogin = response.lastLogin
-
- if (_.isFunction(self.callback)) {
- self.callback()
- }
- },
- error() {
- config.error = t('core', 'Failed to authenticate, try again')
- OC.PasswordConfirmation.requirePasswordConfirmation(self.callback, config)
- },
- })
+ confirmPassword().then(callback, rejectCallback)
},
}
diff --git a/core/src/OC/plugins.js b/core/src/OC/plugins.js
index 27c5e6acb2c..8212fc0b4ee 100644
--- a/core/src/OC/plugins.js
+++ b/core/src/OC/plugins.js
@@ -1,38 +1,19 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
/**
- * @namespace OC.Plugins
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+
export default {
/**
- * @type Array.<OC.Plugin>
+ * @type {Array.<OC.Plugin>}
*/
_plugins: {},
/**
* Register plugin
*
- * @param {String} targetName app name / class name to hook into
+ * @param {string} targetName app name / class name to hook into
* @param {OC.Plugin} plugin plugin
*/
register(targetName, plugin) {
@@ -47,8 +28,8 @@ export default {
* Returns all plugin registered to the given target
* name / app name / class name.
*
- * @param {String} targetName app name / class name to hook into
- * @returns {Array.<OC.Plugin>} array of plugins
+ * @param {string} targetName app name / class name to hook into
+ * @return {Array.<OC.Plugin>} array of plugins
*/
getPlugins(targetName) {
return this._plugins[targetName] || []
@@ -57,9 +38,9 @@ export default {
/**
* Call attach() on all plugins registered to the given target name.
*
- * @param {String} targetName app name / class name
- * @param {Object} targetObject to be extended
- * @param {Object} [options] options
+ * @param {string} targetName app name / class name
+ * @param {object} targetObject to be extended
+ * @param {object} [options] options
*/
attach(targetName, targetObject, options) {
const plugins = this.getPlugins(targetName)
@@ -73,9 +54,9 @@ export default {
/**
* Call detach() on all plugins registered to the given target name.
*
- * @param {String} targetName app name / class name
- * @param {Object} targetObject to be extended
- * @param {Object} [options] options
+ * @param {string} targetName app name / class name
+ * @param {object} targetObject to be extended
+ * @param {object} [options] options
*/
detach(targetName, targetObject, options) {
const plugins = this.getPlugins(targetName)
diff --git a/core/src/OC/query-string.js b/core/src/OC/query-string.js
index 33a51505ae8..df0f366133a 100644
--- a/core/src/OC/query-string.js
+++ b/core/src/OC/query-string.js
@@ -1,30 +1,15 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
/**
* Parses a URL query string into a JS map
+ *
* @param {string} queryString query string in the format param1=1234&param2=abcde&param3=xyz
- * @returns {Object.<string, string>} map containing key/values matching the URL parameters
+ * @return {Record<string, string>} map containing key/values matching the URL parameters
*/
export const parse = queryString => {
let pos
@@ -72,8 +57,9 @@ export const parse = queryString => {
/**
* Builds a URL query from a JS map.
- * @param {Object.<string, string>} params map containing key/values matching the URL parameters
- * @returns {string} String containing a URL query (without question) mark
+ *
+ * @param {Record<string, string>} params map containing key/values matching the URL parameters
+ * @return {string} String containing a URL query (without question) mark
*/
export const build = params => {
if (!params) {
diff --git a/core/src/OC/requesttoken.js b/core/src/OC/requesttoken.js
deleted file mode 100644
index 6c011879b8b..00000000000
--- a/core/src/OC/requesttoken.js
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-import { emit } from '@nextcloud/event-bus'
-
-/**
- * @private
- * @param {Document} global the document to read the initial value from
- * @param {Function} emit the function to invoke for every new token
- * @returns {Object}
- */
-export const manageToken = (global, emit) => {
- let token = global.getElementsByTagName('head')[0].getAttribute('data-requesttoken')
-
- return {
- getToken: () => token,
- setToken: newToken => {
- token = newToken
-
- emit('csrf-token-update', {
- token,
- })
- },
- }
-}
-
-const manageFromDocument = manageToken(document, emit)
-
-/**
- * @returns {string}
- */
-export const getToken = manageFromDocument.getToken
-
-/**
- * @param {String} newToken new token
- */
-export const setToken = manageFromDocument.setToken
diff --git a/core/src/OC/requesttoken.ts b/core/src/OC/requesttoken.ts
new file mode 100644
index 00000000000..8ecf0b3de7e
--- /dev/null
+++ b/core/src/OC/requesttoken.ts
@@ -0,0 +1,49 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { emit } from '@nextcloud/event-bus'
+import { generateUrl } from '@nextcloud/router'
+
+/**
+ * Get the current CSRF token.
+ */
+export function getRequestToken(): string {
+ return document.head.dataset.requesttoken!
+}
+
+/**
+ * Set a new CSRF token (e.g. because of session refresh).
+ * This also emits an event bus event for the updated token.
+ *
+ * @param token - The new token
+ * @fires Error - If the passed token is not a potential valid token
+ */
+export function setRequestToken(token: string): void {
+ if (!token || typeof token !== 'string') {
+ throw new Error('Invalid CSRF token given', { cause: { token } })
+ }
+
+ document.head.dataset.requesttoken = token
+ emit('csrf-token-update', { token })
+}
+
+/**
+ * Fetch the request token from the API.
+ * This does also set it on the current context, see `setRequestToken`.
+ *
+ * @fires Error - If the request failed
+ */
+export async function fetchRequestToken(): Promise<string> {
+ const url = generateUrl('/csrftoken')
+
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error('Could not fetch CSRF token from API', { cause: response })
+ }
+
+ const { token } = await response.json()
+ setRequestToken(token)
+ return token
+}
diff --git a/core/src/OC/routing.js b/core/src/OC/routing.js
index 7895b958c81..4b81714d6f0 100644
--- a/core/src/OC/routing.js
+++ b/core/src/OC/routing.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
@@ -25,8 +9,9 @@ import {
/**
* Creates a relative url for remote use
+ *
* @param {string} service id
- * @returns {string} the url
+ * @return {string} the url
*/
export const linkToRemoteBase = service => {
return realGetRootUrl() + '/remote.php/' + service
diff --git a/core/src/OC/theme.js b/core/src/OC/theme.js
index 8e49499f485..af45c37de7e 100644
--- a/core/src/OC/theme.js
+++ b/core/src/OC/theme.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const theme = window._theme || {}
diff --git a/core/src/OC/util-history.js b/core/src/OC/util-history.js
index 54019b804c2..7ecd0e098c6 100644
--- a/core/src/OC/util-history.js
+++ b/core/src/OC/util-history.js
@@ -1,26 +1,10 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
-import OC from './index'
+import OC from './index.js'
/**
* Utility class for the history API,
@@ -39,11 +23,11 @@ export default {
* Note: this includes a workaround for IE8/IE9 that uses
* the hash part instead of the search part.
*
- * @param {Object|string} params to append to the URL, can be either a string
+ * @param {object | string} params to append to the URL, can be either a string
* or a map
* @param {string} [url] URL to be used, otherwise the current URL will be used,
* using the params as query string
- * @param {boolean} [replace=false] whether to replace instead of pushing
+ * @param {boolean} [replace] whether to replace instead of pushing
*/
_pushState(params, url, replace) {
let strParams
@@ -90,7 +74,7 @@ export default {
* Note: this includes a workaround for IE8/IE9 that uses
* the hash part instead of the search part.
*
- * @param {Object|string} params to append to the URL, can be either a string or a map
+ * @param {object | string} params to append to the URL, can be either a string or a map
* @param {string} [url] URL to be used, otherwise the current URL will be used, using the params as query string
*/
pushState(params, url) {
@@ -103,7 +87,7 @@ export default {
* Note: this includes a workaround for IE8/IE9 that uses
* the hash part instead of the search part.
*
- * @param {Object|string} params to append to the URL, can be either a string
+ * @param {object | string} params to append to the URL, can be either a string
* or a map
* @param {string} [url] URL to be used, otherwise the current URL will be used,
* using the params as query string
@@ -124,7 +108,8 @@ export default {
/**
* Parse a query string from the hash part of the URL.
* (workaround for IE8 / IE9)
- * @returns {string}
+ *
+ * @return {string}
*/
_parseHashQuery() {
const hash = window.location.hash
@@ -147,7 +132,7 @@ export default {
* Parse the query/search part of the URL.
* Also try and parse it from the URL hash (for IE8)
*
- * @returns {Object} map of parameters
+ * @return {object} map of parameters
*/
parseUrlQuery() {
const query = this._parseHashQuery()
diff --git a/core/src/OC/util.js b/core/src/OC/util.js
index 611c42d317f..c46d9a141b1 100644
--- a/core/src/OC/util.js
+++ b/core/src/OC/util.js
@@ -1,31 +1,17 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import $ from 'jquery'
import moment from 'moment'
-import History from './util-history'
-import OC from './index'
+import History from './util-history.js'
+import OC from './index.js'
import { formatFileSize as humanFileSize } from '@nextcloud/files'
+/**
+ * @param {any} t -
+ */
function chunkify(t) {
// Adapted from http://my.opera.com/GreyWyvern/blog/show.dml/1671288
const tz = []
@@ -52,6 +38,7 @@ function chunkify(t) {
/**
* Utility functions
+ *
* @namespace OC.Util
*/
export default {
@@ -59,7 +46,7 @@ export default {
History,
/**
- * @deprecated use https://nextcloud.github.io/nextcloud-files/modules/_humanfilesize_.html#formatfilesize
+ * @deprecated use https://nextcloud.github.io/nextcloud-files/functions/formatFileSize.html
*/
humanFileSize,
@@ -67,8 +54,9 @@ export default {
* Returns a file size in bytes from a humanly readable string
* Makes 2kB to 2048.
* Inspired by computerFileSize in helper.php
- * @param {string} string file size in human readable format
- * @returns {number} or null if string could not be parsed
+ *
+ * @param {string} string file size in human-readable format
+ * @return {number} or null if string could not be parsed
*
*
*/
@@ -114,11 +102,11 @@ export default {
/**
* @param {string|number} timestamp timestamp
* @param {string} format date format, see momentjs docs
- * @returns {string} timestamp formatted as requested
+ * @return {string} timestamp formatted as requested
*/
formatDate(timestamp, format) {
if (window.TESTING === undefined) {
- console.warn('OC.Util.formatDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment')
+ OC.debug && console.warn('OC.Util.formatDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment')
}
format = format || 'LLL'
return moment(timestamp).format(format)
@@ -126,11 +114,11 @@ export default {
/**
* @param {string|number} timestamp timestamp
- * @returns {string} human readable difference from now
+ * @return {string} human readable difference from now
*/
relativeModifiedDate(timestamp) {
if (window.TESTING === undefined) {
- console.warn('OC.Util.relativeModifiedDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment')
+ OC.debug && console.warn('OC.Util.relativeModifiedDate is deprecated and will be removed in Nextcloud 21. See @nextcloud/moment')
}
const diff = moment().diff(moment(timestamp))
if (diff >= 0 && diff < 45000) {
@@ -140,18 +128,9 @@ export default {
},
/**
- * Returns whether this is IE
- *
- * @returns {bool} true if this is IE, false otherwise
- */
- isIE() {
- return $('html').hasClass('ie')
- },
-
- /**
* Returns the width of a generic browser scrollbar
*
- * @returns {int} width of scrollbar
+ * @return {number} width of scrollbar
*/
getScrollBarWidth() {
if (this._scrollBarWidth) {
@@ -191,7 +170,7 @@ export default {
* Remove the time component from a given date
*
* @param {Date} date date
- * @returns {Date} date with stripped time
+ * @return {Date} date with stripped time
*/
stripTime(date) {
// FIXME: likely to break when crossing DST
@@ -201,9 +180,10 @@ export default {
/**
* Compare two strings to provide a natural sort
+ *
* @param {string} a first string to compare
* @param {string} b second string to compare
- * @returns {number} -1 if b comes before a, 1 if a comes before b
+ * @return {number} -1 if b comes before a, 1 if a comes before b
* or 0 if the strings are identical
*/
naturalSortCompare(a, b) {
@@ -230,8 +210,9 @@ export default {
/**
* Calls the callback in a given interval until it returns true
- * @param {function} callback function to call on success
- * @param {integer} interval in milliseconds
+ *
+ * @param {Function} callback function to call on success
+ * @param {number} interval in milliseconds
*/
waitFor(callback, interval) {
const internalCallback = function() {
@@ -245,9 +226,10 @@ export default {
/**
* Checks if a cookie with the given name is present and is set to the provided value.
+ *
* @param {string} name name of the cookie
* @param {string} value value of the cookie
- * @returns {boolean} true if the cookie with the given name has the given value
+ * @return {boolean} true if the cookie with the given name has the given value
*/
isCookieSetToValue(name, value) {
const cookies = document.cookie.split(';')
diff --git a/core/src/OC/webroot.js b/core/src/OC/webroot.js
index 89c04a6bb07..cbe5a6190e1 100644
--- a/core/src/OC/webroot.js
+++ b/core/src/OC/webroot.js
@@ -1,22 +1,6 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
let webroot = window._oc_webroot
diff --git a/core/src/OC/xhr-error.js b/core/src/OC/xhr-error.js
index 8d2ad115336..233aaf60350 100644
--- a/core/src/OC/xhr-error.js
+++ b/core/src/OC/xhr-error.js
@@ -1,55 +1,42 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
import $ from 'jquery'
-import OC from './index'
-import Notification from './notification'
+import OC from './index.js'
+import Notification from './notification.js'
+import { getCurrentUser } from '@nextcloud/auth'
+import { showWarning } from '@nextcloud/dialogs'
/**
* Warn users that the connection to the server was lost temporarily
*
- * This function is throttled to prevent stacked notfications.
+ * This function is throttled to prevent stacked notifications.
* After 7sec the first notification is gone, then we can show another one
* if necessary.
*/
export const ajaxConnectionLostHandler = _.throttle(() => {
- Notification.showTemporary(t('core', 'Connection to server lost'))
+ showWarning(t('core', 'Connection to server lost'))
}, 7 * 1000, { trailing: false })
/**
* Process ajax error, redirects to main page
* if an error/auth error status was returned.
+ *
* @param {XMLHttpRequest} xhr xhr request
*/
export const processAjaxError = xhr => {
// purposefully aborted request ?
- // OC._userIsNavigatingAway needed to distinguish ajax calls cancelled by navigating away
- // from calls cancelled by failed cross-domain ajax due to SSO redirect
+ // OC._userIsNavigatingAway needed to distinguish Ajax calls cancelled by navigating away
+ // from calls cancelled by failed cross-domain Ajax due to SSO redirect
if (xhr.status === 0 && (xhr.statusText === 'abort' || xhr.statusText === 'timeout' || OC._reloadCalled)) {
return
}
- if (_.contains([302, 303, 307, 401], xhr.status) && OC.currentUser) {
+ if ([302, 303, 307, 401].includes(xhr.status) && getCurrentUser()) {
// sometimes "beforeunload" happens later, so need to defer the reload a bit
setTimeout(function() {
if (!OC._userIsNavigatingAway && !OC._reloadCalled) {
@@ -62,7 +49,7 @@ export const processAjaxError = xhr => {
OC.reload()
}
timer++
- }, 1000 // 1 second interval
+ }, 1000, // 1 second interval
)
// only call reload once
diff --git a/core/src/OCA/index.js b/core/src/OCA/index.js
index 8cf5e6efeb4..cf5c29ce60a 100644
--- a/core/src/OCA/index.js
+++ b/core/src/OCA/index.js
@@ -1,33 +1,11 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import Search from './search'
-
/**
* Namespace for apps
+ *
* @namespace OCA
*/
-export default {
- /**
- * @deprecated 20.0.0, will be removed in Nextcloud 22
- */
- Search,
-}
+export default { }
diff --git a/core/src/OCA/search.js b/core/src/OCA/search.js
deleted file mode 100644
index f3eba1247f9..00000000000
--- a/core/src/OCA/search.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-/**
- * @deprecated 20.0.0, will be removed in Nextcloud 22
- */
-export default class Search {
-
- /**
- * @deprecated 20.0.0, will be removed in Nextcloud 22
- */
- constructor() {
- console.warn('OCA.Search is deprecated. Please use the unified search API instead')
- }
-
-}
diff --git a/core/src/OCP/accessibility.js b/core/src/OCP/accessibility.js
new file mode 100644
index 00000000000..4a1399f3f96
--- /dev/null
+++ b/core/src/OCP/accessibility.js
@@ -0,0 +1,28 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { loadState } from '@nextcloud/initial-state'
+
+/**
+ * Set the page heading
+ *
+ * @param {string} heading page title from the history api
+ * @since 27.0.0
+ */
+export function setPageHeading(heading) {
+ const headingEl = document.getElementById('page-heading-level-1')
+ if (headingEl) {
+ headingEl.textContent = heading
+ }
+}
+export default {
+ /**
+ * @return {boolean} Whether the user opted-out of shortcuts so that they should not be registered
+ */
+ disableKeyboardShortcuts() {
+ return loadState('theming', 'shortcutsDisabled', false)
+ },
+ setPageHeading,
+}
diff --git a/core/src/OCP/appconfig.js b/core/src/OCP/appconfig.js
index 78ee1643878..78f94922d53 100644
--- a/core/src/OCP/appconfig.js
+++ b/core/src/OCP/appconfig.js
@@ -1,35 +1,21 @@
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
+import { generateOcsUrl } from '@nextcloud/router'
-import OC from '../OC/index'
+import OC from '../OC/index.js'
/**
* @param {string} method 'post' or 'delete'
* @param {string} endpoint endpoint
- * @param {Object} [options] destructuring object
- * @param {Object} [options.data] option data
- * @param {function} [options.success] success callback
- * @param {function} [options.error] error callback
- * @internal
+ * @param {object} [options] destructuring object
+ * @param {object} [options.data] option data
+ * @param {Function} [options.success] success callback
+ * @param {Function} [options.error] error callback
*/
function call(method, endpoint, options) {
if ((method === 'post' || method === 'delete') && OC.PasswordConfirmation.requiresPasswordConfirmation()) {
@@ -40,7 +26,7 @@ function call(method, endpoint, options) {
options = options || {}
$.ajax({
type: method.toUpperCase(),
- url: OC.linkToOCS('apps/provisioning_api/api/v1', 2) + 'config/apps' + endpoint,
+ url: generateOcsUrl('apps/provisioning_api/api/v1/config/apps') + endpoint,
data: options.data || {},
success: options.success,
error: options.error,
@@ -48,8 +34,8 @@ function call(method, endpoint, options) {
}
/**
- * @param {Object} [options] destructuring object
- * @param {function} [options.success] success callback
+ * @param {object} [options] destructuring object
+ * @param {Function} [options.success] success callback
* @since 11.0.0
*/
export function getApps(options) {
@@ -58,9 +44,9 @@ export function getApps(options) {
/**
* @param {string} app app id
- * @param {Object} [options] destructuring object
- * @param {function} [options.success] success callback
- * @param {function} [options.error] error callback
+ * @param {object} [options] destructuring object
+ * @param {Function} [options.success] success callback
+ * @param {Function} [options.error] error callback
* @since 11.0.0
*/
export function getKeys(app, options) {
@@ -70,10 +56,10 @@ export function getKeys(app, options) {
/**
* @param {string} app app id
* @param {string} key key
- * @param {string|function} defaultValue default value
- * @param {Object} [options] destructuring object
- * @param {function} [options.success] success callback
- * @param {function} [options.error] error callback
+ * @param {string | Function} defaultValue default value
+ * @param {object} [options] destructuring object
+ * @param {Function} [options.success] success callback
+ * @param {Function} [options.error] error callback
* @since 11.0.0
*/
export function getValue(app, key, defaultValue, options) {
@@ -89,9 +75,9 @@ export function getValue(app, key, defaultValue, options) {
* @param {string} app app id
* @param {string} key key
* @param {string} value value
- * @param {Object} [options] destructuring object
- * @param {function} [options.success] success callback
- * @param {function} [options.error] error callback
+ * @param {object} [options] destructuring object
+ * @param {Function} [options.success] success callback
+ * @param {Function} [options.error] error callback
* @since 11.0.0
*/
export function setValue(app, key, value, options) {
@@ -106,9 +92,9 @@ export function setValue(app, key, value, options) {
/**
* @param {string} app app id
* @param {string} key key
- * @param {Object} [options] destructuring object
- * @param {function} [options.success] success callback
- * @param {function} [options.error] error callback
+ * @param {object} [options] destructuring object
+ * @param {Function} [options.success] success callback
+ * @param {Function} [options.error] error callback
* @since 11.0.0
*/
export function deleteKey(app, key, options) {
diff --git a/core/src/OCP/collaboration.js b/core/src/OCP/collaboration.js
index 73573b3f1f7..82ff34392cf 100644
--- a/core/src/OCP/collaboration.js
+++ b/core/src/OCP/collaboration.js
@@ -1,43 +1,27 @@
/**
- * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- *
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import escapeHTML from 'escape-html'
/**
* @typedef TypeDefinition
- * @method {callback} action This action is executed to let the user select a resource
+ * @function action This action is executed to let the user select a resource
* @param {string} icon Contains the icon css class for the type
- * @constructor
+ * @function Object() { [native code] }
*/
/**
* @type {TypeDefinition[]}
- **/
+ */
const types = {}
/**
* Those translations will be used by the vue component but they should be shipped with the server
* FIXME: Those translations should be added to the library
- * @returns {Array}
+ *
+ * @return {Array}
*/
export const l10nProjects = () => {
return [
diff --git a/core/src/OCP/comments.js b/core/src/OCP/comments.js
index 2e12accddce..34699a477d1 100644
--- a/core/src/OCP/comments.js
+++ b/core/src/OCP/comments.js
@@ -1,10 +1,6 @@
/**
- * @copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * This file is licensed under the Affero General Public License version 3 or
- * later. See the COPYING file.
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -12,22 +8,33 @@ import $ from 'jquery'
/*
* Detects links:
* Either the http(s) protocol is given or two strings, basically limited to ascii with the last
- * word being at least one digit long,
+ * word being at least one digit long,
* followed by at least another character
*
* The downside: anything not ascii is excluded. Not sure how common it is in areas using different
* alphabets… the upside: fake domains with similar looking characters won't be formatted as links
+ *
+ * This is a copy of the backend regex in IURLGenerator, make sure to adjust both when changing
*/
-const urlRegex = /(\s|^)(https?:\/\/)?((?:[-A-Z0-9+_]+\.)+[-A-Z]+(?:\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\s|$)/ig
+const urlRegex = /(\s|^)(https?:\/\/)([-A-Z0-9+_.]+(?::[0-9]+)?(?:\/[-A-Z0-9+&@#%?=~_|!:,.;()]*)*)(\s|$)/ig
+/**
+ * @param {any} content -
+ */
export function plainToRich(content) {
return this.formatLinksRich(content)
}
+/**
+ * @param {any} content -
+ */
export function richToPlain(content) {
return this.formatLinksPlain(content)
}
+/**
+ * @param {any} content -
+ */
export function formatLinksRich(content) {
return content.replace(urlRegex, function(_, leadingSpace, protocol, url, trailingSpace) {
let linkText = url
@@ -41,6 +48,9 @@ export function formatLinksRich(content) {
})
}
+/**
+ * @param {any} content -
+ */
export function formatLinksPlain(content) {
const $content = $('<div></div>').html(content)
$content.find('a').each(function() {
diff --git a/core/src/OCP/index.js b/core/src/OCP/index.js
index 4f2c47f1fa4..94f4e8e5eb3 100644
--- a/core/src/OCP/index.js
+++ b/core/src/OCP/index.js
@@ -1,13 +1,22 @@
-import * as AppConfig from './appconfig'
-import * as Comments from './comments'
-import Loader from './loader'
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
import { loadState } from '@nextcloud/initial-state'
-import Collaboration from './collaboration'
-import * as WhatsNew from './whatsnew'
-import Toast from './toast'
+
+import * as AppConfig from './appconfig.js'
+import * as Comments from './comments.js'
+import * as WhatsNew from './whatsnew.js'
+
+import Accessibility from './accessibility.js'
+import Collaboration from './collaboration.js'
+import Loader from './loader.js'
+import Toast from './toast.js'
/** @namespace OCP */
export default {
+ Accessibility,
AppConfig,
Collaboration,
Comments,
diff --git a/core/src/OCP/loader.js b/core/src/OCP/loader.js
index 26beaffca33..d307eb27996 100644
--- a/core/src/OCP/loader.js
+++ b/core/src/OCP/loader.js
@@ -1,25 +1,10 @@
/**
- * @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
- *
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { generateFilePath } from '@nextcloud/router'
+
const loadedScripts = {}
const loadedStylesheets = {}
/**
@@ -33,7 +18,7 @@ export default {
*
* @param {string} app the app name
* @param {string} file the script file name
- * @returns {Promise}
+ * @return {Promise}
*/
loadScript(app, file) {
const key = app + file
@@ -42,7 +27,7 @@ export default {
}
loadedScripts[key] = true
return new Promise(function(resolve, reject) {
- const scriptPath = OC.filePath(app, 'js', file)
+ const scriptPath = generateFilePath(app, 'js', file)
const script = document.createElement('script')
script.src = scriptPath
script.setAttribute('nonce', btoa(OC.requestToken))
@@ -57,7 +42,7 @@ export default {
*
* @param {string} app the app name
* @param {string} file the script file name
- * @returns {Promise}
+ * @return {Promise}
*/
loadStylesheet(app, file) {
const key = app + file
@@ -66,7 +51,7 @@ export default {
}
loadedStylesheets[key] = true
return new Promise(function(resolve, reject) {
- const stylePath = OC.filePath(app, 'css', file)
+ const stylePath = generateFilePath(app, 'css', file)
const link = document.createElement('link')
link.href = stylePath
link.type = 'text/css'
diff --git a/core/src/OCP/toast.js b/core/src/OCP/toast.js
index e5331377dc9..f93344bbc8e 100644
--- a/core/src/OCP/toast.js
+++ b/core/src/OCP/toast.js
@@ -1,23 +1,6 @@
-/*
- * @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
@@ -27,13 +10,15 @@ import {
showWarning,
} from '@nextcloud/dialogs'
+/** @typedef {import('toastify-js')} Toast */
+
export default {
/**
* @deprecated 19.0.0 use `showSuccess` from the `@nextcloud/dialogs` package instead
*
* @param {string} text the toast text
* @param {object} options options
- * @returns {Toast}
+ * @return {Toast}
*/
success(text, options) {
return showSuccess(text, options)
@@ -43,7 +28,7 @@ export default {
*
* @param {string} text the toast text
* @param {object} options options
- * @returns {Toast}
+ * @return {Toast}
*/
warning(text, options) {
return showWarning(text, options)
@@ -53,7 +38,7 @@ export default {
*
* @param {string} text the toast text
* @param {object} options options
- * @returns {Toast}
+ * @return {Toast}
*/
error(text, options) {
return showError(text, options)
@@ -63,7 +48,7 @@ export default {
*
* @param {string} text the toast text
* @param {object} options options
- * @returns {Toast}
+ * @return {Toast}
*/
info(text, options) {
return showInfo(text, options)
@@ -73,7 +58,7 @@ export default {
*
* @param {string} text the toast text
* @param {object} options options
- * @returns {Toast}
+ * @return {Toast}
*/
message(text, options) {
return showMessage(text, options)
diff --git a/core/src/OCP/whatsnew.js b/core/src/OCP/whatsnew.js
index 17c9eeabce2..acada6a8383 100644
--- a/core/src/OCP/whatsnew.js
+++ b/core/src/OCP/whatsnew.js
@@ -1,23 +1,21 @@
/**
- * @copyright (c) 2017 Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
- *
- * This file is licensed under the Affero General Public License version 3 or
- * later. See the COPYING file.
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import _ from 'underscore'
import $ from 'jquery'
+import { generateOcsUrl } from '@nextcloud/router'
-import OC from '../OC/index'
-
+/**
+ * @param {any} options -
+ */
export function query(options) {
options = options || {}
const dismissOptions = options.dismiss || {}
$.ajax({
type: 'GET',
- url: options.url || OC.linkToOCS('core', 2) + 'whatsnew?format=json',
+ url: options.url || generateOcsUrl('core/whatsnew?format=json'),
success: options.success || function(data, statusText, xhr) {
onQuerySuccess(data, statusText, xhr, dismissOptions)
},
@@ -25,11 +23,15 @@ export function query(options) {
})
}
+/**
+ * @param {any} version -
+ * @param {any} options -
+ */
export function dismiss(version, options) {
options = options || {}
$.ajax({
type: 'POST',
- url: options.url || OC.linkToOCS('core', 2) + 'whatsnew',
+ url: options.url || generateOcsUrl('core/whatsnew'),
data: { version: encodeURIComponent(version) },
success: options.success || onDismissSuccess,
error: options.error || onDismissError,
@@ -38,6 +40,12 @@ export function dismiss(version, options) {
$('.whatsNewPopover').remove()
}
+/**
+ * @param {any} data -
+ * @param {any} statusText -
+ * @param {any} xhr -
+ * @param {any} dismissOptions -
+ */
function onQuerySuccess(data, statusText, xhr, dismissOptions) {
console.debug('querying Whats New data was successful: ' + statusText)
console.debug(data)
@@ -118,15 +126,26 @@ function onQuerySuccess(data, statusText, xhr, dismissOptions) {
document.body.appendChild(div)
}
+/**
+ * @param {any} x -
+ * @param {any} t -
+ * @param {any} e -
+ */
function onQueryError(x, t, e) {
console.debug('querying Whats New Data resulted in an error: ' + t + e)
console.debug(x)
}
+/**
+ * @param {any} data -
+ */
function onDismissSuccess(data) {
// noop
}
+/**
+ * @param {any} data -
+ */
function onDismissError(data) {
console.debug('dismissing Whats New data resulted in an error: ' + data)
}
diff --git a/core/src/Polyfill/closest.js b/core/src/Polyfill/closest.js
deleted file mode 100644
index 6af05d526a7..00000000000
--- a/core/src/Polyfill/closest.js
+++ /dev/null
@@ -1,19 +0,0 @@
-// https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
-
-if (!Element.prototype.matches) {
- Element.prototype.matches
- = Element.prototype.msMatchesSelector
- || Element.prototype.webkitMatchesSelector
-}
-
-if (!Element.prototype.closest) {
- Element.prototype.closest = function(s) {
- let el = this
-
- do {
- if (el.matches(s)) return el
- el = el.parentElement || el.parentNode
- } while (el !== null && el.nodeType === 1)
- return null
- }
-}
diff --git a/core/src/Polyfill/console.js b/core/src/Polyfill/console.js
deleted file mode 100644
index 0d60fe6f20d..00000000000
--- a/core/src/Polyfill/console.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/* eslint-disable no-console */
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-if (typeof console === 'undefined' || typeof console.log === 'undefined') {
- if (!window.console) {
- window.console = {}
- }
- const noOp = () => {}
- const methods = ['log', 'debug', 'warn', 'info', 'error', 'assert', 'time', 'timeEnd']
- for (let i = 0; i < methods.length; i++) {
- console[methods[i]] = noOp
- }
-}
diff --git a/core/src/Polyfill/index.js b/core/src/Polyfill/index.js
deleted file mode 100644
index 306c72a0777..00000000000
--- a/core/src/Polyfill/index.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-import './console'
-import './closest'
-import './windows-phone'
diff --git a/core/src/Polyfill/tooltip.js b/core/src/Polyfill/tooltip.js
deleted file mode 100644
index 2dd7592edb4..00000000000
--- a/core/src/Polyfill/tooltip.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * @copyright 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @author 2019 Julius Härtl <jus@bitgrid.net>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-$.prototype.tooltip = (function(tooltip) {
- return function(config) {
- try {
- return tooltip.call(this, config)
- } catch (ex) {
- if (ex instanceof TypeError && config === 'destroy') {
- if (window.TESTING === undefined) {
- console.error('Deprecated call $.tooltip(\'destroy\') has been deprecated and should be removed')
- }
- return tooltip.call(this, 'dispose')
- }
- if (ex instanceof TypeError && config === 'fixTitle') {
- if (window.TESTING === undefined) {
- console.error('Deprecated call $.tooltip(\'fixTitle\') has been deprecated and should be removed')
- }
- return tooltip.call(this, '_fixTitle')
- }
- }
- }
-})($.prototype.tooltip)
diff --git a/core/src/Polyfill/windows-phone.js b/core/src/Polyfill/windows-phone.js
deleted file mode 100644
index 983e412e453..00000000000
--- a/core/src/Polyfill/windows-phone.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-// fix device width on windows phone
-if ('-ms-user-select' in document.documentElement.style && navigator.userAgent.match(/IEMobile\/10\.0/)) {
- const msViewportStyle = document.createElement('style')
- msViewportStyle.appendChild(
- document.createTextNode('@-ms-viewport{width:auto!important}')
- )
- document.getElementsByTagName('head')[0].appendChild(msViewportStyle)
-}
diff --git a/core/src/Util/a11y.js b/core/src/Util/a11y.js
new file mode 100644
index 00000000000..2eb753b3faf
--- /dev/null
+++ b/core/src/Util/a11y.js
@@ -0,0 +1,21 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Return whether the DOM event is an accessible mouse or keyboard element activation
+ *
+ * @param {Event} event DOM event
+ *
+ * @return {boolean}
+ */
+export const isA11yActivation = (event) => {
+ if (event.type === 'click') {
+ return true
+ }
+ if (event.type === 'keydown' && event.key === 'Enter') {
+ return true
+ }
+ return false
+}
diff --git a/core/src/Util/get-url-parameter.js b/core/src/Util/get-url-parameter.js
index 6f809994f10..6df264f009f 100644
--- a/core/src/Util/get-url-parameter.js
+++ b/core/src/Util/get-url-parameter.js
@@ -1,33 +1,14 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
- * Get the value of a URL parameter
- * @link http://stackoverflow.com/questions/1403888/get-url-parameter-with-jquery
- * @param {string} name URL parameter
- * @returns {string}
+ * @param {any} name -
*/
export default function getURLParameter(name) {
return decodeURIComponent(
// eslint-disable-next-line no-sparse-arrays
- (new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [, ''])[1].replace(/\+/g, '%20')
+ (new RegExp('[?|&]' + name + '=' + '([^&;]+?)(&|#|;|$)').exec(location.search) || [, ''])[1].replace(/\+/g, '%20'),
) || ''
}
diff --git a/core/src/ajax-cron.ts b/core/src/ajax-cron.ts
new file mode 100644
index 00000000000..d903a3596ea
--- /dev/null
+++ b/core/src/ajax-cron.ts
@@ -0,0 +1,18 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getRootUrl } from '@nextcloud/router'
+import logger from './logger'
+
+window.addEventListener('DOMContentLoaded', async () => {
+ // When the page is loaded send GET to the cron endpoint to trigger background jobs
+ try {
+ logger.debug('Running web cron')
+ await window.fetch(`${getRootUrl()}/cron.php`)
+ logger.debug('Web cron successfull')
+ } catch (e) {
+ logger.debug('Running web cron failed', { error: e })
+ }
+})
diff --git a/core/src/components/AccountMenu/AccountMenuEntry.vue b/core/src/components/AccountMenu/AccountMenuEntry.vue
new file mode 100644
index 00000000000..d983226d273
--- /dev/null
+++ b/core/src/components/AccountMenu/AccountMenuEntry.vue
@@ -0,0 +1,117 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcListItem :id="href ? undefined : id"
+ :anchor-id="id"
+ :active="active"
+ class="account-menu-entry"
+ compact
+ :href="href"
+ :name="name"
+ target="_self"
+ @click="onClick">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" :size="20" class="account-menu-entry__loading" />
+ <slot v-else-if="$scopedSlots.icon" name="icon" />
+ <img v-else
+ class="account-menu-entry__icon"
+ :class="{ 'account-menu-entry__icon--active': active }"
+ :src="iconSource"
+ alt="">
+ </template>
+ </NcListItem>
+</template>
+
+<script lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { defineComponent } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+const versionHash = loadState('core', 'versionHash', '')
+
+export default defineComponent({
+ name: 'AccountMenuEntry',
+
+ components: {
+ NcListItem,
+ NcLoadingIcon,
+ },
+
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ default: false,
+ },
+ icon: {
+ type: String,
+ default: '',
+ },
+ },
+
+ data() {
+ return {
+ loading: false,
+ }
+ },
+
+ computed: {
+ iconSource() {
+ return `${this.icon}?v=${versionHash}`
+ },
+ },
+
+ methods: {
+ onClick(e: MouseEvent) {
+ this.$emit('click', e)
+
+ // Allow to not show the loading indicator
+ // in case the click event was already handled
+ if (!e.defaultPrevented) {
+ this.loading = true
+ }
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.account-menu-entry {
+ &__icon {
+ height: 16px;
+ width: 16px;
+ margin: calc((var(--default-clickable-area) - 16px) / 2); // 16px icon size
+ filter: var(--background-invert-if-dark);
+
+ &--active {
+ filter: var(--primary-invert-if-dark);
+ }
+ }
+
+ &__loading {
+ height: 20px;
+ width: 20px;
+ margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size
+ }
+
+ :deep(.list-item-content__main) {
+ width: fit-content;
+ }
+}
+</style>
diff --git a/core/src/components/AccountMenu/AccountMenuProfileEntry.vue b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
new file mode 100644
index 00000000000..8b895b8ca31
--- /dev/null
+++ b/core/src/components/AccountMenu/AccountMenuProfileEntry.vue
@@ -0,0 +1,100 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcListItem :id="profileEnabled ? undefined : id"
+ :anchor-id="id"
+ :active="active"
+ compact
+ :href="profileEnabled ? href : undefined"
+ :name="displayName"
+ target="_self">
+ <template v-if="profileEnabled" #subname>
+ {{ name }}
+ </template>
+ <template v-if="loading" #indicator>
+ <NcLoadingIcon />
+ </template>
+ </NcListItem>
+</template>
+
+<script lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { getCurrentUser } from '@nextcloud/auth'
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { defineComponent } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+
+const { profileEnabled } = loadState('user_status', 'profileEnabled', { profileEnabled: false })
+
+export default defineComponent({
+ name: 'AccountMenuProfileEntry',
+
+ components: {
+ NcListItem,
+ NcLoadingIcon,
+ },
+
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ setup() {
+ return {
+ profileEnabled,
+ displayName: getCurrentUser()!.displayName,
+ }
+ },
+
+ data() {
+ return {
+ loading: false,
+ }
+ },
+
+ mounted() {
+ subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
+ subscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
+ },
+
+ beforeDestroy() {
+ unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate)
+ unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate)
+ },
+
+ methods: {
+ handleClick() {
+ if (this.profileEnabled) {
+ this.loading = true
+ }
+ },
+
+ handleProfileEnabledUpdate(profileEnabled: boolean) {
+ this.profileEnabled = profileEnabled
+ },
+
+ handleDisplayNameUpdate(displayName: string) {
+ this.displayName = displayName
+ },
+ },
+})
+</script>
diff --git a/core/src/components/AppMenu.vue b/core/src/components/AppMenu.vue
new file mode 100644
index 00000000000..88f626ff569
--- /dev/null
+++ b/core/src/components/AppMenu.vue
@@ -0,0 +1,161 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <nav ref="appMenu"
+ class="app-menu"
+ :aria-label="t('core', 'Applications menu')">
+ <ul :aria-label="t('core', 'Apps')"
+ class="app-menu__list">
+ <AppMenuEntry v-for="app in mainAppList"
+ :key="app.id"
+ :app="app" />
+ </ul>
+ <NcActions class="app-menu__overflow" :aria-label="t('core', 'More apps')">
+ <NcActionLink v-for="app in popoverAppList"
+ :key="app.id"
+ :aria-current="app.active ? 'page' : false"
+ :href="app.href"
+ :icon="app.icon"
+ class="app-menu__overflow-entry">
+ {{ app.name }}
+ </NcActionLink>
+ </NcActions>
+ </nav>
+</template>
+
+<script lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+
+import { subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { n, t } from '@nextcloud/l10n'
+import { useElementSize } from '@vueuse/core'
+import { defineComponent, ref } from 'vue'
+
+import AppMenuEntry from './AppMenuEntry.vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import logger from '../logger'
+
+export default defineComponent({
+ name: 'AppMenu',
+
+ components: {
+ AppMenuEntry,
+ NcActions,
+ NcActionLink,
+ },
+
+ setup() {
+ const appMenu = ref()
+ const { width: appMenuWidth } = useElementSize(appMenu)
+ return {
+ t,
+ n,
+ appMenu,
+ appMenuWidth,
+ }
+ },
+
+ data() {
+ const appList = loadState<INavigationEntry[]>('core', 'apps', [])
+ return {
+ appList,
+ }
+ },
+
+ computed: {
+ appLimit() {
+ const maxApps = Math.floor(this.appMenuWidth / 50)
+ if (maxApps < this.appList.length) {
+ // Ensure there is space for the overflow menu
+ return Math.max(maxApps - 1, 0)
+ }
+ return maxApps
+ },
+
+ mainAppList() {
+ return this.appList.slice(0, this.appLimit)
+ },
+
+ popoverAppList() {
+ return this.appList.slice(this.appLimit)
+ },
+ },
+
+ mounted() {
+ subscribe('nextcloud:app-menu.refresh', this.setApps)
+ },
+
+ beforeDestroy() {
+ unsubscribe('nextcloud:app-menu.refresh', this.setApps)
+ },
+
+ methods: {
+ setNavigationCounter(id: string, counter: number) {
+ const app = this.appList.find(({ app }) => app === id)
+ if (app) {
+ this.$set(app, 'unread', counter)
+ } else {
+ logger.warn(`Could not find app "${id}" for setting navigation count`)
+ }
+ },
+
+ setApps({ apps }: { apps: INavigationEntry[]}) {
+ this.appList = apps
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.app-menu {
+ // The size the currently focussed entry will grow to show the full name
+ --app-menu-entry-growth: calc(var(--default-grid-baseline) * 4);
+ display: flex;
+ flex: 1 1;
+ width: 0;
+
+ &__list {
+ display: flex;
+ flex-wrap: nowrap;
+ margin-inline: calc(var(--app-menu-entry-growth) / 2);
+ }
+
+ &__overflow {
+ margin-block: auto;
+
+ // Adjust the overflow NcActions styles as they are directly rendered on the background
+ :deep(.button-vue--vue-tertiary) {
+ opacity: .7;
+ margin: 3px;
+ filter: var(--background-image-invert-if-bright);
+
+ /* Remove all background and align text color if not expanded */
+ &:not([aria-expanded="true"]) {
+ color: var(--color-background-plain-text);
+
+ &:hover {
+ opacity: 1;
+ background-color: transparent !important;
+ }
+ }
+
+ &:focus-visible {
+ opacity: 1;
+ outline: none !important;
+ }
+ }
+ }
+
+ &__overflow-entry {
+ :deep(.action-link__icon) {
+ // Icons are bright so invert them if bright color theme == bright background is used
+ filter: var(--background-invert-if-bright) !important;
+ }
+ }
+}
+</style>
diff --git a/core/src/components/AppMenuEntry.vue b/core/src/components/AppMenuEntry.vue
new file mode 100644
index 00000000000..4c5acb7e9c8
--- /dev/null
+++ b/core/src/components/AppMenuEntry.vue
@@ -0,0 +1,189 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <li ref="containerElement"
+ class="app-menu-entry"
+ :class="{
+ 'app-menu-entry--active': app.active,
+ 'app-menu-entry--truncated': needsSpace,
+ }">
+ <a class="app-menu-entry__link"
+ :href="app.href"
+ :title="app.name"
+ :aria-current="app.active ? 'page' : false"
+ :target="app.target ? '_blank' : undefined"
+ :rel="app.target ? 'noopener noreferrer' : undefined">
+ <AppMenuIcon class="app-menu-entry__icon" :app="app" />
+ <span ref="labelElement" class="app-menu-entry__label">
+ {{ app.name }}
+ </span>
+ </a>
+ </li>
+</template>
+
+<script setup lang="ts">
+import type { INavigationEntry } from '../types/navigation'
+import { onMounted, ref, watch } from 'vue'
+import AppMenuIcon from './AppMenuIcon.vue'
+
+const props = defineProps<{
+ app: INavigationEntry
+}>()
+
+const containerElement = ref<HTMLLIElement>()
+const labelElement = ref<HTMLSpanElement>()
+const needsSpace = ref(false)
+
+/** Update the space requirements of the app label */
+function calculateSize() {
+ const maxWidth = containerElement.value!.clientWidth
+ // Also keep the 0.5px letter spacing in mind
+ needsSpace.value = (maxWidth - props.app.name.length * 0.5) < (labelElement.value!.scrollWidth)
+}
+// Update size on mounted and when the app name changes
+onMounted(calculateSize)
+watch(() => props.app.name, calculateSize)
+</script>
+
+<style scoped lang="scss">
+.app-menu-entry {
+ --app-menu-entry-font-size: 12px;
+ width: var(--header-height);
+ height: var(--header-height);
+ position: relative;
+
+ &__link {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ // Set color as this is shown directly on the background
+ color: var(--color-background-plain-text);
+ // Make space for focus-visible outline
+ width: calc(100% - 4px);
+ height: calc(100% - 4px);
+ margin: 2px;
+ }
+
+ &__label {
+ opacity: 0;
+ position: absolute;
+ font-size: var(--app-menu-entry-font-size);
+ // this is shown directly on the background
+ color: var(--color-background-plain-text);
+ text-align: center;
+ bottom: 0;
+ inset-inline-start: 50%;
+ top: 50%;
+ display: block;
+ transform: translateX(-50%);
+ max-width: 100%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ letter-spacing: -0.5px;
+ }
+ body[dir=rtl] &__label {
+ transform: translateX(50%) !important;
+ }
+
+ &__icon {
+ font-size: var(--app-menu-entry-font-size);
+ }
+
+ &--active {
+ // When hover or focus, show the label and make it bolder than the other entries
+ .app-menu-entry__label {
+ font-weight: bolder;
+ }
+
+ // When active show a line below the entry as an "active" indicator
+ &::before {
+ content: " ";
+ position: absolute;
+ pointer-events: none;
+ border-bottom-color: var(--color-main-background);
+ transform: translateX(-50%);
+ width: 10px;
+ height: 5px;
+ border-radius: 3px;
+ background-color: var(--color-background-plain-text);
+ inset-inline-start: 50%;
+ bottom: 8px;
+ display: block;
+ transition: all var(--animation-quick) ease-in-out;
+ opacity: 1;
+ }
+ body[dir=rtl] &::before {
+ transform: translateX(50%) !important;
+ }
+ }
+
+ &__icon,
+ &__label {
+ transition: all var(--animation-quick) ease-in-out;
+ }
+
+ // Make the hovered entry bold to see that it is hovered
+ &:hover .app-menu-entry__label,
+ &:focus-within .app-menu-entry__label {
+ font-weight: bold;
+ }
+
+ // Adjust the width when an entry is focussed
+ // The focussed / hovered entry should grow, while both neighbors need to shrink
+ &--truncated:hover,
+ &--truncated:focus-within {
+ .app-menu-entry__label {
+ max-width: calc(var(--header-height) + var(--app-menu-entry-growth));
+ }
+
+ // The next entry needs to shrink half the growth
+ + .app-menu-entry {
+ .app-menu-entry__label {
+ font-weight: normal;
+ max-width: calc(var(--header-height) - var(--app-menu-entry-growth));
+ }
+ }
+ }
+
+ // The previous entry needs to shrink half the growth
+ &:has(+ .app-menu-entry--truncated:hover),
+ &:has(+ .app-menu-entry--truncated:focus-within) {
+ .app-menu-entry__label {
+ font-weight: normal;
+ max-width: calc(var(--header-height) - var(--app-menu-entry-growth));
+ }
+ }
+}
+</style>
+
+<style lang="scss">
+// Showing the label
+.app-menu-entry:hover,
+.app-menu-entry:focus-within,
+.app-menu__list:hover,
+.app-menu__list:focus-within {
+ // Move icon up so that the name does not overflow the icon
+ .app-menu-entry__icon {
+ margin-block-end: 1lh;
+ }
+
+ // Make the label visible
+ .app-menu-entry__label {
+ opacity: 1;
+ }
+
+ // Hide indicator when the text is shown
+ .app-menu-entry--active::before {
+ opacity: 0;
+ }
+
+ .app-menu-icon__unread {
+ opacity: 0;
+ }
+}
+</style>
diff --git a/core/src/components/AppMenuIcon.vue b/core/src/components/AppMenuIcon.vue
new file mode 100644
index 00000000000..1b0d48daf8c
--- /dev/null
+++ b/core/src/components/AppMenuIcon.vue
@@ -0,0 +1,67 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+
+<template>
+ <span class="app-menu-icon"
+ role="img"
+ :aria-hidden="ariaHidden"
+ :aria-label="ariaLabel">
+ <img class="app-menu-icon__icon" :src="app.icon" alt="">
+ <IconDot v-if="app.unread" class="app-menu-icon__unread" :size="10" />
+ </span>
+</template>
+
+<script setup lang="ts">
+import type { INavigationEntry } from '../types/navigation.ts'
+
+import { n } from '@nextcloud/l10n'
+import { computed } from 'vue'
+import IconDot from 'vue-material-design-icons/CircleOutline.vue'
+
+const props = defineProps<{
+ app: INavigationEntry
+}>()
+
+// only hide if there are no unread notifications
+const ariaHidden = computed(() => !props.app.unread ? 'true' : undefined)
+
+const ariaLabel = computed(() => {
+ if (!props.app.unread) {
+ return undefined
+ }
+
+ return `${props.app.name} (${n('core', '{count} notification', '{count} notifications', props.app.unread, { count: props.app.unread })})`
+})
+</script>
+
+<style scoped lang="scss">
+$icon-size: 20px;
+$unread-indicator-size: 10px;
+
+.app-menu-icon {
+ box-sizing: border-box;
+ position: relative;
+
+ height: $icon-size;
+ width: $icon-size;
+
+ &__icon {
+ transition: margin 0.1s ease-in-out;
+ height: $icon-size;
+ width: $icon-size;
+ filter: var(--background-image-invert-if-bright);
+ mask: var(--header-menu-icon-mask);
+ }
+
+ &__unread {
+ color: var(--color-error);
+ position: absolute;
+ // Align the dot to the top right corner of the icon
+ inset-block-end: calc($icon-size + ($unread-indicator-size / -2));
+ inset-inline-end: calc($unread-indicator-size / -2);
+ transition: all 0.1s ease-in-out;
+ }
+}
+</style>
diff --git a/core/src/components/ContactsMenu.js b/core/src/components/ContactsMenu.js
index 0791fe83b0c..e07a699ab9f 100644
--- a/core/src/components/ContactsMenu.js
+++ b/core/src/components/ContactsMenu.js
@@ -1,34 +1,23 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import $ from 'jquery'
-import OC from '../OC'
+import Vue from 'vue'
+
+import ContactsMenu from '../views/ContactsMenu.vue'
/**
* @todo move to contacts menu code https://github.com/orgs/nextcloud/projects/31#card-21213129
*/
export const setUp = () => {
- // eslint-disable-next-line no-new
- new OC.ContactsMenu({
- el: $('#contactsmenu .menu'),
- trigger: $('#contactsmenu .menutoggle'),
- })
+ const mountPoint = document.getElementById('contactsmenu')
+ if (mountPoint) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ name: 'ContactsMenuRoot',
+ el: mountPoint,
+ render: h => h(ContactsMenu),
+ })
+ }
}
diff --git a/core/src/components/ContactsMenu/Contact.vue b/core/src/components/ContactsMenu/Contact.vue
new file mode 100644
index 00000000000..322f53647b1
--- /dev/null
+++ b/core/src/components/ContactsMenu/Contact.vue
@@ -0,0 +1,193 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <li class="contact">
+ <NcAvatar class="contact__avatar"
+ :size="44"
+ :user="contact.isUser ? contact.uid : undefined"
+ :is-no-user="!contact.isUser"
+ :disable-menu="true"
+ :display-name="contact.avatarLabel"
+ :preloaded-user-status="preloadedUserStatus" />
+ <a class="contact__body"
+ :href="contact.profileUrl || contact.topAction?.hyperlink">
+ <div class="contact__body__full-name">{{ contact.fullName }}</div>
+ <div v-if="contact.lastMessage" class="contact__body__last-message">{{ contact.lastMessage }}</div>
+ <div v-if="contact.statusMessage" class="contact__body__status-message">{{ contact.statusMessage }}</div>
+ <div v-else class="contact__body__email-address">{{ contact.emailAddresses[0] }}</div>
+ </a>
+ <NcActions v-if="actions.length"
+ :inline="contact.topAction ? 1 : 0">
+ <template v-for="(action, idx) in actions">
+ <NcActionLink v-if="action.hyperlink !== '#'"
+ :key="`${idx}-link`"
+ :href="action.hyperlink"
+ class="other-actions">
+ <template #icon>
+ <img aria-hidden="true" class="contact__action__icon" :src="action.icon">
+ </template>
+ {{ action.title }}
+ </NcActionLink>
+ <NcActionText v-else :key="`${idx}-text`" class="other-actions">
+ <template #icon>
+ <img aria-hidden="true" class="contact__action__icon" :src="action.icon">
+ </template>
+ {{ action.title }}
+ </NcActionText>
+ </template>
+ <NcActionButton v-for="action in jsActions"
+ :key="action.id"
+ :close-after-click="true"
+ class="other-actions"
+ @click="action.callback(contact)">
+ <template #icon>
+ <NcIconSvgWrapper class="contact__action__icon-svg"
+ :svg="action.iconSvg(contact)" />
+ </template>
+ {{ action.displayName(contact) }}
+ </NcActionButton>
+ </NcActions>
+ </li>
+</template>
+
+<script>
+import NcActionLink from '@nextcloud/vue/components/NcActionLink'
+import NcActionText from '@nextcloud/vue/components/NcActionText'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import { getEnabledContactsMenuActions } from '@nextcloud/vue/functions/contactsMenu'
+
+export default {
+ name: 'Contact',
+ components: {
+ NcActionLink,
+ NcActionText,
+ NcActionButton,
+ NcActions,
+ NcAvatar,
+ NcIconSvgWrapper,
+ },
+ props: {
+ contact: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ actions() {
+ if (this.contact.topAction) {
+ return [this.contact.topAction, ...this.contact.actions]
+ }
+ return this.contact.actions
+ },
+ jsActions() {
+ return getEnabledContactsMenuActions(this.contact)
+ },
+ preloadedUserStatus() {
+ if (this.contact.status) {
+ return {
+ status: this.contact.status,
+ message: this.contact.statusMessage,
+ icon: this.contact.statusIcon,
+ }
+ }
+ return undefined
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.contact {
+ display: flex;
+ position: relative;
+ align-items: center;
+ padding: 3px;
+ padding-inline-start: 10px;
+
+ &__action {
+ &__icon {
+ width: 20px;
+ height: 20px;
+ padding: 12px;
+ filter: var(--background-invert-if-dark);
+ }
+
+ &__icon-svg {
+ padding: 5px;
+ }
+ }
+
+ &__avatar {
+ display: inherit;
+ }
+
+ &__body {
+ flex-grow: 1;
+ padding-inline-start: 10px;
+ margin-inline-start: 10px;
+ min-width: 0;
+
+ div {
+ position: relative;
+ width: 100%;
+ overflow-x: hidden;
+ text-overflow: ellipsis;
+ margin: -1px 0;
+ }
+ div:first-of-type {
+ margin-top: 0;
+ }
+ div:last-of-type {
+ margin-bottom: 0;
+ }
+
+ &__last-message, &__status-message, &__email-address {
+ color: var(--color-text-maxcontrast);
+ }
+
+ &:focus-visible {
+ box-shadow: 0 0 0 4px var(--color-main-background) !important;
+ outline: 2px solid var(--color-main-text) !important;
+ }
+ }
+
+ .other-actions {
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+
+ img {
+ filter: var(--background-invert-if-dark);
+ }
+ }
+
+ button.other-actions {
+ width: 44px;
+
+ &:focus {
+ border-color: transparent;
+ box-shadow: 0 0 0 2px var(--color-main-text);
+ }
+
+ &:focus-visible {
+ border-radius: var(--border-radius-pill);
+ }
+ }
+
+ /* actions menu */
+ .menu {
+ top: 47px;
+ margin-inline-end: 13px;
+ }
+
+ .popovermenu::after {
+ inset-inline-end: 2px;
+ }
+}
+</style>
diff --git a/core/src/components/HeaderMenu.vue b/core/src/components/HeaderMenu.vue
deleted file mode 100644
index 12afa16e091..00000000000
--- a/core/src/components/HeaderMenu.vue
+++ /dev/null
@@ -1,215 +0,0 @@
- <!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-<template>
- <div
- :id="id"
- v-click-outside="clickOutsideConfig"
- :class="{ 'header-menu--opened': opened }"
- class="header-menu">
- <a class="header-menu__trigger"
- href="#"
- :aria-controls="`header-menu-${id}`"
- :aria-expanded="opened"
- aria-haspopup="true"
- @click.prevent="toggleMenu">
- <slot name="trigger" />
- </a>
- <div v-if="opened"
- :id="`header-menu-${id}`"
- class="header-menu__wrapper"
- role="menu">
- <div class="header-menu__carret" />
- <div class="header-menu__content">
- <slot />
- </div>
- </div>
- </div>
-</template>
-
-<script>
-import { directive as ClickOutside } from 'v-click-outside'
-import excludeClickOutsideClasses from '@nextcloud/vue/dist/Mixins/excludeClickOutsideClasses'
-
-export default {
- name: 'HeaderMenu',
-
- directives: {
- ClickOutside,
- },
-
- mixins: [
- excludeClickOutsideClasses,
- ],
-
- props: {
- id: {
- type: String,
- required: true,
- },
- open: {
- type: Boolean,
- default: false,
- },
- },
-
- data() {
- return {
- opened: this.open,
- clickOutsideConfig: {
- handler: this.closeMenu,
- middleware: this.clickOutsideMiddleware,
- },
- }
- },
-
- watch: {
- open(newVal) {
- this.opened = newVal
- this.$nextTick(() => {
- if (this.opened) {
- this.openMenu()
- } else {
- this.closeMenu()
- }
- })
- },
- },
-
- mounted() {
- document.addEventListener('keydown', this.onKeyDown)
- },
- beforeDestroy() {
- document.removeEventListener('keydown', this.onKeyDown)
- },
-
- methods: {
- /**
- * Toggle the current menu open state
- */
- toggleMenu() {
- // Toggling current state
- if (!this.opened) {
- this.openMenu()
- } else {
- this.closeMenu()
- }
- },
-
- /**
- * Close the current menu
- */
- closeMenu() {
- if (!this.opened) {
- return
- }
-
- this.opened = false
- this.$emit('close')
- this.$emit('update:open', false)
- },
-
- /**
- * Open the current menu
- */
- openMenu() {
- if (this.opened) {
- return
- }
-
- this.opened = true
- this.$emit('open')
- this.$emit('update:open', true)
- },
-
- onKeyDown(event) {
- // If opened and escape pressed, close
- if (event.key === 'Escape' && this.opened) {
- event.preventDefault()
-
- /** user cancelled the menu by pressing escape */
- this.$emit('cancel')
-
- /** we do NOT fire a close event to differentiate cancel and close */
- this.opened = false
- this.$emit('update:open', false)
- }
- },
- },
-}
-</script>
-
-<style lang="scss" scoped>
-.header-menu {
- &__trigger {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 50px;
- height: 100%;
- margin: 0;
- padding: 0;
- cursor: pointer;
- opacity: .6;
- }
-
- &--opened &__trigger,
- &__trigger:hover,
- &__trigger:focus,
- &__trigger:active {
- opacity: 1;
- }
-
- &__wrapper {
- position: absolute;
- z-index: 2000;
- top: 50px;
- right: 5px;
- box-sizing: border-box;
- margin: 0;
- border-radius: 0 0 var(--border-radius) var(--border-radius);
- background-color: var(--color-main-background);
-
- filter: drop-shadow(0 1px 5px var(--color-box-shadow));
- }
-
- &__carret {
- position: absolute;
- right: 10px;
- bottom: 100%;
- width: 0;
- height: 0;
- content: ' ';
- pointer-events: none;
- border: 10px solid transparent;
- border-bottom-color: var(--color-main-background);
- }
-
- &__content {
- overflow: auto;
- width: 350px;
- max-width: 350px;
- min-height: calc(44px * 1.5);
- max-height: calc(100vh - 50px * 2);
- }
-}
-
-</style>
diff --git a/core/src/components/LegacyDialogPrompt.vue b/core/src/components/LegacyDialogPrompt.vue
new file mode 100644
index 00000000000..f2ee4be9151
--- /dev/null
+++ b/core/src/components/LegacyDialogPrompt.vue
@@ -0,0 +1,111 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog dialog-classes="legacy-prompt__dialog"
+ :buttons="buttons"
+ :name="name"
+ @update:open="$emit('close', false, inputValue)">
+ <p class="legacy-prompt__text" v-text="text" />
+ <NcPasswordField v-if="isPassword"
+ ref="input"
+ autocomplete="new-password"
+ class="legacy-prompt__input"
+ :label="name"
+ :name="inputName"
+ :value.sync="inputValue" />
+ <NcTextField v-else
+ ref="input"
+ class="legacy-prompt__input"
+ :label="name"
+ :name="inputName"
+ :value.sync="inputValue" />
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent } from 'vue'
+
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+
+export default defineComponent({
+ name: 'LegacyDialogPrompt',
+
+ components: {
+ NcDialog,
+ NcTextField,
+ NcPasswordField,
+ },
+
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+
+ text: {
+ type: String,
+ required: true,
+ },
+
+ isPassword: {
+ type: Boolean,
+ required: true,
+ },
+
+ inputName: {
+ type: String,
+ default: 'prompt-input',
+ },
+ },
+
+ emits: ['close'],
+
+ data() {
+ return {
+ inputValue: '',
+ }
+ },
+
+ computed: {
+ buttons() {
+ return [
+ {
+ label: t('core', 'No'),
+ callback: () => this.$emit('close', false, this.inputValue),
+ },
+ {
+ label: t('core', 'Yes'),
+ type: 'primary',
+ callback: () => this.$emit('close', true, this.inputValue),
+ },
+ ]
+ },
+ },
+
+ mounted() {
+ this.$nextTick(() => this.$refs.input?.focus?.())
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.legacy-prompt {
+ &__text {
+ margin-block: 0 .75em;
+ }
+
+ &__input {
+ margin-block: 0 1em;
+ }
+}
+
+:deep(.legacy-prompt__dialog .dialog__actions) {
+ min-width: calc(100% - 12px);
+ justify-content: space-between;
+}
+</style>
diff --git a/core/src/components/MainMenu.js b/core/src/components/MainMenu.js
index d57711010ea..21a0b6a772f 100644
--- a/core/src/components/MainMenu.js
+++ b/core/src/components/MainMenu.js
@@ -1,97 +1,34 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import $ from 'jquery'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import Vue from 'vue'
-import OC from '../OC'
+import AppMenu from './AppMenu.vue'
-/**
- * Set up the main menu toggle to react to media query changes.
- * If the screen is small enough, the main menu becomes a toggle.
- * If the screen is bigger, the main menu is not a toggle any more.
- */
export const setUp = () => {
- // init the more-apps menu
- OC.registerMenu($('#more-apps > a'), $('#navigation'))
-
- // toggle the navigation
- const $toggle = $('#header .header-appname-container')
- const $navigation = $('#navigation')
- const $appmenu = $('#appmenu')
- // init the menu
- OC.registerMenu($toggle, $navigation)
- $toggle.data('oldhref', $toggle.attr('href'))
- $toggle.attr('href', '#')
- $navigation.hide()
-
- // show loading feedback on more apps list
- $navigation.delegate('a', 'click', event => {
- let $app = $(event.target)
- if (!$app.is('a')) {
- $app = $app.closest('a')
- }
- if (event.which === 1 && !event.ctrlKey && !event.metaKey) {
- $app.find('svg').remove()
- $app.find('div').remove() // prevent odd double-clicks
- // no need for theming, loader is already inverted on dark mode
- // but we need it over the primary colour
- $app.prepend($('<div/>').addClass('icon-loading-small'))
- } else {
- // Close navigation when opening app in
- // a new tab
- OC.hideMenus(() => false)
- }
+ Vue.mixin({
+ methods: {
+ t,
+ n,
+ },
})
- $navigation.delegate('a', 'mouseup', event => {
- if (event.which === 2) {
- // Close navigation when opening app in
- // a new tab via middle click
- OC.hideMenus(() => false)
- }
+ const container = document.getElementById('header-start__appmenu')
+ if (!container) {
+ // no container, possibly we're on a public page
+ return
+ }
+ const AppMenuApp = Vue.extend(AppMenu)
+ const appMenu = new AppMenuApp({}).$mount(container)
+
+ Object.assign(OC, {
+ setNavigationCounter(id, counter) {
+ appMenu.setNavigationCounter(id, counter)
+ },
})
- // show loading feedback on visible apps list
- $appmenu.delegate('li:not(#more-apps) > a', 'click', event => {
- let $app = $(event.target)
- if (!$app.is('a')) {
- $app = $app.closest('a')
- }
-
- if (event.which === 1 && !event.ctrlKey && !event.metaKey && $app.parent('#more-apps').length === 0) {
- $app.find('svg').remove()
- $app.find('div').remove() // prevent odd double-clicks
- $app.prepend($('<div/>').addClass(
- OCA.Theming && OCA.Theming.inverted
- ? 'icon-loading-small'
- : 'icon-loading-small-dark'
- ))
- // trigger redirect
- // needed for ie, but also works for every browser
- window.location = $app.attr('href')
- } else {
- // Close navigation when opening app in
- // a new tab
- OC.hideMenus(() => false)
- }
- })
}
diff --git a/core/src/components/Profile/PrimaryActionButton.vue b/core/src/components/Profile/PrimaryActionButton.vue
new file mode 100644
index 00000000000..dbc446b3d90
--- /dev/null
+++ b/core/src/components/Profile/PrimaryActionButton.vue
@@ -0,0 +1,64 @@
+<!--
+ - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcButton type="primary"
+ :href="href"
+ alignment="center"
+ :target="target"
+ :disabled="disabled">
+ <template #icon>
+ <img class="icon"
+ aria-hidden="true"
+ :src="icon"
+ alt="">
+ </template>
+ <slot />
+ </NcButton>
+</template>
+
+<script>
+import { defineComponent } from 'vue'
+import { t } from '@nextcloud/l10n'
+import NcButton from '@nextcloud/vue/components/NcButton'
+
+export default defineComponent({
+ name: 'PrimaryActionButton',
+
+ components: {
+ NcButton,
+ },
+
+ props: {
+ disabled: {
+ type: Boolean,
+ default: false,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ icon: {
+ type: String,
+ required: true,
+ },
+ target: {
+ type: String,
+ required: true,
+ validator: (value) => ['_self', '_blank', '_parent', '_top'].includes(value),
+ },
+ },
+
+ methods: {
+ t,
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+ .icon {
+ filter: var(--primary-invert-if-dark);
+ }
+</style>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue
new file mode 100644
index 00000000000..f3c57a12042
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuCustomEntry.vue
@@ -0,0 +1,36 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <li ref="listItem" :role="itemRole" v-html="html" />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref } from 'vue'
+
+defineProps<{
+ id: string
+ html: string
+}>()
+
+const listItem = ref<HTMLLIElement>()
+const itemRole = ref('presentation')
+
+onMounted(() => {
+ // check for proper roles
+ const menuitem = listItem.value?.querySelector('[role="menuitem"]')
+ if (menuitem) {
+ return
+ }
+ // check if a button is available
+ const button = listItem.value?.querySelector('button') ?? listItem.value?.querySelector('a')
+ if (button) {
+ button.role = 'menuitem'
+ } else {
+ // if nothing is available set role on `<li>`
+ itemRole.value = 'menuitem'
+ }
+})
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
new file mode 100644
index 00000000000..413806c7089
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuEntry.vue
@@ -0,0 +1,51 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcListItem :anchor-id="`${id}--link`"
+ compact
+ :details="details"
+ :href="href"
+ :name="label"
+ role="presentation"
+ @click="$emit('click')">
+ <template #icon>
+ <slot v-if="$scopedSlots.icon" name="icon" />
+ <div v-else role="presentation" :class="['icon', icon, 'public-page-menu-entry__icon']" />
+ </template>
+ </NcListItem>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue'
+
+import NcListItem from '@nextcloud/vue/components/NcListItem'
+
+const props = defineProps<{
+ /** Only emit click event but do not open href */
+ clickOnly?: boolean
+ // menu entry props
+ id: string
+ label: string
+ icon?: string
+ href: string
+ details?: string
+}>()
+
+onMounted(() => {
+ const anchor = document.getElementById(`${props.id}--link`) as HTMLAnchorElement
+ // Make the `<a>` a menuitem
+ anchor.role = 'menuitem'
+ // Prevent native click handling if required
+ if (props.clickOnly) {
+ anchor.onclick = (event) => event.preventDefault()
+ }
+})
+</script>
+
+<style scoped>
+.public-page-menu-entry__icon {
+ padding-inline-start: var(--default-grid-baseline);
+}
+</style>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
new file mode 100644
index 00000000000..0f02bdf7524
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalDialog.vue
@@ -0,0 +1,90 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcDialog is-form
+ :name="label"
+ :open.sync="open"
+ @submit="createFederatedShare">
+ <NcTextField ref="input"
+ :label="t('core', 'Federated user')"
+ :placeholder="t('core', 'user@your-nextcloud.org')"
+ required
+ :value.sync="remoteUrl" />
+ <template #actions>
+ <NcButton :disabled="loading" type="primary" native-type="submit">
+ <template v-if="loading" #icon>
+ <NcLoadingIcon />
+ </template>
+ {{ t('core', 'Create share') }}
+ </NcButton>
+ </template>
+ </NcDialog>
+</template>
+
+<script setup lang="ts">
+import type Vue from 'vue'
+
+import { t } from '@nextcloud/l10n'
+import { showError } from '@nextcloud/dialogs'
+import { generateUrl } from '@nextcloud/router'
+import { getSharingToken } from '@nextcloud/sharing/public'
+import { nextTick, onMounted, ref, watch } from 'vue'
+import axios from '@nextcloud/axios'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import logger from '../../logger'
+
+defineProps<{
+ label: string
+}>()
+
+const loading = ref(false)
+const remoteUrl = ref('')
+// Todo: @nextcloud/vue should expose the types correctly
+const input = ref<Vue & { focus: () => void }>()
+const open = ref(true)
+
+// Focus when mounted
+onMounted(() => nextTick(() => input.value!.focus()))
+
+// Check validity
+watch(remoteUrl, () => {
+ let validity = ''
+ if (!remoteUrl.value.includes('@')) {
+ validity = t('core', 'The remote URL must include the user.')
+ } else if (!remoteUrl.value.match(/@(.+\..{2,}|localhost)(:\d\d+)?$/)) {
+ validity = t('core', 'Invalid remote URL.')
+ }
+ input.value!.$el.querySelector('input')!.setCustomValidity(validity)
+ input.value!.$el.querySelector('input')!.reportValidity()
+})
+
+/**
+ * Create a federated share for the current share
+ */
+async function createFederatedShare() {
+ loading.value = true
+
+ try {
+ const url = generateUrl('/apps/federatedfilesharing/createFederatedShare')
+ const { data } = await axios.post<{ remoteUrl: string }>(url, {
+ shareWith: remoteUrl.value,
+ token: getSharingToken(),
+ })
+ if (data.remoteUrl.includes('://')) {
+ window.location.href = data.remoteUrl
+ } else {
+ window.location.href = `${window.location.protocol}//${data.remoteUrl}`
+ }
+ } catch (error) {
+ logger.error('Failed to create federated share', { error })
+ showError(t('files_sharing', 'Failed to add the public link to your Nextcloud'))
+ } finally {
+ loading.value = false
+ }
+}
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue
new file mode 100644
index 00000000000..a4451a38bbe
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuExternalEntry.vue
@@ -0,0 +1,36 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <PublicPageMenuEntry :id="id"
+ :icon="icon"
+ href="#"
+ :label="label"
+ @click="openDialog" />
+</template>
+
+<script setup lang="ts">
+import { spawnDialog } from '@nextcloud/dialogs'
+import PublicPageMenuEntry from './PublicPageMenuEntry.vue'
+import PublicPageMenuExternalDialog from './PublicPageMenuExternalDialog.vue'
+
+const props = defineProps<{
+ id: string
+ label: string
+ icon: string
+ href: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'click'): void
+}>()
+
+/**
+ * Open the "create federated share" dialog
+ */
+function openDialog() {
+ spawnDialog(PublicPageMenuExternalDialog, { label: props.label })
+ emit('click')
+}
+</script>
diff --git a/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue
new file mode 100644
index 00000000000..5f3a4883d6d
--- /dev/null
+++ b/core/src/components/PublicPageMenu/PublicPageMenuLinkEntry.vue
@@ -0,0 +1,51 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <PublicPageMenuEntry :id="id"
+ click-only
+ :icon="icon"
+ :href="href"
+ :label="label"
+ @click="onClick" />
+</template>
+
+<script setup lang="ts">
+import { showSuccess } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import PublicPageMenuEntry from './PublicPageMenuEntry.vue'
+
+const props = defineProps<{
+ id: string
+ label: string
+ icon: string
+ href: string
+}>()
+
+const emit = defineEmits<{
+ (e: 'click'): void
+}>()
+
+/**
+ * Copy the href to the clipboard
+ */
+async function copyLink() {
+ try {
+ await window.navigator.clipboard.writeText(props.href)
+ showSuccess(t('core', 'Direct link copied'))
+ } catch {
+ // No secure context -> fallback to dialog
+ window.prompt(t('core', 'Please copy the link manually:'), props.href)
+ }
+}
+
+/**
+ * onclick handler to trigger the "copy link" action
+ * and emit the event so the menu can be closed
+ */
+function onClick() {
+ copyLink()
+ emit('click')
+}
+</script>
diff --git a/core/src/components/UnifiedSearch/CustomDateRangeModal.vue b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
new file mode 100644
index 00000000000..d86192d156e
--- /dev/null
+++ b/core/src/components/UnifiedSearch/CustomDateRangeModal.vue
@@ -0,0 +1,107 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcModal v-if="isModalOpen"
+ id="unified-search"
+ :name="t('core', 'Custom date range')"
+ :show.sync="isModalOpen"
+ :size="'small'"
+ :clear-view-delay="0"
+ :title="t('core', 'Custom date range')"
+ @close="closeModal">
+ <!-- Custom date range -->
+ <div class="unified-search-custom-date-modal">
+ <h1>{{ t('core', 'Custom date range') }}</h1>
+ <div class="unified-search-custom-date-modal__pickers">
+ <NcDateTimePicker :id="'unifiedsearch-custom-date-range-start'"
+ v-model="dateFilter.startFrom"
+ :label="t('core', 'Pick start date')"
+ type="date" />
+ <NcDateTimePicker :id="'unifiedsearch-custom-date-range-end'"
+ v-model="dateFilter.endAt"
+ :label="t('core', 'Pick end date')"
+ type="date" />
+ </div>
+ <div class="unified-search-custom-date-modal__footer">
+ <NcButton @click="applyCustomRange">
+ {{ t('core', 'Search in date range') }}
+ <template #icon>
+ <CalendarRangeIcon :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ </NcModal>
+</template>
+
+<script>
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePickerNative'
+import NcModal from '@nextcloud/vue/components/NcModal'
+import CalendarRangeIcon from 'vue-material-design-icons/CalendarRange.vue'
+
+export default {
+ name: 'CustomDateRangeModal',
+ components: {
+ NcButton,
+ NcModal,
+ CalendarRangeIcon,
+ NcDateTimePicker,
+ },
+ props: {
+ isOpen: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dateFilter: { startFrom: null, endAt: null },
+ }
+ },
+ computed: {
+ isModalOpen: {
+ get() {
+ return this.isOpen
+ },
+ set(value) {
+ this.$emit('update:is-open', value)
+ },
+ },
+ },
+ methods: {
+ closeModal() {
+ this.isModalOpen = false
+ },
+ applyCustomRange() {
+ this.$emit('set:custom-date-range', this.dateFilter)
+ this.closeModal()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.unified-search-custom-date-modal {
+ padding: 10px 20px 10px 20px;
+
+ h1 {
+ font-size: 16px;
+ font-weight: bolder;
+ line-height: 2em;
+ }
+
+ &__pickers {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: end;
+ }
+
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/LegacySearchResult.vue b/core/src/components/UnifiedSearch/LegacySearchResult.vue
new file mode 100644
index 00000000000..4592adf08c9
--- /dev/null
+++ b/core/src/components/UnifiedSearch/LegacySearchResult.vue
@@ -0,0 +1,242 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <a :href="resourceUrl || '#'"
+ class="unified-search__result"
+ :class="{
+ 'unified-search__result--focused': focused,
+ }"
+ @click="reEmitEvent"
+ @focus="reEmitEvent">
+
+ <!-- Icon describing the result -->
+ <div class="unified-search__result-icon"
+ :class="{
+ 'unified-search__result-icon--rounded': rounded,
+ 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded,
+ 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded,
+ [icon]: !loaded && !isIconUrl,
+ }"
+ :style="{
+ backgroundImage: isIconUrl ? `url(${icon})` : '',
+ }">
+
+ <img v-if="hasValidThumbnail"
+ v-show="loaded"
+ :src="thumbnailUrl"
+ alt=""
+ @error="onError"
+ @load="onLoad">
+ </div>
+
+ <!-- Title and sub-title -->
+ <span class="unified-search__result-content">
+ <span class="unified-search__result-line-one" :title="title">
+ <NcHighlight :text="title" :search="query" />
+ </span>
+ <span v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</span>
+ </span>
+ </a>
+</template>
+
+<script>
+import NcHighlight from '@nextcloud/vue/components/NcHighlight'
+
+export default {
+ name: 'LegacySearchResult',
+
+ components: {
+ NcHighlight,
+ },
+
+ props: {
+ thumbnailUrl: {
+ type: String,
+ default: null,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ subline: {
+ type: String,
+ default: null,
+ },
+ resourceUrl: {
+ type: String,
+ default: null,
+ },
+ icon: {
+ type: String,
+ default: '',
+ },
+ rounded: {
+ type: Boolean,
+ default: false,
+ },
+ query: {
+ type: String,
+ default: '',
+ },
+
+ /**
+ * Only used for the first result as a visual feedback
+ * so we can keep the search input focused but pressing
+ * enter still opens the first result
+ */
+ focused: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '',
+ loaded: false,
+ }
+ },
+
+ computed: {
+ isIconUrl() {
+ // If we're facing an absolute url
+ if (this.icon.startsWith('/')) {
+ return true
+ }
+
+ // Otherwise, let's check if this is a valid url
+ try {
+ // eslint-disable-next-line no-new
+ new URL(this.icon)
+ } catch {
+ return false
+ }
+ return true
+ },
+ },
+
+ watch: {
+ // Make sure to reset state on change even when vue recycle the component
+ thumbnailUrl() {
+ this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== ''
+ this.loaded = false
+ },
+ },
+
+ methods: {
+ reEmitEvent(e) {
+ this.$emit(e.type, e)
+ },
+
+ /**
+ * If the image fails to load, fallback to iconClass
+ */
+ onError() {
+ this.hasValidThumbnail = false
+ },
+
+ onLoad() {
+ this.loaded = true
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$clickable-area: 44px;
+$margin: 10px;
+
+.unified-search__result {
+ display: flex;
+ align-items: center;
+ height: $clickable-area;
+ padding: $margin;
+ border: 2px solid transparent;
+ border-radius: var(--border-radius-large) !important;
+
+ &--focused {
+ background-color: var(--color-background-hover);
+ }
+
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-border-maxcontrast);
+ }
+
+ * {
+ cursor: pointer;
+ }
+
+ &-icon {
+ overflow: hidden;
+ width: $clickable-area;
+ height: $clickable-area;
+ border-radius: var(--border-radius);
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 32px;
+ &--rounded {
+ border-radius: math.div($clickable-area, 2);
+ }
+ &--no-preview {
+ background-size: 32px;
+ }
+ &--with-thumbnail {
+ background-size: cover;
+ }
+ &--with-thumbnail:not(&--rounded) {
+ // compensate for border
+ max-width: $clickable-area - 2px;
+ max-height: $clickable-area - 2px;
+ border: 1px solid var(--color-border);
+ }
+
+ img {
+ // Make sure to keep ratio
+ width: 100%;
+ height: 100%;
+
+ object-fit: cover;
+ object-position: center;
+ }
+ }
+
+ &-icon,
+ &-actions {
+ flex: 0 0 $clickable-area;
+ }
+
+ &-content {
+ display: flex;
+ align-items: center;
+ flex: 1 1 100%;
+ flex-wrap: wrap;
+ // Set to minimum and gro from it
+ min-width: 0;
+ padding-inline-start: $margin;
+ }
+
+ &-line-one,
+ &-line-two {
+ overflow: hidden;
+ flex: 1 1 100%;
+ margin: 1px 0;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ // Use the same color as the `a`
+ color: inherit;
+ font-size: inherit;
+ }
+ &-line-two {
+ opacity: .7;
+ font-size: var(--default-font-size);
+ }
+}
+
+</style>
diff --git a/core/src/components/UnifiedSearch/SearchFilterChip.vue b/core/src/components/UnifiedSearch/SearchFilterChip.vue
new file mode 100644
index 00000000000..e08ddd58a4b
--- /dev/null
+++ b/core/src/components/UnifiedSearch/SearchFilterChip.vue
@@ -0,0 +1,79 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="chip">
+ <span class="icon">
+ <slot name="icon" />
+ <span v-if="pretext.length"> {{ pretext }} : </span>
+ </span>
+ <span class="text">{{ text }}</span>
+ <span class="close-icon" @click="deleteChip">
+ <CloseIcon :size="18" />
+ </span>
+ </div>
+</template>
+
+<script>
+import CloseIcon from 'vue-material-design-icons/Close.vue'
+
+export default {
+ name: 'SearchFilterChip',
+ components: {
+ CloseIcon,
+ },
+ props: {
+ text: {
+ type: String,
+ required: true,
+ },
+ pretext: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ deleteChip() {
+ this.$emit('delete', this.filter)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.chip {
+ display: flex;
+ align-items: center;
+ padding: 2px 4px;
+ border: 1px solid var(--color-primary-element-light);
+ border-radius: 20px;
+ background-color: var(--color-primary-element-light);
+ margin: 2px;
+
+ .icon {
+ display: flex;
+ align-items: center;
+ padding-inline-end: 5px;
+
+ img {
+ width: 20px;
+ padding: 2px;
+ border-radius: 20px;
+ filter: var(--background-invert-if-bright);
+ }
+ }
+
+ .text {
+ margin: 0 2px;
+ }
+
+ .close-icon {
+ cursor: pointer ;
+
+ :hover {
+ filter: invert(20%);
+ }
+ }
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/SearchResult.vue b/core/src/components/UnifiedSearch/SearchResult.vue
index e50cc413d03..4f33fbd54cc 100644
--- a/core/src/components/UnifiedSearch/SearchResult.vue
+++ b/core/src/components/UnifiedSearch/SearchResult.vue
@@ -1,74 +1,44 @@
- <!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <a :href="resourceUrl || '#'"
- class="unified-search__result"
- :class="{
- 'unified-search__result--focused': focused,
- }"
- @click="reEmitEvent"
- @focus="reEmitEvent">
-
- <!-- Icon describing the result -->
- <div class="unified-search__result-icon"
- :class="{
- 'unified-search__result-icon--rounded': rounded,
- 'unified-search__result-icon--no-preview': !hasValidThumbnail && !loaded,
- 'unified-search__result-icon--with-thumbnail': hasValidThumbnail && loaded,
- [icon]: !loaded && !isIconUrl,
- }"
- :style="{
- backgroundImage: isIconUrl ? `url(${icon})` : '',
- }"
- role="img">
-
- <img v-if="hasValidThumbnail"
- v-show="loaded"
- :src="thumbnailUrl"
- alt=""
- @error="onError"
- @load="onLoad">
- </div>
-
- <!-- Title and sub-title -->
- <span class="unified-search__result-content">
- <h3 class="unified-search__result-line-one" :title="title">
- <Highlight :text="title" :search="query" />
- </h3>
- <h4 v-if="subline" class="unified-search__result-line-two" :title="subline">{{ subline }}</h4>
- </span>
- </a>
+ <NcListItem class="result-item"
+ :name="title"
+ :bold="false"
+ :href="resourceUrl"
+ target="_self">
+ <template #icon>
+ <div aria-hidden="true"
+ class="result-item__icon"
+ :class="{
+ 'result-item__icon--rounded': rounded,
+ 'result-item__icon--no-preview': !isValidIconOrPreviewUrl(thumbnailUrl),
+ 'result-item__icon--with-thumbnail': isValidIconOrPreviewUrl(thumbnailUrl),
+ [icon]: !isValidIconOrPreviewUrl(icon),
+ }"
+ :style="{
+ backgroundImage: isValidIconOrPreviewUrl(icon) ? `url(${icon})` : '',
+ }">
+ <img v-if="isValidIconOrPreviewUrl(thumbnailUrl) && !thumbnailHasError"
+ :src="thumbnailUrl"
+ @error="thumbnailErrorHandler">
+ </div>
+ </template>
+ <template #subname>
+ {{ subline }}
+ </template>
+ </NcListItem>
</template>
<script>
-import Highlight from '@nextcloud/vue/dist/Components/Highlight'
+import NcListItem from '@nextcloud/vue/components/NcListItem'
export default {
name: 'SearchResult',
-
components: {
- Highlight,
+ NcListItem,
},
-
props: {
thumbnailUrl: {
type: String,
@@ -109,107 +79,71 @@ export default {
default: false,
},
},
-
data() {
return {
- hasValidThumbnail: this.thumbnailUrl && this.thumbnailUrl.trim() !== '',
- loaded: false,
+ thumbnailHasError: false,
}
},
-
- computed: {
- isIconUrl() {
- // If we're facing an absolute url
- if (this.icon.startsWith('/')) {
- return true
- }
-
- // Otherwise, let's check if this is a valid url
- try {
- // eslint-disable-next-line no-new
- new URL(this.icon)
- } catch {
- return false
- }
- return true
- },
- },
-
watch: {
- // Make sure to reset state on change even when vue recycle the component
thumbnailUrl() {
- this.hasValidThumbnail = this.thumbnailUrl && this.thumbnailUrl.trim() !== ''
- this.loaded = false
+ this.thumbnailHasError = false
},
},
-
methods: {
- reEmitEvent(e) {
- this.$emit(e.type, e)
- },
-
- /**
- * If the image fails to load, fallback to iconClass
- */
- onError() {
- this.hasValidThumbnail = false
+ isValidIconOrPreviewUrl(url) {
+ return /^https?:\/\//.test(url) || url.startsWith('/')
},
-
- onLoad() {
- this.loaded = true
+ thumbnailErrorHandler() {
+ this.thumbnailHasError = true
},
},
}
</script>
<style lang="scss" scoped>
-$clickable-area: 44px;
-$margin: 10px;
-
-.unified-search__result {
- display: flex;
- height: $clickable-area;
- padding: $margin;
- border-bottom: 1px solid var(--color-border);
-
- // Load more entry,
- &:last-child {
- border-bottom: none;
- }
-
- &--focused,
- &:active,
- &:hover,
- &:focus {
- background-color: var(--color-background-hover);
- }
+.result-item {
+ :deep(a) {
+ border: 2px solid transparent;
+ border-radius: var(--border-radius-large) !important;
+
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-border-maxcontrast);
+ }
- * {
- cursor: pointer;
+ * {
+ cursor: pointer;
+ }
}
- &-icon {
+ &__icon {
overflow: hidden;
- width: $clickable-area;
- height: $clickable-area;
+ width: var(--default-clickable-area);
+ height: var(--default-clickable-area);
border-radius: var(--border-radius);
background-repeat: no-repeat;
background-position: center center;
background-size: 32px;
+
&--rounded {
- border-radius: $clickable-area / 2;
+ border-radius: calc(var(--default-clickable-area) / 2);
}
+
&--no-preview {
background-size: 32px;
}
+
&--with-thumbnail {
background-size: cover;
}
- &--with-thumbnail:not(&--rounded) {
- // compensate for border
- max-width: $clickable-area - 2px;
- max-height: $clickable-area - 2px;
+
+ &--with-thumbnail:not(#{&}--rounded) {
border: 1px solid var(--color-border);
+ // compensate for border
+ max-height: calc(var(--default-clickable-area) - 2px);
+ max-width: calc(var(--default-clickable-area) - 2px);
}
img {
@@ -221,37 +155,5 @@ $margin: 10px;
object-position: center;
}
}
-
- &-icon,
- &-actions {
- flex: 0 0 $clickable-area;
- }
-
- &-content {
- display: flex;
- align-items: center;
- flex: 1 1 100%;
- flex-wrap: wrap;
- // Set to minimum and gro from it
- min-width: 0;
- padding-left: $margin;
- }
-
- &-line-one,
- &-line-two {
- overflow: hidden;
- flex: 1 1 100%;
- margin: 1px 0;
- white-space: nowrap;
- text-overflow: ellipsis;
- // Use the same color as the `a`
- color: inherit;
- font-size: inherit;
- }
- &-line-two {
- opacity: .7;
- font-size: var(--default-font-size);
- }
}
-
</style>
diff --git a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
index 31f85f413d3..aec2791d8e4 100644
--- a/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
+++ b/core/src/components/UnifiedSearch/SearchResultPlaceholders.vue
@@ -1,3 +1,7 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<ul>
<!-- Placeholder animation -->
@@ -22,8 +26,7 @@
<!-- Placeholders -->
<li v-for="placeholder in [1, 2, 3]" :key="placeholder">
- <svg
- class="unified-search__result-placeholder"
+ <svg class="unified-search__result-placeholder"
xmlns="http://www.w3.org/2000/svg"
fill="url(#unified-search__result-placeholder-gradient)">
<rect class="unified-search__result-placeholder-icon" />
diff --git a/core/src/components/UnifiedSearch/SearchableList.vue b/core/src/components/UnifiedSearch/SearchableList.vue
new file mode 100644
index 00000000000..d7abb6ffdbb
--- /dev/null
+++ b/core/src/components/UnifiedSearch/SearchableList.vue
@@ -0,0 +1,157 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcPopover :shown="opened"
+ @show="opened = true"
+ @hide="opened = false">
+ <template #trigger>
+ <slot ref="popoverTrigger" name="trigger" />
+ </template>
+ <div class="searchable-list__wrapper">
+ <NcTextField :value.sync="searchTerm"
+ :label="labelText"
+ trailing-button-icon="close"
+ :show-trailing-button="searchTerm !== ''"
+ @update:value="searchTermChanged"
+ @trailing-button-click="clearSearch">
+ <IconMagnify :size="20" />
+ </NcTextField>
+ <ul v-if="filteredList.length > 0" class="searchable-list__list">
+ <li v-for="element in filteredList"
+ :key="element.id"
+ :title="element.displayName"
+ role="button">
+ <NcButton alignment="start"
+ type="tertiary"
+ :wide="true"
+ @click="itemSelected(element)">
+ <template #icon>
+ <NcAvatar v-if="element.isUser" :user="element.user" :show-user-status="false" />
+ <NcAvatar v-else
+ :is-no-user="true"
+ :display-name="element.displayName"
+ :show-user-status="false" />
+ </template>
+ {{ element.displayName }}
+ </NcButton>
+ </li>
+ </ul>
+ <div v-else class="searchable-list__empty-content">
+ <NcEmptyContent :name="emptyContentText">
+ <template #icon>
+ <IconAlertCircleOutline />
+ </template>
+ </NcEmptyContent>
+ </div>
+ </div>
+ </NcPopover>
+</template>
+
+<script>
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcPopover from '@nextcloud/vue/components/NcPopover'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
+import IconMagnify from 'vue-material-design-icons/Magnify.vue'
+
+export default {
+ name: 'SearchableList',
+
+ components: {
+ IconMagnify,
+ IconAlertCircleOutline,
+ NcAvatar,
+ NcButton,
+ NcEmptyContent,
+ NcPopover,
+ NcTextField,
+ },
+
+ props: {
+ labelText: {
+ type: String,
+ default: 'this is a label',
+ },
+
+ searchList: {
+ type: Array,
+ required: true,
+ },
+
+ emptyContentText: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ opened: false,
+ error: false,
+ searchTerm: '',
+ }
+ },
+
+ computed: {
+ filteredList() {
+ return this.searchList.filter((element) => {
+ if (!this.searchTerm.toLowerCase().length) {
+ return true
+ }
+ return ['displayName'].some(prop => element[prop].toLowerCase().includes(this.searchTerm.toLowerCase()))
+ })
+ },
+ },
+
+ methods: {
+ clearSearch() {
+ this.searchTerm = ''
+ },
+ itemSelected(element) {
+ this.$emit('item-selected', element)
+ this.clearSearch()
+ this.opened = false
+ },
+ searchTermChanged(term) {
+ this.$emit('search-term-change', term)
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.searchable-list {
+ &__wrapper {
+ padding: calc(var(--default-grid-baseline) * 3);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 250px;
+ }
+
+ &__list {
+ width: 100%;
+ max-height: 284px;
+ overflow-y: auto;
+ margin-top: var(--default-grid-baseline);
+ padding: var(--default-grid-baseline);
+
+ :deep(.button-vue) {
+ border-radius: var(--border-radius-large) !important;
+ span {
+ font-weight: initial;
+ }
+ }
+ }
+
+ &__empty-content {
+ margin-top: calc(var(--default-grid-baseline) * 3);
+ }
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue
new file mode 100644
index 00000000000..171eada8a06
--- /dev/null
+++ b/core/src/components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue
@@ -0,0 +1,166 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <Transition>
+ <div v-if="open"
+ class="local-unified-search animated-width"
+ :class="{ 'local-unified-search--open': open }">
+ <!-- We can not use labels as it breaks the header layout so only aria-label and placeholder -->
+ <NcInputField ref="searchInput"
+ class="local-unified-search__input animated-width"
+ :aria-label="t('core', 'Search in current app')"
+ :placeholder="t('core', 'Search in current app')"
+ show-trailing-button
+ :trailing-button-label="t('core', 'Clear search')"
+ :value="query"
+ @update:value="$emit('update:query', $event)"
+ @trailing-button-click="clearAndCloseSearch">
+ <template #trailing-button-icon>
+ <NcIconSvgWrapper :path="mdiClose" />
+ </template>
+ </NcInputField>
+
+ <NcButton ref="searchGlobalButton"
+ class="local-unified-search__global-search"
+ :aria-label="t('core', 'Search everywhere')"
+ :title="t('core', 'Search everywhere')"
+ type="tertiary-no-background"
+ @click="$emit('global-search')">
+ <template v-if="!isMobile" #default>
+ {{ t('core', 'Search everywhere') }}
+ </template>
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiCloudSearchOutline" />
+ </template>
+ </NcButton>
+ </div>
+ </Transition>
+</template>
+
+<script lang="ts" setup>
+import type { ComponentPublicInstance } from 'vue'
+import { mdiCloudSearchOutline, mdiClose } from '@mdi/js'
+import { translate as t } from '@nextcloud/l10n'
+import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
+import { useElementSize } from '@vueuse/core'
+import { computed, ref, watchEffect } from 'vue'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+
+const props = defineProps<{
+ query: string,
+ open: boolean
+}>()
+
+const emit = defineEmits<{
+ (e: 'update:open', open: boolean): void
+ (e: 'update:query', query: string): void
+ (e: 'global-search'): void
+}>()
+
+// Hacky type until the library provides real Types
+type FocusableComponent = ComponentPublicInstance<object, object, object, Record<string, never>, { focus: () => void }>
+/** The input field component */
+const searchInput = ref<FocusableComponent>()
+/** When the search bar is opened we focus the input */
+watchEffect(() => {
+ if (props.open && searchInput.value) {
+ searchInput.value.focus()
+ }
+})
+
+/** Current window size is below the "mobile" breakpoint (currently 1024px) */
+const isMobile = useIsMobile()
+
+const searchGlobalButton = ref<ComponentPublicInstance>()
+/** Width of the search global button, used to resize the input field */
+const { width: searchGlobalButtonWidth } = useElementSize(searchGlobalButton)
+const searchGlobalButtonCSSWidth = computed(() => searchGlobalButtonWidth.value ? `${searchGlobalButtonWidth.value}px` : 'var(--default-clickable-area)')
+
+/**
+ * Clear the search query and close the search bar
+ */
+function clearAndCloseSearch() {
+ emit('update:query', '')
+ emit('update:open', false)
+}
+</script>
+
+<style scoped lang="scss">
+.local-unified-search {
+ --local-search-width: min(calc(250px + v-bind('searchGlobalButtonCSSWidth')), 95vw);
+ box-sizing: border-box;
+ position: relative;
+ height: var(--header-height);
+ width: var(--local-search-width);
+ display: flex;
+ align-items: center;
+ // Ensure it overlays the other entries
+ z-index: 10;
+ // add some padding for the focus visible outline
+ padding-inline: var(--border-width-input-focused);
+ // hide the overflow - needed for the transition
+ overflow: hidden;
+ // Ensure the position is fixed also during "position: absolut" (transition)
+ inset-inline-end: 0;
+
+ #{&} &__global-search {
+ position: absolute;
+ inset-inline-end: var(--default-clickable-area);
+ }
+
+ #{&} &__input {
+ box-sizing: border-box;
+ // override some nextcloud-vue styles
+ margin: 0;
+ width: var(--local-search-width);
+
+ // Fixup the spacing so we can fit in the "search globally" button
+ // this can break at any time the component library changes
+ :deep(input) {
+ // search global width + close button width
+ padding-inline-end: calc(v-bind('searchGlobalButtonCSSWidth') + var(--default-clickable-area));
+ }
+ }
+}
+
+.animated-width {
+ transition: width var(--animation-quick) linear;
+}
+
+// Make the position absolute during the transition
+// this is needed to "hide" the button behind it
+.v-leave-active {
+ position: absolute !important;
+}
+
+.v-enter,
+.v-leave-to {
+ &.local-unified-search {
+ // Start with only the overlay button
+ --local-search-width: var(--clickable-area-large);
+ }
+}
+
+@media screen and (max-width: 500px) {
+ .local-unified-search.local-unified-search--open {
+ // 100% but still show the menu toggle on the very right
+ --local-search-width: 100vw;
+ padding-inline: var(--default-grid-baseline);
+ }
+
+ // when open we need to position it absolute to allow overlay the full bar
+ :global(.unified-search-menu:has(.local-unified-search--open)) {
+ position: absolute !important;
+ inset-inline: 0;
+ }
+ // Hide all other entries, especially the user menu as it might leak pixels
+ :global(.header-end:has(.local-unified-search--open) > :not(.unified-search-menu)) {
+ display: none;
+ }
+}
+</style>
diff --git a/core/src/components/UnifiedSearch/UnifiedSearchModal.vue b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue
new file mode 100644
index 00000000000..e59058bc0f0
--- /dev/null
+++ b/core/src/components/UnifiedSearch/UnifiedSearchModal.vue
@@ -0,0 +1,838 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcDialog id="unified-search"
+ ref="unifiedSearchModal"
+ content-classes="unified-search-modal__content"
+ dialog-classes="unified-search-modal"
+ :name="t('core', 'Unified search')"
+ :open="open"
+ size="normal"
+ @update:open="onUpdateOpen">
+ <!-- Modal for picking custom time range -->
+ <CustomDateRangeModal :is-open="showDateRangeModal"
+ class="unified-search__date-range"
+ @set:custom-date-range="setCustomDateRange"
+ @update:is-open="showDateRangeModal = $event" />
+
+ <!-- Unified search form -->
+ <div class="unified-search-modal__header">
+ <NcInputField ref="searchInput"
+ data-cy-unified-search-input
+ :value.sync="searchQuery"
+ type="text"
+ :label="t('core', 'Search apps, files, tags, messages') + '...'"
+ @update:value="debouncedFind" />
+ <div class="unified-search-modal__filters" data-cy-unified-search-filters>
+ <NcActions :menu-name="t('core', 'Places')" :open.sync="providerActionMenuIsOpen" data-cy-unified-search-filter="places">
+ <template #icon>
+ <IconListBox :size="20" />
+ </template>
+ <!-- Provider id's may be duplicated since, plugin filters could depend on a provider that is already in the defaults.
+ provider.id concatenated to provider.name is used to create the item id, if same then, there should be an issue. -->
+ <NcActionButton v-for="provider in providers"
+ :key="`${provider.id}-${provider.name.replace(/\s/g, '')}`"
+ :disabled="provider.disabled"
+ @click="addProviderFilter(provider)">
+ <template #icon>
+ <img :src="provider.icon" class="filter-button__icon" alt="">
+ </template>
+ {{ provider.name }}
+ </NcActionButton>
+ </NcActions>
+ <NcActions :menu-name="t('core', 'Date')" :open.sync="dateActionMenuIsOpen" data-cy-unified-search-filter="date">
+ <template #icon>
+ <IconCalendarRange :size="20" />
+ </template>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('today')">
+ {{ t('core', 'Today') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('7days')">
+ {{ t('core', 'Last 7 days') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('30days')">
+ {{ t('core', 'Last 30 days') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('thisyear')">
+ {{ t('core', 'This year') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('lastyear')">
+ {{ t('core', 'Last year') }}
+ </NcActionButton>
+ <NcActionButton :close-after-click="true" @click="applyQuickDateRange('custom')">
+ {{ t('core', 'Custom date range') }}
+ </NcActionButton>
+ </NcActions>
+ <SearchableList :label-text="t('core', 'Search people')"
+ :search-list="userContacts"
+ :empty-content-text="t('core', 'Not found')"
+ data-cy-unified-search-filter="people"
+ @search-term-change="debouncedFilterContacts"
+ @item-selected="applyPersonFilter">
+ <template #trigger>
+ <NcButton>
+ <template #icon>
+ <IconAccountGroup :size="20" />
+ </template>
+ {{ t('core', 'People') }}
+ </NcButton>
+ </template>
+ </SearchableList>
+ <NcButton v-if="localSearch" data-cy-unified-search-filter="current-view" @click="searchLocally">
+ {{ t('core', 'Filter in current view') }}
+ <template #icon>
+ <IconFilter :size="20" />
+ </template>
+ </NcButton>
+ <NcCheckboxRadioSwitch v-if="hasExternalResources"
+ v-model="searchExternalResources"
+ type="switch"
+ class="unified-search-modal__search-external-resources"
+ :class="{'unified-search-modal__search-external-resources--aligned': localSearch}">
+ {{ t('core', 'Search connected services') }}
+ </NcCheckboxRadioSwitch>
+ </div>
+ <div class="unified-search-modal__filters-applied">
+ <FilterChip v-for="filter in filters"
+ :key="filter.id"
+ :text="filter.name ?? filter.text"
+ :pretext="''"
+ @delete="removeFilter(filter)">
+ <template #icon>
+ <NcAvatar v-if="filter.type === 'person'"
+ :user="filter.user"
+ :size="24"
+ :disable-menu="true"
+ :show-user-status="false"
+ :hide-favorite="false" />
+ <IconCalendarRange v-else-if="filter.type === 'date'" />
+ <img v-else :src="filter.icon" alt="">
+ </template>
+ </FilterChip>
+ </div>
+ </div>
+
+ <div v-if="showEmptyContentInfo" class="unified-search-modal__no-content">
+ <NcEmptyContent :name="emptyContentMessage">
+ <template #icon>
+ <IconMagnify :size="64" />
+ </template>
+ </NcEmptyContent>
+ </div>
+
+ <div v-else class="unified-search-modal__results">
+ <h3 class="hidden-visually">
+ {{ t('core', 'Results') }}
+ </h3>
+ <div v-for="providerResult in results" :key="providerResult.id" class="result">
+ <h4 :id="`unified-search-result-${providerResult.id}`" class="result-title">
+ {{ providerResult.name }}
+ </h4>
+ <ul class="result-items" :aria-labelledby="`unified-search-result-${providerResult.id}`">
+ <SearchResult v-for="(result, index) in providerResult.results"
+ :key="index"
+ v-bind="result" />
+ </ul>
+ <div class="result-footer">
+ <NcButton v-if="providerResult.results.length === providerResult.limit" type="tertiary-no-background" @click="loadMoreResultsForProvider(providerResult)">
+ {{ t('core', 'Load more results') }}
+ <template #icon>
+ <IconDotsHorizontal :size="20" />
+ </template>
+ </NcButton>
+ <NcButton v-if="providerResult.inAppSearch" alignment="end-reverse" type="tertiary-no-background">
+ {{ t('core', 'Search in') }} {{ providerResult.name }}
+ <template #icon>
+ <IconArrowRight :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ </div>
+ </NcDialog>
+</template>
+
+<script lang="ts">
+import { subscribe } from '@nextcloud/event-bus'
+import { translate as t } from '@nextcloud/l10n'
+import { useBrowserLocation } from '@vueuse/core'
+import { defineComponent } from 'vue'
+import { getProviders, search as unifiedSearch, getContacts } from '../../services/UnifiedSearchService.js'
+import { useSearchStore } from '../../store/unified-search-external-filters.js'
+
+import debounce from 'debounce'
+import { unifiedSearchLogger } from '../../logger'
+
+import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+import IconAccountGroup from 'vue-material-design-icons/AccountGroupOutline.vue'
+import IconCalendarRange from 'vue-material-design-icons/CalendarRangeOutline.vue'
+import IconDotsHorizontal from 'vue-material-design-icons/DotsHorizontal.vue'
+import IconFilter from 'vue-material-design-icons/Filter.vue'
+import IconListBox from 'vue-material-design-icons/ListBox.vue'
+import IconMagnify from 'vue-material-design-icons/Magnify.vue'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcInputField from '@nextcloud/vue/components/NcInputField'
+import NcDialog from '@nextcloud/vue/components/NcDialog'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+
+import CustomDateRangeModal from './CustomDateRangeModal.vue'
+import FilterChip from './SearchFilterChip.vue'
+import SearchableList from './SearchableList.vue'
+import SearchResult from './SearchResult.vue'
+
+export default defineComponent({
+ name: 'UnifiedSearchModal',
+ components: {
+ IconArrowRight,
+ IconAccountGroup,
+ IconCalendarRange,
+ IconDotsHorizontal,
+ IconFilter,
+ IconListBox,
+ IconMagnify,
+
+ CustomDateRangeModal,
+ FilterChip,
+ NcActions,
+ NcActionButton,
+ NcAvatar,
+ NcButton,
+ NcEmptyContent,
+ NcDialog,
+ NcInputField,
+ NcCheckboxRadioSwitch,
+ SearchableList,
+ SearchResult,
+ },
+
+ props: {
+ /**
+ * Open state of the modal
+ */
+ open: {
+ type: Boolean,
+ required: true,
+ },
+
+ /**
+ * The current query string
+ */
+ query: {
+ type: String,
+ default: '',
+ },
+
+ /**
+ * If the current page / app supports local search
+ */
+ localSearch: {
+ type: Boolean,
+ default: false,
+ },
+ },
+
+ emits: ['update:open', 'update:query'],
+
+ setup() {
+ /**
+ * Reactive version of window.location
+ */
+ const currentLocation = useBrowserLocation()
+ const searchStore = useSearchStore()
+ return {
+ t,
+
+ currentLocation,
+ externalFilters: searchStore.externalFilters,
+ }
+ },
+
+ data() {
+ return {
+ providers: [],
+ providerActionMenuIsOpen: false,
+ dateActionMenuIsOpen: false,
+ providerResultLimit: 5,
+ dateFilter: { id: 'date', type: 'date', text: '', startFrom: null, endAt: null },
+ personFilter: { id: 'person', type: 'person', name: '' },
+ filteredProviders: [],
+ searching: false,
+ searchQuery: '',
+ lastSearchQuery: '',
+ placessearchTerm: '',
+ dateTimeFilter: null,
+ filters: [],
+ results: [],
+ contacts: [],
+ showDateRangeModal: false,
+ internalIsVisible: this.open,
+ initialized: false,
+ searchExternalResources: false,
+ }
+ },
+
+ computed: {
+ isEmptySearch() {
+ return this.searchQuery.length === 0
+ },
+
+ hasNoResults() {
+ return !this.isEmptySearch && this.results.length === 0
+ },
+
+ showEmptyContentInfo() {
+ return this.isEmptySearch || this.hasNoResults
+ },
+
+ emptyContentMessage() {
+ if (this.searching && this.hasNoResults) {
+ return t('core', 'Searching …')
+ }
+ if (this.isEmptySearch) {
+ return t('core', 'Start typing to search')
+ }
+ return t('core', 'No matching results')
+ },
+
+ userContacts() {
+ return this.contacts
+ },
+
+ debouncedFind() {
+ return debounce(this.find, 300)
+ },
+
+ debouncedFilterContacts() {
+ return debounce(this.filterContacts, 300)
+ },
+
+ hasExternalResources() {
+ return this.providers.some(provider => provider.isExternalProvider)
+ },
+ },
+
+ watch: {
+ open() {
+ // Load results when opened with already filled query
+ if (this.open) {
+ this.focusInput()
+ if (!this.initialized) {
+ Promise.all([getProviders(), getContacts({ searchTerm: '' })])
+ .then(([providers, contacts]) => {
+ this.providers = this.groupProvidersByApp([...providers, ...this.externalFilters])
+ this.contacts = this.mapContacts(contacts)
+ unifiedSearchLogger.debug('Search providers and contacts initialized:', { providers: this.providers, contacts: this.contacts })
+ this.initialized = true
+ })
+ .catch((error) => {
+ unifiedSearchLogger.error(error)
+ })
+ }
+ if (this.searchQuery) {
+ this.find(this.searchQuery)
+ }
+ }
+ },
+
+ query: {
+ immediate: true,
+ handler() {
+ this.searchQuery = this.query
+ },
+ },
+
+ searchQuery: {
+ handler() {
+ this.$emit('update:query', this.searchQuery)
+ },
+ },
+
+ searchExternalResources() {
+ if (this.searchQuery) {
+ this.find(this.searchQuery)
+ }
+ },
+ },
+
+ mounted() {
+ subscribe('nextcloud:unified-search:add-filter', this.handlePluginFilter)
+ },
+ methods: {
+ /**
+ * On close the modal is closed and the query is reset
+ * @param open The new open state
+ */
+ onUpdateOpen(open: boolean) {
+ if (!open) {
+ this.$emit('update:open', false)
+ this.$emit('update:query', '')
+ }
+ },
+
+ /**
+ * Only close the modal but keep the query for in-app search
+ */
+ searchLocally() {
+ this.$emit('update:query', this.searchQuery)
+ this.$emit('update:open', false)
+ },
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.searchInput?.focus()
+ })
+ },
+ find(query: string, providersToSearchOverride = null) {
+ if (query.length === 0) {
+ this.results = []
+ this.searching = false
+ return
+ }
+
+ // Reset the provider result limit when performing a new search
+ if (query !== this.lastSearchQuery) {
+ this.providerResultLimit = 5
+ }
+ this.lastSearchQuery = query
+
+ this.searching = true
+ const newResults = []
+ const providersToSearch = providersToSearchOverride || (this.filteredProviders.length > 0 ? this.filteredProviders : this.providers)
+ const searchProvider = (provider) => {
+ const params = {
+ type: provider.searchFrom ?? provider.id,
+ query,
+ cursor: null,
+ extraQueries: provider.extraParams,
+ }
+
+ // This block of filter checks should be dynamic somehow and should be handled in
+ // nextcloud/search lib
+ const activeFilters = this.filters.filter(filter => {
+ return filter.type !== 'provider' && this.providerIsCompatibleWithFilters(provider, [filter.type])
+ })
+
+ activeFilters.forEach(filter => {
+ switch (filter.type) {
+ case 'date':
+ if (provider.filters?.since && provider.filters?.until) {
+ params.since = this.dateFilter.startFrom
+ params.until = this.dateFilter.endAt
+ }
+ break
+ case 'person':
+ if (provider.filters?.person) {
+ params.person = this.personFilter.user
+ }
+ break
+ }
+ })
+
+ if (this.providerResultLimit > 5) {
+ params.limit = this.providerResultLimit
+ unifiedSearchLogger.debug('Limiting search to', params.limit)
+ }
+
+ const shouldSkipSearch = !this.searchExternalResources && provider.isExternalProvider
+ const wasManuallySelected = this.filteredProviders.some(filteredProvider => filteredProvider.id === provider.id)
+ // if the provider is an external resource and the user has not manually selected it, skip the search
+ if (shouldSkipSearch && !wasManuallySelected) {
+ this.searching = false
+ return
+ }
+
+ const request = unifiedSearch(params).request
+
+ request().then((response) => {
+ newResults.push({
+ ...provider,
+ results: response.data.ocs.data.entries,
+ limit: params.limit ?? 5,
+ })
+
+ unifiedSearchLogger.debug('Unified search results:', { results: this.results, newResults })
+
+ this.updateResults(newResults)
+ this.searching = false
+ })
+ }
+
+ providersToSearch.forEach(searchProvider)
+ },
+ updateResults(newResults) {
+ let updatedResults = [...this.results]
+ // If filters are applied, remove any previous results for providers that are not in current filters
+ if (this.filters.length > 0) {
+ updatedResults = updatedResults.filter(result => {
+ return this.filters.some(filter => filter.id === result.id)
+ })
+ }
+ // Process the new results
+ newResults.forEach(newResult => {
+ const existingResultIndex = updatedResults.findIndex(result => result.id === newResult.id)
+ if (existingResultIndex !== -1) {
+ if (newResult.results.length === 0) {
+ // If the new results data has no matches for and existing result, remove the existing result
+ updatedResults.splice(existingResultIndex, 1)
+ } else {
+ // If input triggered a change in existing results, update existing result
+ updatedResults.splice(existingResultIndex, 1, newResult)
+ }
+ } else if (newResult.results.length > 0) {
+ // Push the new result to the array only if its results array is not empty
+ updatedResults.push(newResult)
+ }
+ })
+ const sortedResults = updatedResults.slice(0)
+ // Order results according to provider preference
+ sortedResults.sort((a, b) => {
+ const aProvider = this.providers.find(provider => provider.id === a.id)
+ const bProvider = this.providers.find(provider => provider.id === b.id)
+ const aOrder = aProvider ? aProvider.order : 0
+ const bOrder = bProvider ? bProvider.order : 0
+ return aOrder - bOrder
+ })
+ this.results = sortedResults
+ },
+ mapContacts(contacts) {
+ return contacts.map(contact => {
+ return {
+ // id: contact.id,
+ // name: '',
+ displayName: contact.fullName,
+ isNoUser: false,
+ subname: contact.emailAddresses[0] ? contact.emailAddresses[0] : '',
+ icon: '',
+ user: contact.id,
+ isUser: contact.isUser,
+ }
+ })
+ },
+ filterContacts(query) {
+ getContacts({ searchTerm: query }).then((contacts) => {
+ this.contacts = this.mapContacts(contacts)
+ unifiedSearchLogger.debug(`Contacts filtered by ${query}`, { contacts: this.contacts })
+ })
+ },
+ applyPersonFilter(person) {
+
+ const existingPersonFilter = this.filters.findIndex(filter => filter.id === person.id)
+ if (existingPersonFilter === -1) {
+ this.personFilter.id = person.id
+ this.personFilter.user = person.user
+ this.personFilter.name = person.displayName
+ this.filters.push(this.personFilter)
+ } else {
+ this.filters[existingPersonFilter].id = person.id
+ this.filters[existingPersonFilter].user = person.user
+ this.filters[existingPersonFilter].name = person.displayName
+ }
+
+ this.providers.forEach(async (provider, index) => {
+ this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['person']))
+ })
+
+ this.debouncedFind(this.searchQuery)
+ unifiedSearchLogger.debug('Person filter applied', { person })
+ },
+ async loadMoreResultsForProvider(provider) {
+ this.providerResultLimit += 5
+ this.find(this.searchQuery, [provider])
+ },
+ addProviderFilter(providerFilter, loadMoreResultsForProvider = false) {
+ unifiedSearchLogger.debug('Applying provider filter', { providerFilter, loadMoreResultsForProvider })
+ if (!providerFilter.id) return
+ if (providerFilter.isPluginFilter) {
+ // There is no way to know what should go into the callback currently
+ // Here we are passing isProviderFilterApplied (boolean) which is a flag sent to the plugin
+ // This is sent to the plugin so that depending on whether the filter is applied or not, the plugin can decide what to do
+ // TODO : In nextcloud/search, this should be a proper interface that the plugin can implement
+ const isProviderFilterApplied = this.filteredProviders.some(provider => provider.id === providerFilter.id)
+ providerFilter.callback(!isProviderFilterApplied)
+ }
+ this.providerResultLimit = loadMoreResultsForProvider ? this.providerResultLimit : 5
+ this.providerActionMenuIsOpen = false
+ // With the possibility for other apps to add new filters
+ // Resulting in a possible id/provider collision
+ // If a user tries to apply a filter that seems to already exist, we remove the current one and add the new one.
+ const existingFilterIndex = this.filteredProviders.findIndex(existing => existing.id === providerFilter.id)
+ if (existingFilterIndex > -1) {
+ this.filteredProviders.splice(existingFilterIndex, 1)
+ this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+ }
+ this.filteredProviders.push({
+ ...providerFilter,
+ type: providerFilter.type || 'provider',
+ isPluginFilter: providerFilter.isPluginFilter || false,
+ })
+ this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+ unifiedSearchLogger.debug('Search filters (newly added)', { filters: this.filters })
+ this.debouncedFind(this.searchQuery)
+ },
+ removeFilter(filter) {
+ if (filter.type === 'provider') {
+ for (let i = 0; i < this.filteredProviders.length; i++) {
+ if (this.filteredProviders[i].id === filter.id) {
+ this.filteredProviders.splice(i, 1)
+ break
+ }
+ }
+ this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
+ unifiedSearchLogger.debug('Search filters (recently removed)', { filters: this.filters })
+
+ } else {
+ // Remove non provider filters such as date and person filters
+ for (let i = 0; i < this.filters.length; i++) {
+ if (this.filters[i].id === filter.id) {
+ this.filters.splice(i, 1)
+ this.enableAllProviders()
+ break
+ }
+ }
+ }
+ this.debouncedFind(this.searchQuery)
+ },
+ syncProviderFilters(firstArray, secondArray) {
+ // Create a copy of the first array to avoid modifying it directly.
+ const synchronizedArray = firstArray.slice()
+ // Remove items from the synchronizedArray that are not in the secondArray.
+ synchronizedArray.forEach((item, index) => {
+ const itemId = item.id
+ if (item.type === 'provider') {
+ if (!secondArray.some(secondItem => secondItem.id === itemId)) {
+ synchronizedArray.splice(index, 1)
+ }
+ }
+ })
+ // Add items to the synchronizedArray that are in the secondArray but not in the firstArray.
+ secondArray.forEach(secondItem => {
+ const itemId = secondItem.id
+ if (secondItem.type === 'provider') {
+ if (!synchronizedArray.some(item => item.id === itemId)) {
+ synchronizedArray.push(secondItem)
+ }
+ }
+ })
+
+ return synchronizedArray
+ },
+ updateDateFilter() {
+ const currFilterIndex = this.filters.findIndex(filter => filter.id === 'date')
+ if (currFilterIndex !== -1) {
+ this.filters[currFilterIndex] = this.dateFilter
+ } else {
+ this.filters.push(this.dateFilter)
+ }
+
+ this.providers.forEach(async (provider, index) => {
+ this.providers[index].disabled = !(await this.providerIsCompatibleWithFilters(provider, ['since', 'until']))
+ })
+ this.debouncedFind(this.searchQuery)
+ },
+ applyQuickDateRange(range) {
+ this.dateActionMenuIsOpen = false
+ const today = new Date()
+ let startDate
+ let endDate
+
+ switch (range) {
+ case 'today':
+ // For 'Today', both start and end are set to today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'Today')
+ break
+ case '7days':
+ // For 'Last 7 days', start date is 7 days ago, end is today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6, 0, 0, 0, 0)
+ this.dateFilter.text = t('core', 'Last 7 days')
+ break
+ case '30days':
+ // For 'Last 30 days', start date is 30 days ago, end is today
+ startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29, 0, 0, 0, 0)
+ this.dateFilter.text = t('core', 'Last 30 days')
+ break
+ case 'thisyear':
+ // For 'This year', start date is the first day of the year, end is the last day of the year
+ startDate = new Date(today.getFullYear(), 0, 1, 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear(), 11, 31, 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'This year')
+ break
+ case 'lastyear':
+ // For 'Last year', start date is the first day of the previous year, end is the last day of the previous year
+ startDate = new Date(today.getFullYear() - 1, 0, 1, 0, 0, 0, 0)
+ endDate = new Date(today.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
+ this.dateFilter.text = t('core', 'Last year')
+ break
+ case 'custom':
+ this.showDateRangeModal = true
+ return
+ default:
+ return
+ }
+ this.dateFilter.startFrom = startDate
+ this.dateFilter.endAt = endDate
+ this.updateDateFilter()
+
+ },
+ setCustomDateRange(event) {
+ unifiedSearchLogger.debug('Custom date range', { range: event })
+ this.dateFilter.startFrom = event.startFrom
+ this.dateFilter.endAt = event.endAt
+ this.dateFilter.text = t('core', `Between ${this.dateFilter.startFrom.toLocaleDateString()} and ${this.dateFilter.endAt.toLocaleDateString()}`)
+ this.updateDateFilter()
+ },
+ handlePluginFilter(addFilterEvent) {
+ unifiedSearchLogger.debug('Handling plugin filter', { addFilterEvent })
+ for (let i = 0; i < this.filteredProviders.length; i++) {
+ const provider = this.filteredProviders[i]
+ if (provider.id === addFilterEvent.id) {
+ provider.name = addFilterEvent.filterUpdateText
+ // Filters attached may only make sense with certain providers,
+ // So, find the provider attached, add apply the extra parameters to those providers only
+ const compatibleProviderIndex = this.providers.findIndex(provider => provider.id === addFilterEvent.id)
+ if (compatibleProviderIndex > -1) {
+ provider.extraParams = addFilterEvent.filterParams
+ this.filteredProviders[i] = provider
+ }
+ break
+ }
+ }
+ this.debouncedFind(this.searchQuery)
+ },
+ groupProvidersByApp(filters) {
+ const groupedByProviderApp = {}
+
+ filters.forEach(filter => {
+ const provider = filter.appId ? filter.appId : 'general'
+ if (!groupedByProviderApp[provider]) {
+ groupedByProviderApp[provider] = []
+ }
+ groupedByProviderApp[provider].push(filter)
+ })
+
+ const flattenedArray = []
+ Object.values(groupedByProviderApp).forEach(group => {
+ flattenedArray.push(...group)
+ })
+
+ return flattenedArray
+ },
+ async providerIsCompatibleWithFilters(provider, filterIds) {
+ return filterIds.every(filterId => provider.filters?.[filterId] !== undefined)
+ },
+ async enableAllProviders() {
+ this.providers.forEach(async (_, index) => {
+ this.providers[index].disabled = false
+ })
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(.unified-search-modal .unified-search-modal__content) {
+ --dialog-height: min(80vh, 800px);
+ box-sizing: border-box;
+ height: var(--dialog-height);
+ max-height: var(--dialog-height);
+ min-height: var(--dialog-height);
+
+ display: flex;
+ flex-direction: column;
+ // No padding to prevent scrollbar misplacement
+ padding-inline: 0;
+}
+
+.unified-search-modal {
+ &__header {
+ // Add background to prevent leaking scrolled content (because of sticky position)
+ background-color: var(--color-main-background);
+ // Fix padding to have the input centered
+ padding-inline-end: 12px;
+ // Some padding to make elements scrolled under sticky position look nicer
+ padding-block-end: 12px;
+ // Make it sticky with the input margin for the label
+ position: sticky;
+ top: 6px;
+ }
+
+ &__filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ justify-content: start;
+ padding-top: 4px;
+ }
+
+ &__search-external-resources {
+ :deep(span.checkbox-content) {
+ padding-top: 0;
+ padding-bottom: 0;
+ }
+
+ :deep(.checkbox-content__icon) {
+ margin: auto !important;
+ }
+
+ &--aligned {
+ margin-inline-start: auto;
+ }
+ }
+
+ &__filters-applied {
+ padding-top: 4px;
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ &__no-content {
+ display: flex;
+ align-items: center;
+ margin-top: 0.5em;
+ height: 70%;
+ }
+
+ &__results {
+ overflow: hidden scroll;
+ // Adjust padding to match container but keep the scrollbar on the very end
+ padding-inline: 0 12px;
+ padding-block: 0 12px;
+
+ .result {
+ &-title {
+ color: var(--color-primary-element);
+ font-size: 16px;
+ margin-block: 8px 4px;
+ }
+
+ &-footer {
+ justify-content: space-between;
+ align-items: center;
+ display: flex;
+ }
+ }
+
+ }
+}
+
+.filter-button__icon {
+ height: 20px;
+ width: 20px;
+ object-fit: contain;
+ filter: var(--background-invert-if-bright);
+ padding: 11px; // align with text to fit at least 44px
+}
+
+// Ensure modal is accessible on small devices
+@media only screen and (max-height: 400px) {
+ .unified-search-modal__results {
+ overflow: unset;
+ }
+}
+</style>
diff --git a/core/src/components/UserMenu.js b/core/src/components/UserMenu.js
index a9e7d8725bb..5c488f2341e 100644
--- a/core/src/components/UserMenu.js
+++ b/core/src/components/UserMenu.js
@@ -1,53 +1,20 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import OC from '../OC'
+import Vue from 'vue'
-import $ from 'jquery'
+import AccountMenu from '../views/AccountMenu.vue'
export const setUp = () => {
- const $menu = $('#header #settings')
-
- // show loading feedback
- $menu.delegate('a', 'click', event => {
- let $page = $(event.target)
- if (!$page.is('a')) {
- $page = $page.closest('a')
- }
- if (event.which === 1 && !event.ctrlKey && !event.metaKey) {
- $page.find('img').remove()
- $page.find('div').remove() // prevent odd double-clicks
- $page.prepend($('<div/>').addClass('icon-loading-small'))
- } else {
- // Close navigation when opening menu entry in
- // a new tab
- OC.hideMenus(() => false)
- }
- })
-
- $menu.delegate('a', 'mouseup', event => {
- if (event.which === 2) {
- // Close navigation when opening app in
- // a new tab via middle click
- OC.hideMenus(() => false)
- }
- })
+ const mountPoint = document.getElementById('user-menu')
+ if (mountPoint) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ name: 'AccountMenuRoot',
+ el: mountPoint,
+ render: h => h(AccountMenu),
+ })
+ }
}
diff --git a/core/src/components/login/LoginButton.vue b/core/src/components/login/LoginButton.vue
index f7d426e6c63..da387df0ff6 100644
--- a/core/src/components/login/LoginButton.vue
+++ b/core/src/components/login/LoginButton.vue
@@ -1,44 +1,43 @@
<!--
- - @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div id="submit-wrapper" @click="$emit('click')">
- <input id="submit-form"
- type="submit"
- class="login primary"
- title=""
- :value="!loading ? t('core', 'Log in') : t('core', 'Logging in …')">
- <div class="submit-icon"
- :class="{
- 'icon-confirm-white': !loading,
- 'icon-loading-small': loading && invertedColors,
- 'icon-loading-small-dark': loading && !invertedColors,
- }" />
- </div>
+ <NcButton type="primary"
+ native-type="submit"
+ :wide="true"
+ :disabled="loading"
+ @click="$emit('click')">
+ {{ !loading ? value : valueLoading }}
+ <template #icon>
+ <div v-if="loading" class="submit-wrapper__icon icon-loading-small-dark" />
+ <ArrowRight v-else class="submit-wrapper__icon" />
+ </template>
+ </NcButton>
</template>
<script>
+import { translate as t } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+
export default {
name: 'LoginButton',
+ components: {
+ ArrowRight,
+ NcButton,
+ },
props: {
+ value: {
+ type: String,
+ default: t('core', 'Log in'),
+ },
+ valueLoading: {
+ type: String,
+ default: t('core', 'Logging in …'),
+ },
loading: {
type: Boolean,
required: true,
@@ -51,6 +50,8 @@ export default {
}
</script>
-<style scoped>
-
+<style lang="scss" scoped>
+.button-vue {
+ margin-top: .5rem;
+}
</style>
diff --git a/core/src/components/login/LoginForm.cy.ts b/core/src/components/login/LoginForm.cy.ts
new file mode 100644
index 00000000000..1b1aeda6306
--- /dev/null
+++ b/core/src/components/login/LoginForm.cy.ts
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import LoginForm from './LoginForm.vue'
+
+describe('core: LoginForm', { testIsolation: true }, () => {
+ beforeEach(() => {
+ // Mock the required global state
+ cy.window().then(($window) => {
+ $window.OC = {
+ theme: {
+ name: 'J\'s cloud',
+ },
+ requestToken: 'request-token',
+ }
+ })
+ })
+
+ /**
+ * Ensure that characters like ' are not double HTML escaped.
+ * This was a bug in https://github.com/nextcloud/server/issues/34990
+ */
+ it('does not double escape special characters in product name', () => {
+ cy.mount(LoginForm, {
+ propsData: {
+ username: 'test-user',
+ },
+ })
+
+ cy.get('h2').contains('J\'s cloud')
+ })
+
+ it('fills username from props into form', () => {
+ cy.mount(LoginForm, {
+ propsData: {
+ username: 'test-user',
+ },
+ })
+
+ cy.get('input[name="user"]')
+ .should('exist')
+ .and('have.attr', 'id', 'user')
+
+ cy.get('input[name="user"]')
+ .should('have.value', 'test-user')
+ })
+
+ it('clears password after timeout', () => {
+ // mock timeout of 5 seconds
+ cy.window().then(($window) => {
+ const state = $window.document.createElement('input')
+ state.type = 'hidden'
+ state.id = 'initial-state-core-loginTimeout'
+ state.value = btoa(JSON.stringify(5))
+ $window.document.body.appendChild(state)
+ })
+
+ // mount forms
+ cy.mount(LoginForm)
+
+ cy.get('input[name="password"]')
+ .should('exist')
+ .type('MyPassword')
+
+ cy.get('input[name="password"]')
+ .should('have.value', 'MyPassword')
+
+ // Wait for timeout
+ // eslint-disable-next-line cypress/no-unnecessary-waiting
+ cy.wait(5100)
+
+ cy.get('input[name="password"]')
+ .should('have.value', '')
+ })
+})
diff --git a/core/src/components/login/LoginForm.vue b/core/src/components/login/LoginForm.vue
index 9f0d4f9ba1c..8cbe55f1f68 100644
--- a/core/src/components/login/LoginForm.vue
+++ b/core/src/components/login/LoginForm.vue
@@ -1,48 +1,37 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<form ref="loginForm"
+ class="login-form"
method="post"
name="login"
:action="loginActionUrl"
@submit="submit">
- <fieldset>
- <div v-if="apacheAuthFailed"
- class="warning">
- {{ t('core', 'Server side authentication failed!') }}<br>
- <small>{{ t('core', 'Please contact your administrator.') }}
- </small>
- </div>
- <div v-for="(message, index) in messages"
- :key="index"
- class="warning">
- {{ message }}<br>
- </div>
- <div v-if="internalException"
- class="warning">
- {{ t('core', 'An internal error occurred.') }}<br>
- <small>{{ t('core', 'Please try again or contact your administrator.') }}
- </small>
- </div>
+ <fieldset class="login-form__fieldset" data-login-form>
+ <NcNoteCard v-if="apacheAuthFailed"
+ :title="t('core', 'Server side authentication failed!')"
+ type="warning">
+ {{ t('core', 'Please contact your administrator.') }}
+ </NcNoteCard>
+ <NcNoteCard v-if="csrfCheckFailed"
+ :heading="t('core', 'Session error')"
+ type="error">
+ {{ t('core', 'It appears your session token has expired, please refresh the page and try again.') }}
+ </NcNoteCard>
+ <NcNoteCard v-if="messages.length > 0">
+ <div v-for="(message, index) in messages"
+ :key="index">
+ {{ message }}<br>
+ </div>
+ </NcNoteCard>
+ <NcNoteCard v-if="internalException"
+ :class="t('core', 'An internal error occurred.')"
+ type="warning">
+ {{ t('core', 'Please try again or contact your administrator.') }}
+ </NcNoteCard>
<div id="message"
class="hidden">
<img class="float-spinner"
@@ -52,55 +41,40 @@
<!-- the following div ensures that the spinner is always inside the #message div -->
<div style="clear: both;" />
</div>
- <p class="grouptop"
- :class="{shake: invalidPassword}">
- <input id="user"
- ref="user"
- v-model="user"
- type="text"
- name="user"
- autocapitalize="off"
- :autocomplete="autoCompleteAllowed ? 'on' : 'off'"
- :placeholder="t('core', 'Username or email')"
- :aria-label="t('core', 'Username or email')"
- required
- @change="updateUsername">
- <label for="user" class="infield">{{ t('core', 'Username or email') }}</label>
- </p>
-
- <p class="groupbottom"
- :class="{shake: invalidPassword}">
- <input id="password"
- ref="password"
- :type="passwordInputType"
- class="password-with-toggle"
- name="password"
- :autocomplete="autoCompleteAllowed ? 'on' : 'off'"
- :placeholder="t('core', 'Password')"
- :aria-label="t('core', 'Password')"
- required>
- <label for="password"
- class="infield">{{ t('Password') }}</label>
- <a href="#" class="toggle-password" @click.stop.prevent="togglePassword">
- <img :src="toggleIcon">
- </a>
- </p>
-
- <LoginButton :loading="loading" :inverted-colors="invertedColors" />
-
- <p v-if="invalidPassword"
- class="warning wrongPasswordMsg">
- {{ t('core', 'Wrong username or password.') }}
- </p>
- <p v-else-if="userDisabled"
- class="warning userDisabledMsg">
- {{ t('core', 'User disabled') }}
- </p>
-
- <p v-if="throttleDelay && throttleDelay > 5000"
- class="warning throttledMsg">
- {{ t('core', 'We have detected multiple invalid login attempts from your IP. Therefore your next login is throttled up to 30 seconds.') }}
- </p>
+ <h2 class="login-form__headline" data-login-form-headline>
+ {{ headlineText }}
+ </h2>
+ <NcTextField id="user"
+ ref="user"
+ :label="loginText"
+ name="user"
+ :maxlength="255"
+ :value.sync="user"
+ :class="{shake: invalidPassword}"
+ autocapitalize="none"
+ :spellchecking="false"
+ :autocomplete="autoCompleteAllowed ? 'username' : 'off'"
+ required
+ :error="userNameInputLengthIs255"
+ :helper-text="userInputHelperText"
+ data-login-form-input-user
+ @change="updateUsername" />
+
+ <NcPasswordField id="password"
+ ref="password"
+ name="password"
+ :class="{shake: invalidPassword}"
+ :value.sync="password"
+ :spellchecking="false"
+ autocapitalize="none"
+ :autocomplete="autoCompleteAllowed ? 'current-password' : 'off'"
+ :label="t('core', 'Password')"
+ :helper-text="errorLabel"
+ :error="isError"
+ data-login-form-input-password
+ required />
+
+ <LoginButton data-login-form-submit :loading="loading" />
<input v-if="redirectUrl"
type="hidden"
@@ -114,7 +88,7 @@
:value="timezoneOffset">
<input type="hidden"
name="requesttoken"
- :value="OC.requestToken">
+ :value="requestToken">
<input v-if="directLogin"
type="hidden"
name="direct"
@@ -124,23 +98,38 @@
</template>
<script>
-import jstz from 'jstimezonedetect'
-import LoginButton from './LoginButton'
-import {
- generateUrl,
- imagePath,
-} from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { generateUrl, imagePath } from '@nextcloud/router'
+import debounce from 'debounce'
+
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+
+import AuthMixin from '../../mixins/auth.js'
+import LoginButton from './LoginButton.vue'
export default {
name: 'LoginForm',
- components: { LoginButton },
+
+ components: {
+ LoginButton,
+ NcPasswordField,
+ NcTextField,
+ NcNoteCard,
+ },
+
+ mixins: [AuthMixin],
+
props: {
username: {
type: String,
default: '',
},
redirectUrl: {
- type: String,
+ type: [String, Boolean],
+ default: false,
},
errors: {
type: Array,
@@ -152,10 +141,7 @@ export default {
},
throttleDelay: {
type: Number,
- },
- invertedColors: {
- type: Boolean,
- default: false,
+ default: 0,
},
autoCompleteAllowed: {
type: Boolean,
@@ -165,21 +151,73 @@ export default {
type: Boolean,
default: false,
},
+ emailStates: {
+ type: Array,
+ default() {
+ return []
+ },
+ },
},
+
+ setup() {
+ // non reactive props
+ return {
+ t,
+
+ // Disable escape and sanitize to prevent special characters to be html escaped
+ // For example "J's cloud" would be escaped to "J&#39; cloud". But we do not need escaping as Vue does this in `v-text` automatically
+ headlineText: t('core', 'Log in to {productName}', { productName: OC.theme.name }, undefined, { sanitize: false, escape: false }),
+
+ loginTimeout: loadState('core', 'loginTimeout', 300),
+ requestToken: window.OC.requestToken,
+ timezone: (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone,
+ timezoneOffset: (-new Date().getTimezoneOffset() / 60),
+ }
+ },
+
data() {
return {
loading: false,
- timezone: jstz.determine().name(),
- timezoneOffset: (-new Date().getTimezoneOffset() / 60),
- user: this.username,
+ user: '',
password: '',
- passwordInputType: 'password',
}
},
+
computed: {
+ /**
+ * Reset the login form after a long idle time (debounced)
+ */
+ resetFormTimeout() {
+ // Infinite timeout, do nothing
+ if (this.loginTimeout <= 0) {
+ return () => {}
+ }
+ // Debounce for given timeout (in seconds so convert to milli seconds)
+ return debounce(this.handleResetForm, this.loginTimeout * 1000)
+ },
+
+ isError() {
+ return this.invalidPassword || this.userDisabled
+ || this.throttleDelay > 5000
+ },
+ errorLabel() {
+ if (this.invalidPassword) {
+ return t('core', 'Wrong login or password.')
+ }
+ if (this.userDisabled) {
+ return t('core', 'This account is disabled')
+ }
+ if (this.throttleDelay > 5000) {
+ return t('core', 'We have detected multiple invalid login attempts from your IP. Therefore your next login is throttled up to 30 seconds.')
+ }
+ return undefined
+ },
apacheAuthFailed() {
return this.errors.indexOf('apacheAuthFailed') !== -1
},
+ csrfCheckFailed() {
+ return this.errors.indexOf('csrfCheckFailed') !== -1
+ },
internalException() {
return this.errors.indexOf('internalexception') !== -1
},
@@ -189,35 +227,60 @@ export default {
userDisabled() {
return this.errors.indexOf('userdisabled') !== -1
},
- toggleIcon() {
- return imagePath('core', 'actions/toggle.svg')
- },
loadingIcon() {
return imagePath('core', 'loading-dark.gif')
},
loginActionUrl() {
return generateUrl('login')
},
+ emailEnabled() {
+ return this.emailStates ? this.emailStates.every((state) => state === '1') : 1
+ },
+ loginText() {
+ if (this.emailEnabled) {
+ return t('core', 'Account name or email')
+ }
+ return t('core', 'Account name')
+ },
+ },
+
+ watch: {
+ /**
+ * Reset form reset after the password was changed
+ */
+ password() {
+ this.resetFormTimeout()
+ },
},
+
mounted() {
if (this.username === '') {
- this.$refs.user.focus()
+ this.$refs.user.$refs.inputField.$refs.input.focus()
} else {
- this.$refs.password.focus()
+ this.user = this.username
+ this.$refs.password.$refs.inputField.$refs.input.focus()
}
},
+
methods: {
- togglePassword() {
- if (this.passwordInputType === 'password') {
- this.passwordInputType = 'text'
- } else {
- this.passwordInputType = 'password'
- }
+ /**
+ * Handle reset of the login form after a long IDLE time
+ * This is recommended security behavior to prevent password leak on public devices
+ */
+ handleResetForm() {
+ this.password = ''
},
+
updateUsername() {
this.$emit('update:username', this.user)
},
- submit() {
+ submit(event) {
+ if (this.loading) {
+ // Prevent the form from being submitted twice
+ event.preventDefault()
+ return
+ }
+
this.loading = true
this.$emit('submit')
},
@@ -225,6 +288,27 @@ export default {
}
</script>
-<style scoped>
+<style lang="scss" scoped>
+.login-form {
+ text-align: start;
+ font-size: 1rem;
+ margin: 0;
+ &__fieldset {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: .5rem;
+ }
+
+ &__headline {
+ text-align: center;
+ overflow-wrap: anywhere;
+ }
+
+ // Only show the error state if the user interacted with the login box
+ :deep(input:invalid:not(:user-invalid)) {
+ border-color: var(--color-border-maxcontrast) !important;
+ }
+}
</style>
diff --git a/core/src/components/login/PasswordLessLoginForm.vue b/core/src/components/login/PasswordLessLoginForm.vue
index df774599f92..bc4d25bf70f 100644
--- a/core/src/components/login/PasswordLessLoginForm.vue
+++ b/core/src/components/login/PasswordLessLoginForm.vue
@@ -1,57 +1,75 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <form v-if="isHttps && hasPublicKeyCredential"
+ <form v-if="(isHttps || isLocalhost) && supportsWebauthn"
ref="loginForm"
+ aria-labelledby="password-less-login-form-title"
+ class="password-less-login-form"
method="post"
name="login"
@submit.prevent="submit">
- <fieldset>
- <p class="grouptop groupbottom">
- <input id="user"
- ref="user"
- v-model="user"
- type="text"
- name="user"
- :autocomplete="autoCompleteAllowed ? 'on' : 'off'"
- :placeholder="t('core', 'Username or email')"
- :aria-label="t('core', 'Username or email')"
- required
- @change="$emit('update:username', user)">
- <label for="user" class="infield">{{ t('core', 'Username or email') }}</label>
- </p>
-
- <div v-if="!validCredentials">
- {{ t('core', 'Your account is not setup for passwordless login.') }}
- </div>
-
- <LoginButton v-if="validCredentials"
- :loading="loading"
- :inverted-colors="invertedColors"
- @click="authenticate" />
- </fieldset>
+ <h2 id="password-less-login-form-title">
+ {{ t('core', 'Log in with a device') }}
+ </h2>
+
+ <NcTextField required
+ :value="user"
+ :autocomplete="autoCompleteAllowed ? 'on' : 'off'"
+ :error="!validCredentials"
+ :label="t('core', 'Login or email')"
+ :placeholder="t('core', 'Login or email')"
+ :helper-text="!validCredentials ? t('core', 'Your account is not setup for passwordless login.') : ''"
+ @update:value="changeUsername" />
+
+ <LoginButton v-if="validCredentials"
+ :loading="loading"
+ @click="authenticate" />
</form>
- <div v-else-if="!hasPublicKeyCredential">
- {{ t('core', 'Passwordless authentication is not supported in your browser.') }}
- </div>
- <div v-else-if="!isHttps">
- {{ t('core', 'Passwordless authentication is only available over a secure connection.') }}
- </div>
+
+ <NcEmptyContent v-else-if="!isHttps && !isLocalhost"
+ :name="t('core', 'Your connection is not secure')"
+ :description="t('core', 'Passwordless authentication is only available over a secure connection.')">
+ <template #icon>
+ <LockOpenIcon />
+ </template>
+ </NcEmptyContent>
+
+ <NcEmptyContent v-else
+ :name="t('core', 'Browser not supported')"
+ :description="t('core', 'Passwordless authentication is not supported in your browser.')">
+ <template #icon>
+ <InformationIcon />
+ </template>
+ </NcEmptyContent>
</template>
-<script>
+<script type="ts">
+import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
+import { defineComponent } from 'vue'
import {
+ NoValidCredentials,
startAuthentication,
finishAuthentication,
-} from '../../services/WebAuthnAuthenticationService'
-import LoginButton from './LoginButton'
+} from '../../services/WebAuthnAuthenticationService.ts'
-class NoValidCredentials extends Error {
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
-}
+import InformationIcon from 'vue-material-design-icons/InformationOutline.vue'
+import LoginButton from './LoginButton.vue'
+import LockOpenIcon from 'vue-material-design-icons/LockOpen.vue'
+import logger from '../../logger'
-export default {
+export default defineComponent({
name: 'PasswordLessLoginForm',
components: {
LoginButton,
+ InformationIcon,
+ LockOpenIcon,
+ NcEmptyContent,
+ NcTextField,
},
props: {
username: {
@@ -59,10 +77,7 @@ export default {
default: '',
},
redirectUrl: {
- type: String,
- },
- invertedColors: {
- type: Boolean,
+ type: [String, Boolean],
default: false,
},
autoCompleteAllowed: {
@@ -73,11 +88,18 @@ export default {
type: Boolean,
default: false,
},
- hasPublicKeyCredential: {
+ isLocalhost: {
type: Boolean,
default: false,
},
},
+
+ setup() {
+ return {
+ supportsWebauthn: browserSupportsWebAuthn(),
+ }
+ },
+
data() {
return {
user: this.username,
@@ -86,114 +108,37 @@ export default {
}
},
methods: {
- authenticate() {
- console.debug('passwordless login initiated')
+ async authenticate() {
+ // check required fields
+ if (!this.$refs.loginForm.checkValidity()) {
+ return
+ }
- this.getAuthenticationData(this.user)
- .then(publicKey => {
- console.debug(publicKey)
- return publicKey
- })
- .then(this.sign)
- .then(this.completeAuthentication)
- .catch(error => {
- if (error instanceof NoValidCredentials) {
- this.validCredentials = false
- return
- }
- console.debug(error)
- })
- },
- getAuthenticationData(uid) {
- const base64urlDecode = function(input) {
- // Replace non-url compatible chars with base64 standard chars
- input = input
- .replace(/-/g, '+')
- .replace(/_/g, '/')
+ console.debug('passwordless login initiated')
- // Pad out with standard base64 required padding characters
- const pad = input.length % 4
- if (pad) {
- if (pad === 1) {
- throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding')
- }
- input += new Array(5 - pad).join('=')
+ try {
+ const params = await startAuthentication(this.user)
+ await this.completeAuthentication(params)
+ } catch (error) {
+ if (error instanceof NoValidCredentials) {
+ this.validCredentials = false
+ return
}
-
- return window.atob(input)
+ logger.debug(error)
}
-
- return startAuthentication(uid)
- .then(publicKey => {
- console.debug('Obtained PublicKeyCredentialRequestOptions')
- console.debug(publicKey)
-
- if (!Object.prototype.hasOwnProperty.call(publicKey, 'allowCredentials')) {
- console.debug('No credentials found.')
- throw new NoValidCredentials()
- }
-
- publicKey.challenge = Uint8Array.from(base64urlDecode(publicKey.challenge), c => c.charCodeAt(0))
- publicKey.allowCredentials = publicKey.allowCredentials.map(function(data) {
- return {
- ...data,
- id: Uint8Array.from(base64urlDecode(data.id), c => c.charCodeAt(0)),
- }
- })
-
- console.debug('Converted PublicKeyCredentialRequestOptions')
- console.debug(publicKey)
- return publicKey
- })
- .catch(error => {
- console.debug('Error while obtaining data')
- throw error
- })
},
- sign(publicKey) {
- const arrayToBase64String = function(a) {
- return window.btoa(String.fromCharCode(...a))
- }
-
- const arrayToString = function(a) {
- return String.fromCharCode(...a)
- }
-
- return navigator.credentials.get({ publicKey })
- .then(data => {
- console.debug(data)
- console.debug(new Uint8Array(data.rawId))
- console.debug(arrayToBase64String(new Uint8Array(data.rawId)))
- return {
- id: data.id,
- type: data.type,
- rawId: arrayToBase64String(new Uint8Array(data.rawId)),
- response: {
- authenticatorData: arrayToBase64String(new Uint8Array(data.response.authenticatorData)),
- clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)),
- signature: arrayToBase64String(new Uint8Array(data.response.signature)),
- userHandle: data.response.userHandle ? arrayToString(new Uint8Array(data.response.userHandle)) : null,
- },
- }
- })
- .then(challenge => {
- console.debug(challenge)
- return challenge
- })
- .catch(error => {
- console.debug('GOT AN ERROR!')
- console.debug(error) // Example: timeout, interaction refused...
- })
+ changeUsername(username) {
+ this.user = username
+ this.$emit('update:username', this.user)
},
completeAuthentication(challenge) {
- console.debug('TIME TO COMPLETE')
-
- const location = this.redirectUrl
+ const redirectUrl = this.redirectUrl
- return finishAuthentication(JSON.stringify(challenge))
- .then(data => {
+ return finishAuthentication(challenge)
+ .then(({ defaultRedirectUrl }) => {
console.debug('Logged in redirecting')
- window.location.href = location
+ // Redirect url might be false so || should be used instead of ??.
+ window.location.href = redirectUrl || defaultRedirectUrl
})
.catch(error => {
console.debug('GOT AN ERROR WHILE SUBMITTING CHALLENGE!')
@@ -204,9 +149,14 @@ export default {
// noop
},
},
-}
+})
</script>
-<style scoped>
-
+<style lang="scss" scoped>
+.password-less-login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 0;
+}
</style>
diff --git a/core/src/components/login/ResetPassword.vue b/core/src/components/login/ResetPassword.vue
index cba7a89fd88..fee1deacc36 100644
--- a/core/src/components/login/ResetPassword.vue
+++ b/core/src/components/login/ResetPassword.vue
@@ -1,87 +1,68 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <form @submit.prevent="submit">
- <fieldset>
- <p>
- <input id="user"
- v-model="user"
- type="text"
- name="user"
- autocapitalize="off"
- :placeholder="t('core', 'Username or email')"
- :aria-label="t('core', 'Username or email')"
- required
- @change="updateUsername">
- <!--<?php p($_['user_autofocus'] ? 'autofocus' : ''); ?>
- autocomplete="<?php p($_['login_form_autocomplete']); ?>" autocapitalize="none" autocorrect="off"-->
- <label for="user" class="infield">{{ t('core', 'Username or email') }}</label>
- </p>
- <div id="reset-password-wrapper">
- <input id="reset-password-submit"
- type="submit"
- class="login primary"
- title=""
- :value="t('core', 'Reset password')">
- <div class="submit-icon"
- :class="{
- 'icon-confirm-white': !loading,
- 'icon-loading-small': loading && invertedColors,
- 'icon-loading-small-dark': loading && !invertedColors,
- }" />
- </div>
- <p v-if="message === 'send-success'"
- class="update">
- {{ t('core', 'A password reset message has been sent to the e-mail address of this account. If you do not receive it, check your spam/junk folders or ask your local administrator for help.') }}
- <br>
- {{ t('core', 'If it is not there ask your local administrator.') }}
- </p>
- <p v-else-if="message === 'send-error'"
- class="update warning">
- {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }}
- </p>
- <p v-else-if="message === 'reset-error'"
- class="update warning">
- {{ t('core', 'Password can not be changed. Please contact your administrator.') }}
- </p>
- <p v-else-if="message"
- class="update"
- :class="{warning: error}" />
-
- <a href="#"
- @click.prevent="$emit('abort')">
- {{ t('core', 'Back to login') }}
- </a>
- </fieldset>
+ <form class="reset-password-form" @submit.prevent="submit">
+ <h2>{{ t('core', 'Reset password') }}</h2>
+
+ <NcTextField id="user"
+ :value.sync="user"
+ name="user"
+ :maxlength="255"
+ autocapitalize="off"
+ :label="t('core', 'Login or email')"
+ :error="userNameInputLengthIs255"
+ :helper-text="userInputHelperText"
+ required
+ @change="updateUsername" />
+
+ <LoginButton :loading="loading" :value="t('core', 'Reset password')" />
+
+ <NcButton type="tertiary" wide @click="$emit('abort')">
+ {{ t('core', 'Back to login') }}
+ </NcButton>
+
+ <NcNoteCard v-if="message === 'send-success'"
+ type="success">
+ {{ t('core', 'If this account exists, a password reset message has been sent to its email address. If you do not receive it, verify your email address and/or Login, check your spam/junk folders or ask your local administration for help.') }}
+ </NcNoteCard>
+ <NcNoteCard v-else-if="message === 'send-error'"
+ type="error">
+ {{ t('core', 'Couldn\'t send reset email. Please contact your administrator.') }}
+ </NcNoteCard>
+ <NcNoteCard v-else-if="message === 'reset-error'"
+ type="error">
+ {{ t('core', 'Password cannot be changed. Please contact your administrator.') }}
+ </NcNoteCard>
</form>
</template>
-<script>
+<script lang="ts">
+import { generateUrl } from '@nextcloud/router'
+import { defineComponent } from 'vue'
+
import axios from '@nextcloud/axios'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
-import { generateUrl } from '@nextcloud/router'
+import AuthMixin from '../../mixins/auth.js'
+import LoginButton from './LoginButton.vue'
+import logger from '../../logger.js'
-export default {
+export default defineComponent({
name: 'ResetPassword',
+ components: {
+ LoginButton,
+ NcButton,
+ NcNoteCard,
+ NcTextField,
+ },
+
+ mixins: [AuthMixin],
+
props: {
username: {
type: String,
@@ -91,16 +72,13 @@ export default {
type: String,
required: true,
},
- invertedColors: {
- type: Boolean,
- default: false,
- },
},
+
data() {
return {
error: false,
loading: false,
- message: undefined,
+ message: '',
user: this.username,
}
},
@@ -113,39 +91,38 @@ export default {
updateUsername() {
this.$emit('update:username', this.user)
},
- submit() {
+
+ async submit() {
this.loading = true
this.error = false
this.message = ''
const url = generateUrl('/lostpassword/email')
- const data = {
- user: this.user,
- }
+ try {
+ const { data } = await axios.post(url, { user: this.user })
+ if (data.status !== 'success') {
+ throw new Error(`got status ${data.status}`)
+ }
- return axios.post(url, data)
- .then(resp => resp.data)
- .then(data => {
- if (data.status !== 'success') {
- throw new Error(`got status ${data.status}`)
- }
-
- this.message = 'send-success'
- })
- .catch(e => {
- console.error('could not send reset e-mail request', e)
-
- this.error = true
- this.message = 'send-error'
- })
- .then(() => { this.loading = false })
+ this.message = 'send-success'
+ } catch (error) {
+ logger.error('could not send reset email request', { error })
+
+ this.error = true
+ this.message = 'send-error'
+ } finally {
+ this.loading = false
+ }
},
},
-}
+})
</script>
-<style scoped>
- .update {
- width: auto;
- }
+<style lang="scss" scoped>
+.reset-password-form {
+ display: flex;
+ flex-direction: column;
+ gap: .5rem;
+ width: 100%;
+}
</style>
diff --git a/core/src/components/login/UpdatePassword.vue b/core/src/components/login/UpdatePassword.vue
index 3fa3c60773c..b7b9ecccd0a 100644
--- a/core/src/components/login/UpdatePassword.vue
+++ b/core/src/components/login/UpdatePassword.vue
@@ -1,24 +1,7 @@
<!--
- - @copyright Copyright (c) 2019 Julius Härtl <jus@bitgrid.net>
- -
- - @author Julius Härtl <jus@bitgrid.net>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
<form @submit.prevent="submit">
@@ -29,6 +12,9 @@
v-model="password"
type="password"
name="password"
+ autocomplete="new-password"
+ autocapitalize="none"
+ spellcheck="false"
required
:placeholder="t('core', 'New password')">
</p>
@@ -46,18 +32,9 @@
</label>
</div>
- <div id="submit-wrapper">
- <input id="submit"
- type="submit"
- class="login primary"
- title=""
- :value="!loading ? t('core', 'Reset password') : t('core', 'Resetting password')">
- <div class="submit-icon"
- :class="{
- 'icon-loading-small': loading && invertedColors,
- 'icon-loading-small-dark': loading && !invertedColors
- }" />
- </div>
+ <LoginButton :loading="loading"
+ :value="t('core', 'Reset password')"
+ :value-loading="t('core', 'Resetting password')" />
<p v-if="error && message" :class="{warning: error}">
{{ message }}
@@ -68,9 +45,13 @@
<script>
import Axios from '@nextcloud/axios'
+import LoginButton from './LoginButton.vue'
export default {
name: 'UpdatePassword',
+ components: {
+ LoginButton,
+ },
props: {
username: {
type: String,
@@ -80,10 +61,6 @@ export default {
type: String,
required: true,
},
- invertedColors: {
- type: Boolean,
- default: false,
- },
},
data() {
return {
@@ -125,7 +102,7 @@ export default {
}
} catch (e) {
this.error = true
- this.message = e.message ? e.message : t('core', 'Password can not be changed. Please contact your administrator.')
+ this.message = e.message ? e.message : t('core', 'Password cannot be changed. Please contact your administrator.')
} finally {
this.loading = false
}
diff --git a/core/src/components/setup/RecommendedApps.vue b/core/src/components/setup/RecommendedApps.vue
index 25158d6e915..f2120c28402 100644
--- a/core/src/components/setup/RecommendedApps.vue
+++ b/core/src/components/setup/RecommendedApps.vue
@@ -1,70 +1,70 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div class="body-login-container">
+ <div class="guest-box" data-cy-setup-recommended-apps>
<h2>{{ t('core', 'Recommended apps') }}</h2>
<p v-if="loadingApps" class="loading text-center">
{{ t('core', 'Loading apps …') }}
</p>
<p v-else-if="loadingAppsError" class="loading-error text-center">
- {{ t('core', 'Could not fetch list of apps from the app store.') }}
- </p>
- <p v-else class="text-center">
- {{ t('core', 'Installing apps …') }}
+ {{ t('core', 'Could not fetch list of apps from the App Store.') }}
</p>
+
<div v-for="app in recommendedApps" :key="app.id" class="app">
- <img :src="customIcon(app.id)" alt="">
- <div class="info">
- <h3>
- {{ app.name }}
- <span v-if="app.loading" class="icon icon-loading-small-dark" />
- <span v-else-if="app.active" class="icon icon-checkmark-white" />
- </h3>
- <p v-html="customDescription(app.id)" />
- <p v-if="app.installationError">
- <strong>{{ t('core', 'App download or installation failed') }}</strong>
- </p>
- <p v-else-if="!app.isCompatible">
- <strong>{{ t('core', 'Can\'t install this app because it is not compatible') }}</strong>
- </p>
- <p v-else-if="!app.canInstall">
- <strong>{{ t('core', 'Can\'t install this app') }}</strong>
- </p>
- </div>
+ <template v-if="!isHidden(app.id)">
+ <img :src="customIcon(app.id)" alt="">
+ <div class="info">
+ <h3>{{ customName(app) }}</h3>
+ <p v-text="customDescription(app.id)" />
+ <p v-if="app.installationError">
+ <strong>{{ t('core', 'App download or installation failed') }}</strong>
+ </p>
+ <p v-else-if="!app.isCompatible">
+ <strong>{{ t('core', 'Cannot install this app because it is not compatible') }}</strong>
+ </p>
+ <p v-else-if="!app.canInstall">
+ <strong>{{ t('core', 'Cannot install this app') }}</strong>
+ </p>
+ </div>
+ <NcCheckboxRadioSwitch :checked="app.isSelected || app.active"
+ :disabled="!app.isCompatible || app.active"
+ :loading="app.loading"
+ @update:checked="toggleSelect(app.id)" />
+ </template>
+ </div>
+
+ <div class="dialog-row">
+ <NcButton v-if="showInstallButton && !installingApps"
+ data-cy-setup-recommended-apps-skip
+ :href="defaultPageUrl"
+ variant="tertiary">
+ {{ t('core', 'Skip') }}
+ </NcButton>
+
+ <NcButton v-if="showInstallButton"
+ data-cy-setup-recommended-apps-install
+ :disabled="installingApps || !isAnyAppSelected"
+ variant="primary"
+ @click.stop.prevent="installApps">
+ {{ installingApps ? t('core', 'Installing apps …') : t('core', 'Install recommended apps') }}
+ </NcButton>
</div>
- <p class="text-center">
- <a :href="defaultPageUrl">{{ t('core', 'Cancel') }}</a>
- </p>
</div>
</template>
<script>
-import axios from '@nextcloud/axios'
-import { generateUrl, imagePath } from '@nextcloud/router'
+import { t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
+import { generateUrl, imagePath } from '@nextcloud/router'
+import axios from '@nextcloud/axios'
import pLimit from 'p-limit'
-import { translate as t } from '@nextcloud/l10n'
+import logger from '../../logger.js'
-import logger from '../../logger'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
const recommended = {
calendar: {
@@ -80,69 +80,85 @@ const recommended = {
icon: imagePath('core', 'actions/mail.svg'),
},
spreed: {
- description: t('core', 'Chatting, video calls, screensharing, online meetings and web conferencing – in your browser and with mobile apps.'),
+ description: t('core', 'Chatting, video calls, screen sharing, online meetings and web conferencing – in your browser and with mobile apps.'),
+ icon: imagePath('core', 'apps/spreed.svg'),
},
richdocuments: {
- description: t('core', 'Collaboratively edit office documents.'),
+ name: 'Nextcloud Office',
+ description: t('core', 'Collaborative documents, spreadsheets and presentations, built on Collabora Online.'),
+ icon: imagePath('core', 'apps/richdocuments.svg'),
+ },
+ notes: {
+ description: t('core', 'Distraction free note taking app.'),
+ icon: imagePath('core', 'apps/notes.svg'),
},
richdocumentscode: {
- description: t('core', 'Local document editing back-end used by the Collabora Online app.'),
+ hidden: true,
},
}
const recommendedIds = Object.keys(recommended)
-const defaultPageUrl = loadState('core', 'defaultPageUrl')
export default {
name: 'RecommendedApps',
+ components: {
+ NcCheckboxRadioSwitch,
+ NcButton,
+ },
data() {
return {
+ showInstallButton: false,
+ installingApps: false,
loadingApps: true,
loadingAppsError: false,
apps: [],
- defaultPageUrl,
+ defaultPageUrl: loadState('core', 'defaultPageUrl'),
}
},
computed: {
recommendedApps() {
return this.apps.filter(app => recommendedIds.includes(app.id))
},
+ isAnyAppSelected() {
+ return this.recommendedApps.some(app => app.isSelected)
+ },
},
- mounted() {
- return axios.get(generateUrl('settings/apps/list'))
- .then(resp => resp.data)
- .then(data => {
- logger.info(`${data.apps.length} apps fetched`)
-
- this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false }))
- logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps })
-
- this.installApps()
- })
- .catch(error => {
- logger.error('could not fetch app list', { error })
-
- this.loadingAppsError = true
- })
- .then(() => {
- this.loadingApps = false
- })
+ async mounted() {
+ try {
+ const { data } = await axios.get(generateUrl('settings/apps/list'))
+ logger.info(`${data.apps.length} apps fetched`)
+
+ this.apps = data.apps.map(app => Object.assign(app, { loading: false, installationError: false, isSelected: app.isCompatible }))
+ logger.debug(`${this.recommendedApps.length} recommended apps found`, { apps: this.recommendedApps })
+
+ this.showInstallButton = true
+ } catch (error) {
+ logger.error('could not fetch app list', { error })
+
+ this.loadingAppsError = true
+ } finally {
+ this.loadingApps = false
+ }
},
methods: {
installApps() {
+ this.installingApps = true
+
const limit = pLimit(1)
const installing = this.recommendedApps
- .filter(app => !app.active && app.isCompatible && app.canInstall)
- .map(app => limit(() => {
+ .filter(app => !app.active && app.isCompatible && app.canInstall && app.isSelected)
+ .map(app => limit(async () => {
logger.info(`installing ${app.id}`)
app.loading = true
return axios.post(generateUrl('settings/apps/enable'), { appIds: [app.id], groups: [] })
.catch(error => {
logger.error(`could not install ${app.id}`, { error })
+ app.isSelected = false
app.installationError = true
})
.then(() => {
logger.info(`installed ${app.id}`)
app.loading = false
+ app.active = true
})
}))
logger.debug(`installing ${installing.length} recommended apps`)
@@ -150,7 +166,7 @@ export default {
.then(() => {
logger.info('all recommended apps installed, redirecting …')
- window.location = defaultPageUrl
+ window.location = this.defaultPageUrl
})
.catch(error => logger.error('could not install recommended apps', { error }))
},
@@ -161,6 +177,12 @@ export default {
}
return recommended[appId].icon
},
+ customName(app) {
+ if (!(app.id in recommended)) {
+ return app.name
+ }
+ return recommended[app.id].name || app.name
+ },
customDescription(appId) {
if (!(appId in recommended)) {
logger.warn(`no app description for recommended app ${appId}`)
@@ -168,17 +190,40 @@ export default {
}
return recommended[appId].description
},
+ isHidden(appId) {
+ if (!(appId in recommended)) {
+ return false
+ }
+ return !!recommended[appId].hidden
+ },
+ toggleSelect(appId) {
+ // disable toggle when installButton is disabled
+ if (!(appId in recommended) || !this.showInstallButton) {
+ return
+ }
+ const index = this.apps.findIndex(app => app.id === appId)
+ this.$set(this.apps[index], 'isSelected', !this.apps[index].isSelected)
+ },
},
}
</script>
<style lang="scss" scoped>
-.body-login-container {
-
+.dialog-row {
+ display: flex;
+ justify-content: end;
+ margin-top: 8px;
}
-p.loading, p.loading-error {
- height: 100px;
+p {
+ &.loading,
+ &.loading-error {
+ height: 100px;
+ }
+
+ &:last-child {
+ margin-top: 10px;
+ }
}
.text-center {
@@ -192,7 +237,7 @@ p.loading, p.loading-error {
img {
height: 50px;
width: 50px;
- filter: invert(1);
+ filter: var(--background-invert-if-dark);
}
img, .info {
@@ -201,17 +246,17 @@ p.loading, p.loading-error {
.info {
h3, p {
- text-align: left;
+ text-align: start;
}
h3 {
- color: #fff;
margin-top: 0;
}
+ }
- h3 > span.icon {
- display: inline-block;
- }
+ .checkbox-radio-switch {
+ margin-inline-start: auto;
+ padding: 0 2px;
}
}
</style>
diff --git a/core/src/eventbus.d.ts b/core/src/eventbus.d.ts
new file mode 100644
index 00000000000..4fac9bc7841
--- /dev/null
+++ b/core/src/eventbus.d.ts
@@ -0,0 +1,14 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+declare module '@nextcloud/event-bus' {
+ export interface NextcloudEvents {
+ // mapping of 'event name' => 'event type'
+ 'nextcloud:unified-search:reset': undefined
+ 'nextcloud:unified-search:search': { query: string }
+ }
+}
+
+export {}
diff --git a/core/src/files/client.js b/core/src/files/client.js
index 4e606f4f5ac..7c69a65161b 100644
--- a/core/src/files/client.js
+++ b/core/src/files/client.js
@@ -1,14 +1,10 @@
-/* eslint-disable */
-/*
- * Copyright (c) 2015
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable */
import escapeHTML from 'escape-html'
/* global dav */
@@ -20,7 +16,7 @@ import escapeHTML from 'escape-html'
*
* @param {Object} options
* @param {String} options.host host name
- * @param {int} [options.port] port
+ * @param {number} [options.port] port
* @param {boolean} [options.useHTTPS] whether to use https
* @param {String} [options.root] root path
* @param {String} [options.userName] user name
@@ -82,6 +78,7 @@ import escapeHTML from 'escape-html'
Client.PROPERTY_GETCONTENTLENGTH = '{' + Client.NS_DAV + '}getcontentlength'
Client.PROPERTY_ISENCRYPTED = '{' + Client.NS_DAV + '}is-encrypted'
Client.PROPERTY_SHARE_PERMISSIONS = '{' + Client.NS_OCS + '}share-permissions'
+ Client.PROPERTY_SHARE_ATTRIBUTES = '{' + Client.NS_NEXTCLOUD + '}share-attributes'
Client.PROPERTY_QUOTA_AVAILABLE_BYTES = '{' + Client.NS_DAV + '}quota-available-bytes'
Client.PROTOCOL_HTTP = 'http'
@@ -138,6 +135,10 @@ import escapeHTML from 'escape-html'
* Share permissions
*/
[Client.NS_OCS, 'share-permissions'],
+ /**
+ * Share attributes
+ */
+ [Client.NS_NEXTCLOUD, 'share-attributes'],
]
/**
@@ -394,6 +395,18 @@ import escapeHTML from 'escape-html'
data.sharePermissions = parseInt(sharePermissionsProp)
}
+ const shareAttributesProp = props[Client.PROPERTY_SHARE_ATTRIBUTES]
+ if (!_.isUndefined(shareAttributesProp)) {
+ try {
+ data.shareAttributes = JSON.parse(shareAttributesProp)
+ } catch (e) {
+ console.warn('Could not parse share attributes returned by server: "' + shareAttributesProp + '"')
+ data.shareAttributes = [];
+ }
+ } else {
+ data.shareAttributes = [];
+ }
+
const mounTypeProp = props['{' + Client.NS_NEXTCLOUD + '}mount-type']
if (!_.isUndefined(mounTypeProp)) {
data.mountType = mounTypeProp
@@ -427,7 +440,7 @@ import escapeHTML from 'escape-html'
/**
* Returns whether the given status code means success
*
- * @param {int} status status code
+ * @param {number} status status code
*
* @returns true if status code is between 200 and 299 included
*/
@@ -524,7 +537,7 @@ import escapeHTML from 'escape-html'
*
* @param {Object} filter filter criteria
* @param {Object} [filter.systemTagIds] list of system tag ids to filter by
- * @param {bool} [filter.favorite] set it to filter by favorites
+ * @param {boolean} [filter.favorite] set it to filter by favorites
* @param {Object} [options] options
* @param {Array} [options.properties] list of Webdav properties to retrieve
*
@@ -676,7 +689,7 @@ import escapeHTML from 'escape-html'
* @param {String} body file body
* @param {Object} [options]
* @param {String} [options.contentType='text/plain'] content type
- * @param {bool} [options.overwrite=true] whether to overwrite an existing file
+ * @param {boolean} [options.overwrite=true] whether to overwrite an existing file
*
* @returns {Promise}
*/
@@ -719,7 +732,7 @@ import escapeHTML from 'escape-html'
return promise
},
- _simpleCall: function(method, path) {
+ _simpleCall: function(method, path, headers) {
if (!path) {
throw 'Missing argument "path"'
}
@@ -730,7 +743,8 @@ import escapeHTML from 'escape-html'
this._client.request(
method,
- this._buildUrl(path)
+ this._buildUrl(path),
+ headers ? headers : {}
).then(
function(result) {
if (self._isSuccessStatus(result.status)) {
@@ -751,8 +765,8 @@ import escapeHTML from 'escape-html'
*
* @returns {Promise}
*/
- createDirectory: function(path) {
- return this._simpleCall('MKCOL', path)
+ createDirectory: function(path, headers) {
+ return this._simpleCall('MKCOL', path, headers)
},
/**
diff --git a/core/src/files/fileinfo.js b/core/src/files/fileinfo.js
index 19baad5a2d7..7ebe06a8349 100644
--- a/core/src/files/fileinfo.js
+++ b/core/src/files/fileinfo.js
@@ -1,14 +1,10 @@
-/* eslint-disable */
-/*
- * Copyright (c) 2015
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable */
(function(OC) {
/**
@@ -140,7 +136,23 @@
*/
sharePermissions: null,
+ /**
+ * @type Array
+ */
+ shareAttributes: [],
+
quotaAvailableBytes: -1,
+
+ canDownload: function() {
+ for (const i in this.shareAttributes) {
+ const attr = this.shareAttributes[i]
+ if (attr.scope === 'permissions' && attr.key === 'download') {
+ return attr.value === true
+ }
+ }
+
+ return true
+ },
}
if (!OC.Files) {
diff --git a/core/src/files/iedavclient.js b/core/src/files/iedavclient.js
deleted file mode 100644
index 447d05c0497..00000000000
--- a/core/src/files/iedavclient.js
+++ /dev/null
@@ -1,162 +0,0 @@
-/* eslint-disable */
-/*
- * Copyright (c) 2015
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
- */
-
-/* global dav */
-(function(dav) {
-
- /**
- * Override davclient.js methods with IE-compatible logic
- */
- dav.Client.prototype = _.extend({}, dav.Client.prototype, {
-
- /**
- * Performs a HTTP request, and returns a Promise
- *
- * @param {string} method HTTP method
- * @param {string} url Relative or absolute url
- * @param {Object} headers HTTP headers as an object.
- * @param {string} body HTTP request body.
- * @returns {Promise}
- */
- request: function(method, url, headers, body) {
-
- const self = this
- const xhr = this.xhrProvider()
- headers = headers || {}
-
- if (this.userName) {
- headers.Authorization = 'Basic ' + btoa(this.userName + ':' + this.password)
- // xhr.open(method, this.resolveUrl(url), true, this.userName, this.password);
- }
- xhr.open(method, this.resolveUrl(url), true)
- let ii
- for (ii in headers) {
- xhr.setRequestHeader(ii, headers[ii])
- }
-
- if (body === undefined) {
- xhr.send()
- } else {
- xhr.send(body)
- }
-
- return new Promise(function(fulfill, reject) {
-
- xhr.onreadystatechange = function() {
-
- if (xhr.readyState !== 4) {
- return
- }
-
- let resultBody = xhr.response
- if (xhr.status === 207) {
- resultBody = self.parseMultiStatus(xhr.responseXML)
- }
-
- fulfill({
- body: resultBody,
- status: xhr.status,
- xhr: xhr,
- })
-
- }
-
- xhr.ontimeout = function() {
-
- reject(new Error('Timeout exceeded'))
-
- }
-
- })
-
- },
-
- _getElementsByTagName: function(node, name, resolver) {
- const parts = name.split(':')
- const tagName = parts[1]
- const namespace = resolver(parts[0])
- // make sure we can get elements
- if (typeof node === 'string') {
- const parser = new DOMParser()
- node = parser.parseFromString(node, 'text/xml')
- }
- if (node.getElementsByTagNameNS) {
- return node.getElementsByTagNameNS(namespace, tagName)
- }
- return node.getElementsByTagName(name)
- },
-
- /**
- * Parses a multi-status response body.
- *
- * @param {string} xmlBody
- * @param {Array}
- */
- parseMultiStatus: function(doc) {
- const result = []
- const resolver = function(foo) {
- let ii
- for (ii in this.xmlNamespaces) {
- if (this.xmlNamespaces[ii] === foo) {
- return ii
- }
- }
- }.bind(this)
-
- const responses = this._getElementsByTagName(doc, 'd:response', resolver)
- let i
- for (i = 0; i < responses.length; i++) {
- const responseNode = responses[i]
- const response = {
- href: null,
- propStat: [],
- }
-
- const hrefNode = this._getElementsByTagName(responseNode, 'd:href', resolver)[0]
-
- response.href = hrefNode.textContent || hrefNode.text
-
- const propStatNodes = this._getElementsByTagName(responseNode, 'd:propstat', resolver)
- let j = 0
-
- for (j = 0; j < propStatNodes.length; j++) {
- const propStatNode = propStatNodes[j]
- const statusNode = this._getElementsByTagName(propStatNode, 'd:status', resolver)[0]
-
- const propStat = {
- status: statusNode.textContent || statusNode.text,
- properties: [],
- }
-
- const propNode = this._getElementsByTagName(propStatNode, 'd:prop', resolver)[0]
- if (!propNode) {
- continue
- }
- let k = 0
- for (k = 0; k < propNode.childNodes.length; k++) {
- const prop = propNode.childNodes[k]
- const value = this._parsePropNode(prop)
- propStat.properties['{' + prop.namespaceURI + '}' + (prop.localName || prop.baseName)] = value
-
- }
- response.propStat.push(propStat)
- }
-
- result.push(response)
- }
-
- return result
-
- },
-
- })
-
-})(dav)
diff --git a/core/src/globals.js b/core/src/globals.js
index e0d2c4b8ead..4b07cc17c3e 100644
--- a/core/src/globals.js
+++ b/core/src/globals.js
@@ -1,62 +1,39 @@
-/* eslint-disable @nextcloud/no-deprecations */
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { initCore } from './init'
+/* eslint-disable @nextcloud/no-deprecations */
+import { initCore } from './init.js'
import _ from 'underscore'
import $ from 'jquery'
-import 'jquery-migrate/dist/jquery-migrate.min'
// TODO: switch to `jquery-ui` package and import widgets and effects individually
// `jquery-ui-dist` is used as a workaround for the issue of missing effects
-import 'jquery-ui-dist/jquery-ui'
+import 'jquery-ui-dist/jquery-ui.js'
import 'jquery-ui-dist/jquery-ui.css'
import 'jquery-ui-dist/jquery-ui.theme.css'
// END TODO
-import autosize from 'autosize'
import Backbone from 'backbone'
-import 'bootstrap/js/dist/tooltip'
-import './Polyfill/tooltip'
import ClipboardJS from 'clipboard'
import { dav } from 'davclient.js'
import Handlebars from 'handlebars'
-import 'jcrop/js/jquery.Jcrop'
-import 'jcrop/css/jquery.Jcrop.css'
-import jstimezonedetect from 'jstimezonedetect'
import md5 from 'blueimp-md5'
import moment from 'moment'
import 'select2'
import 'select2/select2.css'
-import 'snap.js/dist/snap'
+import 'snap.js/dist/snap.js'
import 'strengthify'
import 'strengthify/strengthify.css'
-import OC from './OC/index'
-import OCP from './OCP/index'
-import OCA from './OCA/index'
-import { getToken as getRequestToken } from './OC/requesttoken'
+import OC from './OC/index.js'
+import OCP from './OCP/index.js'
+import OCA from './OCA/index.js'
+import { getRequestToken } from './OC/requesttoken.ts'
const warnIfNotTesting = function() {
if (window.TESTING === undefined) {
- console.warn.apply(console, arguments)
+ OC.debug && console.warn.apply(console, arguments)
}
}
@@ -65,9 +42,9 @@ const warnIfNotTesting = function() {
* warn if used!
*
* @param {Function} func the library to deprecate
- * @param {String} funcName the name of the library
- * @param {Int} version the version this gets removed
- * @returns {function}
+ * @param {string} funcName the name of the library
+ * @param {number} version the version this gets removed
+ * @return {Function}
*/
const deprecate = (func, funcName, version) => {
const oldFunc = func
@@ -80,7 +57,7 @@ const deprecate = (func, funcName, version) => {
}
const setDeprecatedProp = (global, cb, msg) => {
- (Array.isArray(global) ? global : [global]).map(global => {
+ (Array.isArray(global) ? global : [global]).forEach(global => {
if (window[global] !== undefined) {
delete window[global]
}
@@ -99,13 +76,12 @@ const setDeprecatedProp = (global, cb, msg) => {
}
window._ = _
-setDeprecatedProp(['$', 'jQuery'], () => $, 'The global jQuery is deprecated. It will be updated to v3.x in Nextcloud 21. In later versions of Nextcloud it might be removed completely. Please ship your own.')
-setDeprecatedProp('autosize', () => autosize, 'please ship your own, this will be removed in Nextcloud 20')
+setDeprecatedProp(['$', 'jQuery'], () => $, 'The global jQuery is deprecated. It will be removed in a later versions without another warning. Please ship your own.')
setDeprecatedProp('Backbone', () => Backbone, 'please ship your own, this will be removed in Nextcloud 20')
setDeprecatedProp(['Clipboard', 'ClipboardJS'], () => ClipboardJS, 'please ship your own, this will be removed in Nextcloud 20')
window.dav = dav
setDeprecatedProp('Handlebars', () => Handlebars, 'please ship your own, this will be removed in Nextcloud 20')
-setDeprecatedProp(['jstz', 'jstimezonedetect'], () => jstimezonedetect, 'please ship your own, this will be removed in Nextcloud 20')
+// Global md5 only required for: apps/files/js/file-upload.js
setDeprecatedProp('md5', () => md5, 'please ship your own, this will be removed in Nextcloud 20')
setDeprecatedProp('moment', () => moment, 'please ship your own, this will be removed in Nextcloud 20')
@@ -126,6 +102,7 @@ $.fn.select2 = deprecate($.fn.select2, 'select2', 19)
/**
* translate a string
+ *
* @param {string} app the id of the app for which to translate the string
* @param {string} text the string to translate
* @param [vars] map of placeholder key to value
@@ -136,6 +113,7 @@ window.t = _.bind(OC.L10N.translate, OC.L10N)
/**
* translate a string
+ *
* @param {string} app the id of the app for which to translate the string
* @param {string} text_singular the string to translate for exactly one object
* @param {string} text_plural the string to translate for n objects
diff --git a/core/src/icons.js b/core/src/icons.js
new file mode 100644
index 00000000000..5845b01fea1
--- /dev/null
+++ b/core/src/icons.js
@@ -0,0 +1,357 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+/* eslint-disable quote-props */
+/* eslint-disable n/no-unpublished-import */
+import path from 'path'
+import fs from 'fs'
+import sass from 'sass'
+
+const colors = {
+ dark: '000',
+ white: 'fff',
+ // gold but for backwards compatibility called yellow
+ yellow: 'a08b00',
+ red: 'e9322d',
+ orange: 'eca700',
+ green: '46ba61',
+ grey: '969696',
+}
+
+const variables = {}
+const icons = {
+ 'add': path.join(__dirname, '../img', 'actions', 'add.svg'),
+ 'address': path.join(__dirname, '../img', 'actions', 'address.svg'),
+ 'alert-outline': path.join(__dirname, '../img', 'actions', 'alert-outline.svg'),
+ 'audio-off': path.join(__dirname, '../img', 'actions', 'audio-off.svg'),
+ 'audio': path.join(__dirname, '../img', 'actions', 'audio.svg'),
+ 'calendar': path.join(__dirname, '../img', 'places', 'calendar.svg'),
+ 'caret': path.join(__dirname, '../img', 'actions', 'caret.svg'),
+ 'category-app-bundles': path.join(__dirname, '../img', 'categories', 'bundles.svg'),
+ 'category-auth': path.join(__dirname, '../img', 'categories', 'auth.svg'),
+ 'category-customization': path.join(__dirname, '../img', 'categories', 'customization.svg'),
+ 'category-dashboard': path.join(__dirname, '../img', 'categories', 'dashboard.svg'),
+ 'category-files': path.join(__dirname, '../img', 'categories', 'files.svg'),
+ 'category-games': path.join(__dirname, '../img', 'categories', 'games.svg'),
+ 'category-integration': path.join(__dirname, '../img', 'categories', 'integration.svg'),
+ 'category-monitoring': path.join(__dirname, '../img', 'categories', 'monitoring.svg'),
+ 'category-multimedia': path.join(__dirname, '../img', 'categories', 'multimedia.svg'),
+ 'category-office': path.join(__dirname, '../img', 'categories', 'office.svg'),
+ 'category-organization': path.join(__dirname, '../img', 'categories', 'organization.svg'),
+ 'category-social': path.join(__dirname, '../img', 'categories', 'social.svg'),
+ 'category-workflow': path.join(__dirname, '../img', 'categories', 'workflow.svg'),
+ 'change': path.join(__dirname, '../img', 'actions', 'change.svg'),
+ 'checkmark': path.join(__dirname, '../img', 'actions', 'checkmark.svg'),
+ 'circles': path.join(__dirname, '../img', 'apps', 'circles.svg'),
+ 'clippy': path.join(__dirname, '../img', 'actions', 'clippy.svg'),
+ 'close': path.join(__dirname, '../img', 'actions', 'close.svg'),
+ 'comment': path.join(__dirname, '../img', 'actions', 'comment.svg'),
+ 'confirm-fade': path.join(__dirname, '../img', 'actions', 'confirm-fade.svg'),
+ 'confirm': path.join(__dirname, '../img', 'actions', 'confirm.svg'),
+ 'contacts': path.join(__dirname, '../img', 'places', 'contacts.svg'),
+ 'delete': path.join(__dirname, '../img', 'actions', 'delete.svg'),
+ 'desktop': path.join(__dirname, '../img', 'clients', 'desktop.svg'),
+ 'details': path.join(__dirname, '../img', 'actions', 'details.svg'),
+ 'disabled-user': path.join(__dirname, '../img', 'actions', 'disabled-user.svg'),
+ 'disabled-users': path.join(__dirname, '../img', 'actions', 'disabled-users.svg'),
+ 'download': path.join(__dirname, '../img', 'actions', 'download.svg'),
+ 'edit': path.join(__dirname, '../img', 'actions', 'edit.svg'),
+ 'encryption': path.join(__dirname, '../../', 'apps/files_external/img', 'app.svg'),
+ 'error': path.join(__dirname, '../img', 'actions', 'error.svg'),
+ 'external': path.join(__dirname, '../img', 'actions', 'external.svg'),
+ 'favorite': path.join(__dirname, '../img', 'actions', 'star-dark.svg'),
+ 'files': path.join(__dirname, '../img', 'places', 'files.svg'),
+ 'filter': path.join(__dirname, '../img', 'actions', 'filter.svg'),
+ 'folder': path.join(__dirname, '../img', 'filetypes', 'folder.svg'),
+ 'fullscreen': path.join(__dirname, '../img', 'actions', 'fullscreen.svg'),
+ 'group': path.join(__dirname, '../img', 'actions', 'group.svg'),
+ 'history': path.join(__dirname, '../img', 'actions', 'history.svg'),
+ 'home': path.join(__dirname, '../img', 'places', 'home.svg'),
+ 'info': path.join(__dirname, '../img', 'actions', 'info.svg'),
+ 'link': path.join(__dirname, '../img', 'places', 'link.svg'),
+ 'logout': path.join(__dirname, '../img', 'actions', 'logout.svg'),
+ 'mail': path.join(__dirname, '../img', 'actions', 'mail.svg'),
+ 'menu-sidebar': path.join(__dirname, '../img', 'actions', 'menu-sidebar.svg'),
+ 'menu': path.join(__dirname, '../img', 'actions', 'menu.svg'),
+ 'more': path.join(__dirname, '../img', 'actions', 'more.svg'),
+ 'music': path.join(__dirname, '../img', 'places', 'music.svg'),
+ 'password': path.join(__dirname, '../img', 'actions', 'password.svg'),
+ 'pause': path.join(__dirname, '../img', 'actions', 'pause.svg'),
+ 'phone': path.join(__dirname, '../img', 'clients', 'phone.svg'),
+ 'picture': path.join(__dirname, '../img', 'places', 'picture.svg'),
+ 'play-add': path.join(__dirname, '../img', 'actions', 'play-add.svg'),
+ 'play-next': path.join(__dirname, '../img', 'actions', 'play-next.svg'),
+ 'play-previous': path.join(__dirname, '../img', 'actions', 'play-previous.svg'),
+ 'play': path.join(__dirname, '../img', 'actions', 'play.svg'),
+ 'projects': path.join(__dirname, '../img', 'actions', 'projects.svg'),
+ 'public': path.join(__dirname, '../img', 'actions', 'public.svg'),
+ 'quota': path.join(__dirname, '../img', 'actions', 'quota.svg'),
+ 'recent': path.join(__dirname, '../img', 'actions', 'recent.svg'),
+ 'rename': path.join(__dirname, '../img', 'actions', 'rename.svg'),
+ 'screen-off': path.join(__dirname, '../img', 'actions', 'screen-off.svg'),
+ 'screen': path.join(__dirname, '../img', 'actions', 'screen.svg'),
+ 'search': path.join(__dirname, '../img', 'actions', 'search.svg'),
+ 'settings': path.join(__dirname, '../img', 'actions', 'settings-dark.svg'),
+ 'share': path.join(__dirname, '../img', 'actions', 'share.svg'),
+ 'shared': path.join(__dirname, '../img', 'actions', 'share.svg'),
+ 'sound-off': path.join(__dirname, '../img', 'actions', 'sound-off.svg'),
+ 'sound': path.join(__dirname, '../img', 'actions', 'sound.svg'),
+ 'star': path.join(__dirname, '../img', 'actions', 'star.svg'),
+ 'starred': path.join(__dirname, '../img', 'actions', 'star-dark.svg'),
+ 'star-rounded': path.join(__dirname, '../img', 'actions', 'star-rounded.svg'),
+ 'tablet': path.join(__dirname, '../img', 'clients', 'tablet.svg'),
+ 'tag': path.join(__dirname, '../img', 'actions', 'tag.svg'),
+ 'talk': path.join(__dirname, '../img', 'apps', 'spreed.svg'),
+ 'teams': path.join(__dirname, '../img', 'apps', 'circles.svg'),
+ 'template-add': path.join(__dirname, '../img', 'actions', 'template-add.svg'),
+ 'timezone': path.join(__dirname, '../img', 'actions', 'timezone.svg'),
+ 'toggle-background': path.join(__dirname, '../img', 'actions', 'toggle-background.svg'),
+ 'toggle-filelist': path.join(__dirname, '../img', 'actions', 'toggle-filelist.svg'),
+ 'toggle-pictures': path.join(__dirname, '../img', 'actions', 'toggle-pictures.svg'),
+ 'toggle': path.join(__dirname, '../img', 'actions', 'toggle.svg'),
+ 'triangle-e': path.join(__dirname, '../img', 'actions', 'triangle-e.svg'),
+ 'triangle-n': path.join(__dirname, '../img', 'actions', 'triangle-n.svg'),
+ 'triangle-s': path.join(__dirname, '../img', 'actions', 'triangle-s.svg'),
+ 'unshare': path.join(__dirname, '../img', 'actions', 'unshare.svg'),
+ 'upload': path.join(__dirname, '../img', 'actions', 'upload.svg'),
+ 'user-admin': path.join(__dirname, '../img', 'actions', 'user-admin.svg'),
+ 'user': path.join(__dirname, '../img', 'actions', 'user.svg'),
+ 'video-off': path.join(__dirname, '../img', 'actions', 'video-off.svg'),
+ 'video-switch': path.join(__dirname, '../img', 'actions', 'video-switch.svg'),
+ 'video': path.join(__dirname, '../img', 'actions', 'video.svg'),
+ 'view-close': path.join(__dirname, '../img', 'actions', 'view-close.svg'),
+ 'view-download': path.join(__dirname, '../img', 'actions', 'view-download.svg'),
+ 'view-next': path.join(__dirname, '../img', 'actions', 'arrow-right.svg'),
+ 'view-pause': path.join(__dirname, '../img', 'actions', 'view-pause.svg'),
+ 'view-play': path.join(__dirname, '../img', 'actions', 'view-play.svg'),
+ 'view-previous': path.join(__dirname, '../img', 'actions', 'arrow-left.svg'),
+}
+
+const iconsColor = {
+ 'add-folder-description': {
+ path: path.join(__dirname, '../img', 'actions', 'add-folder-description.svg'),
+ color: 'grey',
+ },
+ 'settings': {
+ path: path.join(__dirname, '../img', 'actions', 'settings.svg'),
+ color: 'black',
+ },
+ 'error-color': {
+ path: path.join(__dirname, '../img', 'actions', 'error.svg'),
+ color: 'red',
+ },
+ 'checkmark-color': {
+ path: path.join(__dirname, '../img', 'actions', 'checkmark.svg'),
+ color: 'green',
+ },
+ 'starred': {
+ path: path.join(__dirname, '../img', 'actions', 'star-dark.svg'),
+ color: 'yellow',
+ },
+ 'star': {
+ path: path.join(__dirname, '../img', 'actions', 'star-dark.svg'),
+ color: 'grey',
+ },
+ 'delete-color': {
+ path: path.join(__dirname, '../img', 'actions', 'delete.svg'),
+ color: 'red',
+ },
+ 'file': {
+ path: path.join(__dirname, '../img', 'filetypes', 'text.svg'),
+ color: 'grey',
+ },
+ 'filetype-file': {
+ path: path.join(__dirname, '../img', 'filetypes', 'file.svg'),
+ color: 'grey',
+ },
+ 'filetype-folder': {
+ path: path.join(__dirname, '../img', 'filetypes', 'folder.svg'),
+ // TODO: replace primary ?
+ color: 'primary',
+ },
+ 'filetype-folder-drag-accept': {
+ path: path.join(__dirname, '../img', 'filetypes', 'folder-drag-accept.svg'),
+ // TODO: replace primary ?
+ color: 'primary',
+ },
+ 'filetype-text': {
+ path: path.join(__dirname, '../img', 'filetypes', 'text.svg'),
+ color: 'grey',
+ },
+ 'file-text': {
+ path: path.join(__dirname, '../img', 'filetypes', 'text.svg'),
+ color: 'black',
+ },
+}
+
+// use this to define aliases to existing icons
+// key is the css selector, value is the variable
+const iconsAliases = {
+ 'icon-caret': 'icon-caret-white',
+ // starring action
+ 'icon-star:hover': 'icon-starred',
+ 'icon-star:focus': 'icon-starred',
+ // Un-starring action
+ 'icon-starred:hover': 'icon-star-grey',
+ 'icon-starred:focus': 'icon-star-grey',
+ // Delete normal
+ 'icon-delete.no-permission:hover': 'icon-delete-dark',
+ 'icon-delete.no-permission:focus': 'icon-delete-dark',
+ 'icon-delete.no-hover:hover': 'icon-delete-dark',
+ 'icon-delete.no-hover:focus': 'icon-delete-dark',
+ 'icon-delete:hover': 'icon-delete-color-red',
+ 'icon-delete:focus': 'icon-delete-color-red',
+ // Delete white
+ 'icon-delete-white.no-permission:hover': 'icon-delete-white',
+ 'icon-delete-white.no-permission:focus': 'icon-delete-white',
+ 'icon-delete-white.no-hover:hover': 'icon-delete-white',
+ 'icon-delete-white.no-hover:focus': 'icon-delete-white',
+ 'icon-delete-white:hover': 'icon-delete-color-red',
+ 'icon-delete-white:focus': 'icon-delete-color-red',
+ // Default to white
+ 'icon-view-close': 'icon-view-close-white',
+ 'icon-view-download': 'icon-view-download-white',
+ 'icon-view-pause': 'icon-view-pause-white',
+ 'icon-view-play': 'icon-view-play-white',
+ // Default app place to white
+ 'icon-calendar': 'icon-calendar-white',
+ 'icon-contacts': 'icon-contacts-white',
+ 'icon-files': 'icon-files-white',
+ // Re-using existing icons
+ 'icon-category-installed': 'icon-user-dark',
+ 'icon-category-enabled': 'icon-checkmark-dark',
+ 'icon-category-disabled': 'icon-close-dark',
+ 'icon-category-updates': 'icon-download-dark',
+ 'icon-category-security': 'icon-password-dark',
+ 'icon-category-search': 'icon-search-dark',
+ 'icon-category-tools': 'icon-settings-dark',
+ 'nav-icon-systemtagsfilter': 'icon-tag-dark',
+}
+
+const colorSvg = function(svg = '', color = '000') {
+ if (!color.match(/^[0-9a-f]{3,6}$/i)) {
+ // Prevent not-sane colors from being written into the SVG
+ console.warn(color, 'does not match the required format')
+ color = '000'
+ }
+
+ // add fill (fill is not present on black elements)
+ const fillRe = /<((circle|rect|path)((?!fill=)[a-z0-9 =".\-#():;,])+)\/>/gmi
+ svg = svg.replace(fillRe, '<$1 fill="#' + color + '"/>')
+
+ // replace any fill or stroke colors
+ svg = svg.replace(/stroke="#([a-z0-9]{3,6})"/gmi, 'stroke="#' + color + '"')
+ svg = svg.replace(/fill="#([a-z0-9]{3,6})"/gmi, 'fill="#' + color + '"')
+
+ return svg
+}
+
+const generateVariablesAliases = function(invert = false) {
+ let css = ''
+ Object.keys(variables).forEach(variable => {
+ if (variable.indexOf('original-') !== -1) {
+ let finalVariable = variable.replace('original-', '')
+ if (invert) {
+ finalVariable = finalVariable.replace('white', 'tempwhite')
+ .replace('dark', 'white')
+ .replace('tempwhite', 'dark')
+ }
+ css += `${finalVariable}: var(${variable});`
+ }
+ })
+ return css
+}
+
+const formatIcon = function(icon, invert = false) {
+ const color1 = invert ? 'white' : 'dark'
+ const color2 = invert ? 'dark' : 'white'
+ return `
+ .icon-${icon},
+ .icon-${icon}-dark {
+ background-image: var(--icon-${icon}-${color1});
+ }
+ .icon-${icon}-white,
+ .icon-${icon}.icon-white {
+ background-image: var(--icon-${icon}-${color2});
+ }`
+}
+const formatIconColor = function(icon) {
+ const { color } = iconsColor[icon]
+ return `
+ .icon-${icon} {
+ background-image: var(--icon-${icon}-${color});
+ }`
+}
+const formatAlias = function(alias, invert = false) {
+ let icon = iconsAliases[alias]
+ if (invert) {
+ icon = icon.replace('white', 'tempwhite')
+ .replace('dark', 'white')
+ .replace('tempwhite', 'dark')
+ }
+ return `
+ .${alias} {
+ background-image: var(--${icon})
+ }`
+}
+
+let css = ''
+Object.keys(icons).forEach(icon => {
+ const path = icons[icon]
+
+ const svg = fs.readFileSync(path, 'utf8')
+ const darkSvg = colorSvg(svg, '000000')
+ const whiteSvg = colorSvg(svg, 'ffffff')
+
+ variables[`--original-icon-${icon}-dark`] = Buffer.from(darkSvg, 'utf-8').toString('base64')
+ variables[`--original-icon-${icon}-white`] = Buffer.from(whiteSvg, 'utf-8').toString('base64')
+})
+
+Object.keys(iconsColor).forEach(icon => {
+ const { path, color } = iconsColor[icon]
+
+ const svg = fs.readFileSync(path, 'utf8')
+ const coloredSvg = colorSvg(svg, colors[color])
+ variables[`--icon-${icon}-${color}`] = Buffer.from(coloredSvg, 'utf-8').toString('base64')
+})
+
+// ICONS VARIABLES LIST
+css += ':root {'
+Object.keys(variables).forEach(variable => {
+ const data = variables[variable]
+ css += `${variable}: url(data:image/svg+xml;base64,${data});`
+})
+css += '}'
+
+// DEFAULT THEME
+css += 'body {'
+css += generateVariablesAliases()
+Object.keys(icons).forEach(icon => {
+ css += formatIcon(icon)
+})
+Object.keys(iconsColor).forEach(icon => {
+ css += formatIconColor(icon)
+})
+Object.keys(iconsAliases).forEach(alias => {
+ css += formatAlias(alias)
+})
+css += '}'
+
+// DARK THEME MEDIA QUERY
+css += '@media (prefers-color-scheme: dark) { body {'
+css += generateVariablesAliases(true)
+css += '}}'
+
+// DARK THEME
+css += '[data-themes*=light] {'
+css += generateVariablesAliases()
+css += '}'
+
+// DARK THEME
+css += '[data-themes*=dark] {'
+css += generateVariablesAliases(true)
+css += '}'
+
+// WRITE CSS
+fs.writeFileSync(path.join(__dirname, '../../dist', 'icons.css'), sass.compileString(css).css)
diff --git a/core/src/init.js b/core/src/init.js
index b6bb49346bd..1bcd8218702 100644
--- a/core/src/init.js
+++ b/core/src/init.js
@@ -1,135 +1,65 @@
-/* globals Snap */
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* globals Snap */
import _ from 'underscore'
import $ from 'jquery'
import moment from 'moment'
-import cssVars from 'css-vars-ponyfill'
-import { initSessionHeartBeat } from './session-heartbeat'
-import OC from './OC/index'
-import { setUp as setUpContactsMenu } from './components/ContactsMenu'
-import { setUp as setUpMainMenu } from './components/MainMenu'
-import { setUp as setUpUserMenu } from './components/UserMenu'
-import PasswordConfirmation from './OC/password-confirmation'
+import OC from './OC/index.js'
+import { initSessionHeartBeat } from './session-heartbeat.ts'
+import { setUp as setUpContactsMenu } from './components/ContactsMenu.js'
+import { setUp as setUpMainMenu } from './components/MainMenu.js'
+import { setUp as setUpUserMenu } from './components/UserMenu.js'
+import { interceptRequests } from './utils/xhr-request.js'
+import { initFallbackClipboardAPI } from './utils/ClipboardFallback.ts'
// keep in sync with core/css/variables.scss
const breakpointMobileWidth = 1024
-const resizeMenu = () => {
- const appList = $('#appmenu li')
- const rightHeaderWidth = $('.header-right').outerWidth()
- const headerWidth = $('header').outerWidth()
- const usePercentualAppMenuLimit = 0.67
- const minAppsDesktop = 12
- let availableWidth = headerWidth - $('#nextcloud').outerWidth() - (rightHeaderWidth > 210 ? rightHeaderWidth : 210)
- const isMobile = $(window).width() < breakpointMobileWidth
- if (!isMobile) {
- availableWidth = availableWidth * usePercentualAppMenuLimit
- }
- let appCount = Math.floor((availableWidth / $(appList).width()))
- if (isMobile && appCount > minAppsDesktop) {
- appCount = minAppsDesktop
- }
- if (!isMobile && appCount < minAppsDesktop) {
- appCount = minAppsDesktop
- }
-
- // show at least 2 apps in the popover
- if (appList.length - 1 - appCount >= 1) {
- appCount--
- }
-
- $('#more-apps a').removeClass('active')
- let lastShownApp
- for (let k = 0; k < appList.length - 1; k++) {
- const name = $(appList[k]).data('id')
- if (k < appCount) {
- $(appList[k]).removeClass('hidden')
- $('#apps li[data-id=' + name + ']').addClass('in-header')
- lastShownApp = appList[k]
- } else {
- $(appList[k]).addClass('hidden')
- $('#apps li[data-id=' + name + ']').removeClass('in-header')
- // move active app to last position if it is active
- if (appCount > 0 && $(appList[k]).children('a').hasClass('active')) {
- $(lastShownApp).addClass('hidden')
- $('#apps li[data-id=' + $(lastShownApp).data('id') + ']').removeClass('in-header')
- $(appList[k]).removeClass('hidden')
- $('#apps li[data-id=' + name + ']').addClass('in-header')
- }
- }
- }
-
- // show/hide more apps icon
- if ($('#apps li:not(.in-header)').length === 0) {
- $('#more-apps').hide()
- $('#navigation').hide()
- } else {
- $('#more-apps').show()
- }
-}
-
const initLiveTimestamps = () => {
// Update live timestamps every 30 seconds
setInterval(() => {
$('.live-relative-timestamp').each(function() {
- $(this).text(OC.Util.relativeModifiedDate(parseInt($(this).attr('data-timestamp'), 10)))
+ const timestamp = parseInt($(this).attr('data-timestamp'), 10)
+ $(this).text(moment(timestamp).fromNow())
})
}, 30 * 1000)
}
/**
+ * Moment doesn't have aliases for every locale and doesn't parse some locale IDs correctly so we need to alias them
+ */
+const localeAliases = {
+ zh: 'zh-cn',
+ zh_Hans: 'zh-cn',
+ zh_Hans_CN: 'zh-cn',
+ zh_Hans_HK: 'zh-cn',
+ zh_Hans_MO: 'zh-cn',
+ zh_Hans_SG: 'zh-cn',
+ zh_Hant: 'zh-hk',
+ zh_Hant_HK: 'zh-hk',
+ zh_Hant_MO: 'zh-mo',
+ zh_Hant_TW: 'zh-tw',
+}
+let locale = OC.getLocale()
+if (Object.prototype.hasOwnProperty.call(localeAliases, locale)) {
+ locale = localeAliases[locale]
+}
+
+/**
* Set users locale to moment.js as soon as possible
*/
-moment.locale(OC.getLocale())
+moment.locale(locale)
/**
* Initializes core
*/
export const initCore = () => {
- const userAgent = window.navigator.userAgent
- const msie = userAgent.indexOf('MSIE ')
- const trident = userAgent.indexOf('Trident/')
- const edge = userAgent.indexOf('Edge/')
-
- if (msie > 0 || trident > 0) {
- // (IE 10 or older) || IE 11
- $('html').addClass('ie')
- } else if (edge > 0) {
- // for edge
- $('html').addClass('edge')
- }
-
- // css variables fallback for IE
- if (msie > 0 || trident > 0 || edge > 0) {
- console.info('Legacy browser detected, applying css vars polyfill')
- cssVars({
- watch: true,
- // set edge < 16 as incompatible
- onlyLegacy: !(/Edge\/([0-9]{2})\./i.test(navigator.userAgent)
- && parseInt(/Edge\/([0-9]{2})\./i.exec(navigator.userAgent)[1]) < 16),
- })
- }
+ interceptRequests()
+ initFallbackClipboardAPI()
$(window).on('unload.main', () => { OC._unloadCalled = true })
$(window).on('beforeunload.main', () => {
@@ -178,30 +108,6 @@ export const initCore = () => {
setUpUserMenu()
setUpContactsMenu()
- // move triangle of apps dropdown to align with app name triangle
- // 2 is the additional offset between the triangles
- if ($('#navigation').length) {
- $('#header #nextcloud + .menutoggle').on('click', () => {
- $('#menu-css-helper').remove()
- const caretPosition = $('.header-appname + .icon-caret').offset().left - 2
- if (caretPosition > 255) {
- // if the app name is longer than the menu, just put the triangle in the middle
-
- } else {
- $('head').append('<style id="menu-css-helper">#navigation:after { left: ' + caretPosition + 'px }</style>')
- }
- })
- $('#header #appmenu .menutoggle').on('click', () => {
- $('#appmenu').toggleClass('menu-open')
- if ($('#appmenu').is(':visible')) {
- $('#menu-css-helper').remove()
- }
- })
- }
-
- $(window).resize(resizeMenu)
- setTimeout(resizeMenu, 0)
-
// just add snapper for logged in users
// and if the app doesn't handle the nav slider itself
if ($('#app-navigation').length && !$('html').hasClass('lte9')
@@ -237,6 +143,12 @@ export const initCore = () => {
// we need this because dragging stop triggers that
animating = false
})
+ snapper.on('open', () => {
+ $appNavigation.attr('aria-hidden', 'false')
+ })
+ snapper.on('close', () => {
+ $appNavigation.attr('aria-hidden', 'true')
+ })
// These are necessary because calling open or close
// on snapper during an animation makes it trigger an
@@ -290,6 +202,7 @@ export const initCore = () => {
// close sidebar when switching navigation entry
const $appNavigation = $('#app-navigation')
+ $appNavigation.attr('aria-hidden', 'true')
$appNavigation.delegate('a, :button', 'click', event => {
const $target = $(event.target)
// don't hide navigation when changing settings or adding things
@@ -341,6 +254,7 @@ export const initCore = () => {
const toggleSnapperOnSize = () => {
if ($(window).width() > breakpointMobileWidth) {
+ $appNavigation.attr('aria-hidden', 'false')
snapper.close()
snapper.disable()
@@ -364,5 +278,4 @@ export const initCore = () => {
}
initLiveTimestamps()
- PasswordConfirmation.init()
}
diff --git a/core/src/install.js b/core/src/install.js
deleted file mode 100644
index a25945e7591..00000000000
--- a/core/src/install.js
+++ /dev/null
@@ -1,142 +0,0 @@
-import $ from 'jquery'
-import { translate as t } from '@nextcloud/l10n'
-import { getToken } from './OC/requesttoken'
-import getURLParameter from './Util/get-url-parameter'
-
-import './jquery/showpassword'
-
-import 'jquery-ui/ui/widgets/button'
-import 'jquery-ui/themes/base/theme.css'
-import 'jquery-ui/themes/base/button.css'
-
-import 'strengthify'
-import 'strengthify/strengthify.css'
-
-window.addEventListener('DOMContentLoaded', function() {
- const dbtypes = {
- sqlite: !!$('#hasSQLite').val(),
- mysql: !!$('#hasMySQL').val(),
- postgresql: !!$('#hasPostgreSQL').val(),
- oracle: !!$('#hasOracle').val(),
- }
-
- $('#selectDbType').buttonset()
- // change links inside an info box back to their default appearance
- $('#selectDbType p.info a').button('destroy')
-
- if ($('#hasSQLite').val()) {
- $('#use_other_db').hide()
- $('#use_oracle_db').hide()
- } else {
- $('#sqliteInformation').hide()
- }
- $('#adminlogin').change(function() {
- $('#adminlogin').val($.trim($('#adminlogin').val()))
- })
- $('#sqlite').click(function() {
- $('#use_other_db').slideUp(250)
- $('#use_oracle_db').slideUp(250)
- $('#sqliteInformation').show()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+')
- })
-
- $('#mysql,#pgsql').click(function() {
- $('#use_other_db').slideDown(250)
- $('#use_oracle_db').slideUp(250)
- $('#sqliteInformation').hide()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-]+')
- })
-
- $('#oci').click(function() {
- $('#use_other_db').slideDown(250)
- $('#use_oracle_db').show(250)
- $('#sqliteInformation').hide()
- $('#dbname').attr('pattern', '[0-9a-zA-Z$_-.]+')
- })
-
- $('#showAdvanced').click(function(e) {
- e.preventDefault()
- $('#datadirContent').slideToggle(250)
- $('#databaseBackend').slideToggle(250)
- $('#databaseField').slideToggle(250)
- })
- $('form').submit(function() {
- // Save form parameters
- const post = $(this).serializeArray()
-
- // Show spinner while finishing setup
- $('.float-spinner').show(250)
-
- // Disable inputs
- $(':submit', this).attr('disabled', 'disabled').val($(':submit', this).data('finishing'))
- $('input', this).addClass('ui-state-disabled').attr('disabled', 'disabled')
- // only disable buttons if they are present
- if ($('#selectDbType').find('.ui-button').length > 0) {
- $('#selectDbType').buttonset('disable')
- }
- $('.strengthify-wrapper, .tipsy')
- .css('-ms-filter', '"progid:DXImageTransform.Microsoft.Alpha(Opacity=30)"')
- .css('filter', 'alpha(opacity=30)')
- .css('opacity', 0.3)
-
- // Create the form
- const form = $('<form>')
- form.attr('action', $(this).attr('action'))
- form.attr('method', 'POST')
-
- for (let i = 0; i < post.length; i++) {
- const input = $('<input type="hidden">')
- input.attr(post[i])
- form.append(input)
- }
-
- // Add redirect_url
- const redirectURL = getURLParameter('redirect_url')
- if (redirectURL) {
- const redirectURLInput = $('<input type="hidden">')
- redirectURLInput.attr({
- name: 'redirect_url',
- value: redirectURL,
- })
- form.append(redirectURLInput)
- }
-
- // Submit the form
- form.appendTo(document.body)
- form.submit()
- return false
- })
-
- // Expand latest db settings if page was reloaded on error
- const currentDbType = $('input[type="radio"]:checked').val()
-
- if (currentDbType === undefined) {
- $('input[type="radio"]').first().click()
- }
-
- if (
- currentDbType === 'sqlite'
- || (dbtypes.sqlite && currentDbType === undefined)
- ) {
- $('#datadirContent').hide(250)
- $('#databaseBackend').hide(250)
- $('#databaseField').hide(250)
- $('.float-spinner').hide(250)
- }
-
- $('#adminpass').strengthify({
- zxcvbn: OC.linkTo('core', 'vendor/zxcvbn/dist/zxcvbn.js'),
- titles: [
- t('core', 'Very weak password'),
- t('core', 'Weak password'),
- t('core', 'So-so password'),
- t('core', 'Good password'),
- t('core', 'Strong password'),
- ],
- drawTitles: true,
- nonce: btoa(getToken()),
- })
-
- $('#dbpass').showPassword().keyup()
- $('#adminpass').showPassword().keyup()
-})
diff --git a/core/src/install.ts b/core/src/install.ts
new file mode 100644
index 00000000000..4ef608ec2bd
--- /dev/null
+++ b/core/src/install.ts
@@ -0,0 +1,43 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import Vue from 'vue'
+import Setup from './views/Setup.vue'
+
+type Error = {
+ error: string
+ hint: string
+}
+
+export type DbType = 'sqlite' | 'mysql' | 'pgsql' | 'oci'
+
+export type SetupConfig = {
+ adminlogin: string
+ adminpass: string
+ directory: string
+ dbuser: string
+ dbpass: string
+ dbname: string
+ dbtablespace: string
+ dbhost: string
+ dbtype: DbType | ''
+
+ databases: Partial<Record<DbType, string>>
+
+ hasAutoconfig: boolean
+ htaccessWorking: boolean
+ serverRoot: string
+
+ errors: string[]|Error[]
+}
+
+export type SetupLinks = {
+ adminInstall: string
+ adminSourceInstall: string
+ adminDBConfiguration: string
+}
+
+const SetupVue = Vue.extend(Setup)
+new SetupVue().$mount('#content')
diff --git a/core/src/jquery/avatar.js b/core/src/jquery/avatar.js
index 8c17e65e98c..3851a26ce31 100644
--- a/core/src/jquery/avatar.js
+++ b/core/src/jquery/avatar.js
@@ -1,28 +1,12 @@
-/*
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+import { getCurrentUser } from '@nextcloud/auth'
+import { generateUrl } from '@nextcloud/router'
import $ from 'jquery'
-import OC from '../OC'
-
/**
* This plugin inserts the right avatar for the user, depending on, whether a
* custom avatar is uploaded - which it uses then - or not, and display a
@@ -106,8 +90,8 @@ $.fn.avatar = function(user, size, ie8fix, hidedefault, callback, displayname) {
let url
// If this is our own avatar we have to use the version attribute
- if (user === OC.getCurrentUser().uid) {
- url = OC.generateUrl(
+ if (user === getCurrentUser()?.uid) {
+ url = generateUrl(
'/avatar/{user}/{size}?v={version}',
{
user,
@@ -115,7 +99,7 @@ $.fn.avatar = function(user, size, ie8fix, hidedefault, callback, displayname) {
version: oc_userconfig.avatar.version,
})
} else {
- url = OC.generateUrl(
+ url = generateUrl(
'/avatar/{user}/{size}',
{
user,
diff --git a/core/src/jquery/contactsmenu.js b/core/src/jquery/contactsmenu.js
index 447f5adb62a..fba014c364e 100644
--- a/core/src/jquery/contactsmenu.js
+++ b/core/src/jquery/contactsmenu.js
@@ -1,27 +1,12 @@
-/*
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
-import OC from '../OC'
+import { generateUrl } from '@nextcloud/router'
+import { isA11yActivation } from '../Util/a11y.js'
const LIST = ''
+ '<div class="menu popovermenu menu-left hidden contactsmenu-popover">'
@@ -47,7 +32,11 @@ $.fn.contactsMenu = function(shareWith, shareType, appendTo) {
appendTo.append(LIST)
const $list = appendTo.find('div.contactsmenu-popover')
- $div.click(function() {
+ $div.on('click keydown', function(event) {
+ if (!isA11yActivation(event)) {
+ return
+ }
+
if (!$list.hasClass('hidden')) {
$list.addClass('hidden')
$list.hide()
@@ -62,7 +51,7 @@ $.fn.contactsMenu = function(shareWith, shareType, appendTo) {
}
$list.addClass('loaded')
- $.ajax(OC.generateUrl('/contactsmenu/findOne'), {
+ $.ajax(generateUrl('/contactsmenu/findOne'), {
method: 'POST',
data: {
shareType,
diff --git a/core/src/jquery/css/jquery-ui-fixes.scss b/core/src/jquery/css/jquery-ui-fixes.scss
index 42c684ad510..637f4bfe14b 100644
--- a/core/src/jquery/css/jquery-ui-fixes.scss
+++ b/core/src/jquery/css/jquery-ui-fixes.scss
@@ -1,3 +1,7 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
/* Component containers
----------------------------------*/
.ui-widget-content {
@@ -5,14 +9,17 @@
background: var(--color-main-background) none;
color: var(--color-main-text);
}
+
.ui-widget-content a {
color: var(--color-main-text);
}
+
.ui-widget-header {
border: none;
color: var(--color-main-text);
background-image: none;
}
+
.ui-widget-header a {
color: var(--color-main-text);
}
@@ -27,11 +34,13 @@
font-weight: bold;
color: #555;
}
+
.ui-state-default a,
.ui-state-default a:link,
.ui-state-default a:visited {
color: #555;
}
+
.ui-state-hover,
.ui-widget-content .ui-state-hover,
.ui-widget-header .ui-state-hover,
@@ -43,20 +52,23 @@
font-weight: bold;
color: var(--color-main-text);
}
+
.ui-state-hover a,
.ui-state-hover a:hover,
.ui-state-hover a:link,
.ui-state-hover a:visited {
color: var(--color-main-text);
}
+
.ui-state-active,
.ui-widget-content .ui-state-active,
.ui-widget-header .ui-state-active {
- border: 1px solid var(--color-primary);
+ border: 1px solid var(--color-primary-element);
background: var(--color-main-background) none;
font-weight: bold;
color: var(--color-main-text);
}
+
.ui-state-active a,
.ui-state-active a:link,
.ui-state-active a:visited {
@@ -73,11 +85,13 @@
color: var(--color-text-light);
font-weight: 600;
}
+
.ui-state-highlight a,
.ui-widget-content .ui-state-highlight a,
.ui-widget-header .ui-state-highlight a {
color: var(--color-text-lighter);
}
+
.ui-state-error,
.ui-widget-content .ui-state-error,
.ui-widget-header .ui-state-error {
@@ -85,11 +99,13 @@
background: var(--color-error) none;
color: #ffffff;
}
+
.ui-state-error a,
.ui-widget-content .ui-state-error a,
.ui-widget-header .ui-state-error a {
color: #ffffff;
}
+
.ui-state-error-text,
.ui-widget-content .ui-state-error-text,
.ui-widget-header .ui-state-error-text {
@@ -101,20 +117,25 @@
.ui-state-default .ui-icon {
background-image: url('images/ui-icons_1d2d44_256x240.png');
}
+
.ui-state-hover .ui-icon,
.ui-state-focus .ui-icon {
background-image: url('images/ui-icons_1d2d44_256x240.png');
}
+
.ui-state-active .ui-icon {
background-image: url('images/ui-icons_1d2d44_256x240.png');
}
+
.ui-state-highlight .ui-icon {
background-image: url('images/ui-icons_ffffff_256x240.png');
}
+
.ui-state-error .ui-icon,
.ui-state-error-text .ui-icon {
background-image: url('images/ui-icons_ffd27a_256x240.png');
}
+
.ui-icon.ui-icon-none {
display: none;
}
@@ -126,6 +147,7 @@
background: #666666 url('images/ui-bg_diagonals-thick_20_666666_40x40.png') 50% 50% repeat;
opacity: .5;
}
+
.ui-widget-shadow {
margin: -5px 0 0 -5px;
padding: 5px;
@@ -139,8 +161,8 @@
border: none;
.ui-tabs-nav.ui-corner-all {
- border-bottom-left-radius: 0;
- border-bottom-right-radius: 0;
+ border-end-start-radius: 0;
+ border-end-end-radius: 0;
}
.ui-tabs-nav {
@@ -185,7 +207,8 @@
.ui-menu-item a {
color: var(--color-text-lighter);
display: block;
- padding: 4px 4px 4px 14px;
+ padding: 4px;
+ padding-inline-start: 14px;
&.ui-state-focus, &.ui-state-active {
box-shadow: inset 4px 0 var(--color-primary-element);
@@ -201,8 +224,8 @@
&.ui-corner-all {
border-radius: 0;
- border-bottom-left-radius: var(--border-radius);
- border-bottom-right-radius: var(--border-radius);
+ border-end-start-radius: var(--border-radius);
+ border-end-end-radius: var(--border-radius);
}
.ui-state-hover, .ui-widget-content .ui-state-hover,
@@ -223,9 +246,14 @@
}
.ui-button.primary {
- background-color: var(--color-primary);
- color: var(--color-primary-text);
- border: 1px solid var(--color-primary-text);
+ background-color: var(--color-primary-element);
+ color: var(--color-primary-element-text);
+ border: 1px solid var(--color-primary-element-text);
+}
+
+// fix ui-buttons on hover
+.ui-button:hover {
+ font-weight:bold !important;
}
diff --git a/core/src/jquery/css/jquery.ocdialog.scss b/core/src/jquery/css/jquery.ocdialog.scss
index b7e762f4cb6..b950d98c381 100644
--- a/core/src/jquery/css/jquery.ocdialog.scss
+++ b/core/src/jquery/css/jquery.ocdialog.scss
@@ -1,28 +1,34 @@
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
.oc-dialog {
background: var(--color-main-background);
color: var(--color-text-light);
border-radius: var(--border-radius-large);
box-shadow: 0 0 30px var(--color-box-shadow);
- padding: 15px;
- z-index: 10000;
+ padding: 24px;
+ z-index: 100001;
font-size: 100%;
box-sizing: border-box;
min-width: 200px;
top: 50%;
- left: 50%;
+ inset-inline-start: 50%;
transform: translate(-50%, -50%);
max-height: calc(100% - 20px);
max-width: calc(100% - 20px);
overflow: auto;
}
+
.oc-dialog-title {
background: var(--color-main-background);
}
+
.oc-dialog-buttonrow {
position: relative;
display: flex;
background: transparent;
- right: 0;
+ inset-inline-end: 0;
bottom: 0;
padding: 0;
padding-top: 10px;
@@ -50,11 +56,14 @@
.oc-dialog-close {
position: absolute;
- top: 0;
- right: 0;
+ width: 44px !important;
+ height: 44px !important;
+ top: 4px;
+ inset-inline-end: 4px;
padding: 25px;
- background: var(--icon-close-000) no-repeat center;
+ background: var(--icon-close-dark) no-repeat center;
opacity: .5;
+ border-radius: var(--border-radius-pill);
&:hover,
&:focus,
@@ -66,10 +75,10 @@
.oc-dialog-dim {
background-color: #000;
opacity: .2;
- z-index: 9999;
+ z-index: 100001;
position: fixed;
top: 0;
- left: 0;
+ inset-inline-start: 0;
width: 100%;
height: 100%;
}
diff --git a/core/src/jquery/exists.js b/core/src/jquery/exists.js
index 3481faeb6cd..8a8efdb5a63 100644
--- a/core/src/jquery/exists.js
+++ b/core/src/jquery/exists.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -24,8 +8,9 @@ import $ from 'jquery'
/**
* check if an element exists.
* allows you to write if ($('#myid').exists()) to increase readability
- * @link http://stackoverflow.com/questions/31044/is-there-an-exists-function-for-jquery
- * @returns {boolean}
+ *
+ * @see {@link http://stackoverflow.com/questions/31044/is-there-an-exists-function-for-jquery}
+ * @return {boolean}
*/
$.fn.exists = function() {
return this.length > 0
diff --git a/core/src/jquery/filterattr.js b/core/src/jquery/filterattr.js
index 204d04b46a0..f577e55e4e0 100644
--- a/core/src/jquery/filterattr.js
+++ b/core/src/jquery/filterattr.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
@@ -26,7 +10,7 @@ import $ from 'jquery'
*
* @param {string} attrName attribute name
* @param {string} attrValue attribute value
- * @returns {Void}
+ * @return {void}
*/
$.fn.filterAttr = function(attrName, attrValue) {
return this.filter(function() {
diff --git a/core/src/jquery/index.js b/core/src/jquery/index.js
index aceee5cf87b..f285ba19449 100644
--- a/core/src/jquery/index.js
+++ b/core/src/jquery/index.js
@@ -1,37 +1,21 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
-import './avatar'
-import './contactsmenu'
-import './exists'
-import './filterattr'
-import './ocdialog'
-import './octemplate'
-import './placeholder'
-import './requesttoken'
-import './selectrange'
-import './showpassword'
-import './ui-fixes'
+import './avatar.js'
+import './contactsmenu.js'
+import './exists.js'
+import './filterattr.js'
+import './ocdialog.js'
+import './octemplate.js'
+import './placeholder.js'
+import './requesttoken.js'
+import './selectrange.js'
+import './showpassword.js'
+import './ui-fixes.js'
import './css/jquery-ui-fixes.scss'
import './css/jquery.ocdialog.scss'
diff --git a/core/src/jquery/ocdialog.js b/core/src/jquery/ocdialog.js
index e08db2530e3..a5f588ec659 100644
--- a/core/src/jquery/ocdialog.js
+++ b/core/src/jquery/ocdialog.js
@@ -1,25 +1,11 @@
-/*
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
+import { createFocusTrap } from 'focus-trap'
+import { isA11yActivation } from '../Util/a11y.js'
$.widget('oc.ocdialog', {
options: {
@@ -42,16 +28,32 @@ $.widget('oc.ocdialog', {
this.originalTitle = this.element.attr('title')
this.options.title = this.options.title || this.originalTitle
- this.$dialog = $('<div class="oc-dialog" />')
+ this.$dialog = $('<div class="oc-dialog"></div>')
.attr({
// Setting tabIndex makes the div focusable
tabIndex: -1,
role: 'dialog',
+ 'aria-modal': true,
})
.insertBefore(this.element)
this.$dialog.append(this.element.detach())
this.element.removeAttr('title').addClass('oc-dialog-content').appendTo(this.$dialog)
+ // Activate the primary button on enter if there is a single input
+ if (self.element.find('input').length === 1) {
+ const $input = self.element.find('input')
+ $input.on('keydown', function(event) {
+ if (isA11yActivation(event)) {
+ if (self.$buttonrow) {
+ const $button = self.$buttonrow.find('button.primary')
+ if ($button && !$button.prop('disabled')) {
+ $button.click()
+ }
+ }
+ }
+ })
+ }
+
this.$dialog.css({
display: 'inline-block',
position: 'fixed',
@@ -88,27 +90,15 @@ $.widget('oc.ocdialog', {
event.preventDefault()
return false
}
- // If no button is selected we trigger the primary
- if (
- self.$buttonrow
- && self.$buttonrow.find($(event.target)).length === 0
- ) {
- const $button = self.$buttonrow.find('button.primary')
- if ($button && !$button.prop('disabled')) {
- $button.trigger('click')
- }
- } else if (self.$buttonrow) {
- $(event.target).trigger('click')
- }
return false
}
})
this._setOptions(this.options)
this._createOverlay()
+ this._useFocusTrap()
},
_init() {
- this.$dialog.focus()
this._trigger('open')
},
_setOption(key, value) {
@@ -129,7 +119,7 @@ $.widget('oc.ocdialog', {
if (this.$buttonrow) {
this.$buttonrow.empty()
} else {
- const $buttonrow = $('<div class="oc-dialog-buttonrow" />')
+ const $buttonrow = $('<div class="oc-dialog-buttonrow"></div>')
this.$buttonrow = $buttonrow.appendTo(this.$dialog)
}
if (value.length === 1) {
@@ -149,8 +139,10 @@ $.widget('oc.ocdialog', {
self.$defaultButton = $button
}
self.$buttonrow.append($button)
- $button.click(function() {
- val.click.apply(self.element[0], arguments)
+ $button.on('click keydown', function(event) {
+ if (isA11yActivation(event)) {
+ val.click.apply(self.element[0], arguments)
+ }
})
})
this.$buttonrow.find('button')
@@ -167,11 +159,14 @@ $.widget('oc.ocdialog', {
break
case 'closeButton':
if (value) {
- const $closeButton = $('<a class="oc-dialog-close"></a>')
+ const $closeButton = $('<button class="oc-dialog-close"></button>')
+ $closeButton.attr('aria-label', t('core', 'Close "{dialogTitle}" dialog', { dialogTitle: this.$title || this.options.title }))
this.$dialog.prepend($closeButton)
- $closeButton.on('click', function() {
- self.options.closeCallback && self.options.closeCallback()
- self.close()
+ $closeButton.on('click keydown', function(event) {
+ if (isA11yActivation(event)) {
+ self.options.closeCallback && self.options.closeCallback()
+ self.close()
+ }
})
} else {
this.$dialog.find('.oc-dialog-close').remove()
@@ -219,7 +214,7 @@ $.widget('oc.ocdialog', {
}
this.overlay = $('<div>')
.addClass('oc-dialog-dim')
- .appendTo(contentDiv)
+ .insertBefore(this.$dialog)
this.overlay.on('click keydown keyup', function(event) {
if (event.target !== self.$dialog.get(0) && self.$dialog.find($(event.target)).length === 0) {
event.preventDefault()
@@ -239,6 +234,23 @@ $.widget('oc.ocdialog', {
this.overlay = null
}
},
+ _useFocusTrap() {
+ // Create global stack if undefined
+ Object.assign(window, { _nc_focus_trap: window._nc_focus_trap || [] })
+
+ const dialogElement = this.$dialog[0]
+ this.focusTrap = createFocusTrap(dialogElement, {
+ allowOutsideClick: true,
+ trapStack: window._nc_focus_trap,
+ fallbackFocus: dialogElement,
+ })
+
+ this.focusTrap.activate()
+ },
+ _clearFocusTrap() {
+ this.focusTrap?.deactivate()
+ this.focusTrap = null
+ },
widget() {
return this.$dialog
},
@@ -249,6 +261,7 @@ $.widget('oc.ocdialog', {
this.enterCallback = null
},
close() {
+ this._clearFocusTrap()
this._destroyOverlay()
const self = this
// Ugly hack to catch remaining keyup events.
diff --git a/core/src/jquery/octemplate.js b/core/src/jquery/octemplate.js
index f89e13b000f..cecbe880aa6 100644
--- a/core/src/jquery/octemplate.js
+++ b/core/src/jquery/octemplate.js
@@ -1,3 +1,8 @@
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
import $ from 'jquery'
import escapeHTML from 'escape-html'
@@ -41,14 +46,14 @@ import escapeHTML from 'escape-html'
* var contacts = // fetched in some ajax call
*
* $.each(contacts, function(idx, contact) {
- * $contactList.append(
- * $tmpl.octemplate({
- * id: contact.getId(),
- * name: contact.getDisplayName(),
- * email: contact.getPreferredEmail(),
- * phone: contact.getPreferredPhone(),
- * });
- * );
+ * $contactList.append(
+ * $tmpl.octemplate({
+ * id: contact.getId(),
+ * name: contact.getDisplayName(),
+ * email: contact.getPreferredEmail(),
+ * phone: contact.getPreferredPhone(),
+ * });
+ * );
* });
*/
/**
@@ -84,7 +89,7 @@ const Template = {
function(a, b) {
const r = o[b]
return typeof r === 'string' || typeof r === 'number' ? r : a
- }
+ },
)
} catch (e) {
console.error(e, 'data:', data)
diff --git a/core/src/jquery/placeholder.js b/core/src/jquery/placeholder.js
index 6c5f94313f6..e57951af5e4 100644
--- a/core/src/jquery/placeholder.js
+++ b/core/src/jquery/placeholder.js
@@ -1,27 +1,10 @@
-/* eslint-disable */
/**
- * ownCloud
- *
- * @author John Molakvoæ
- * @copyright 2016-2018 John Molakvoæ <skjnldsv@protonmail.com>
- * @author Morris Jobke
- * @copyright 2013 Morris Jobke <morris.jobke@gmail.com>
- *
- * This library is free software; you can redistribute it and/or
- * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or any later version.
- *
- * This library is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU AFFERO GENERAL PUBLIC LICENSE for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this library. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2013-2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable */
import $ from 'jquery'
import md5 from 'blueimp-md5'
@@ -137,7 +120,7 @@ const toRgb = (s) => {
}
String.prototype.toRgb = function() {
- console.warn('String.prototype.toRgb is deprecated! It will be removed in Nextcloud 22.')
+ OC.debug && console.warn('String.prototype.toRgb is deprecated! It will be removed in Nextcloud 22.')
return toRgb(this)
}
diff --git a/core/src/jquery/requesttoken.js b/core/src/jquery/requesttoken.js
index 5f15d43aa17..1e9e06515a6 100644
--- a/core/src/jquery/requesttoken.js
+++ b/core/src/jquery/requesttoken.js
@@ -1,31 +1,15 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
-import { getToken } from '../OC/requesttoken'
+import { getRequestToken } from '../OC/requesttoken.ts'
$(document).on('ajaxSend', function(elm, xhr, settings) {
if (settings.crossDomain === false) {
- xhr.setRequestHeader('requesttoken', getToken())
+ xhr.setRequestHeader('requesttoken', getRequestToken())
xhr.setRequestHeader('OCS-APIREQUEST', 'true')
}
})
diff --git a/core/src/jquery/selectrange.js b/core/src/jquery/selectrange.js
index 3076726024a..a4d8f49ce43 100644
--- a/core/src/jquery/selectrange.js
+++ b/core/src/jquery/selectrange.js
@@ -1,32 +1,17 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import $ from 'jquery'
/**
* select a range in an input field
- * @link http://stackoverflow.com/questions/499126/jquery-set-cursor-position-in-text-area
- * @param {int} start start selection from
- * @param {int} end number of char from start
- * @returns {Void}
+ *
+ * @see {@link http://stackoverflow.com/questions/499126/jquery-set-cursor-position-in-text-area}
+ * @param {number} start start selection from
+ * @param {number} end number of char from start
+ * @return {void}
*/
$.fn.selectRange = function(start, end) {
return this.each(function() {
diff --git a/core/src/jquery/showpassword.js b/core/src/jquery/showpassword.js
index ac0a9556ae8..8d938d7853b 100644
--- a/core/src/jquery/showpassword.js
+++ b/core/src/jquery/showpassword.js
@@ -1,40 +1,24 @@
-/*
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/** @typedef {import('jquery')} jQuery */
import $ from 'jquery'
-/*
-* @name Show Password
-* @description
-* @version 1.3
-* @requires Jquery 1.5
-*
-* @author Jan Jarfalk
-* @author-email jan.jarfalk@unwrongest.com
-* @author-website http://www.unwrongest.com
-*
-* @special-thanks Michel Gratton
-*
-* @licens MIT License - http://www.opensource.org/licenses/mit-license.php
-*/
+/**
+ * @name Show Password
+ * @description
+ * @version 1.3.0
+ * @requires jQuery 1.5
+ *
+ * @author Jan Jarfalk <jan.jarfalk@unwrongest.com>
+ * author-website http://www.unwrongest.com
+ *
+ * special-thanks Michel Gratton
+ *
+ * @license MIT
+ */
$.fn.extend({
showPassword(c) {
diff --git a/core/src/jquery/ui-fixes.js b/core/src/jquery/ui-fixes.js
index d70c5579f94..e23464b2f9d 100644
--- a/core/src/jquery/ui-fixes.js
+++ b/core/src/jquery/ui-fixes.js
@@ -1,3 +1,8 @@
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
import $ from 'jquery'
// Set autocomplete width the same as the related input
diff --git a/core/src/legacy-unified-search.js b/core/src/legacy-unified-search.js
new file mode 100644
index 00000000000..59ee462fbf5
--- /dev/null
+++ b/core/src/legacy-unified-search.js
@@ -0,0 +1,38 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+import { getCSPNonce } from '@nextcloud/auth'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import Vue from 'vue'
+
+import UnifiedSearch from './views/LegacyUnifiedSearch.vue'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+const logger = getLoggerBuilder()
+ .setApp('unified-search')
+ .detectUser()
+ .build()
+
+Vue.mixin({
+ data() {
+ return {
+ logger,
+ }
+ },
+ methods: {
+ t,
+ n,
+ },
+})
+
+export default new Vue({
+ el: '#unified-search',
+ // eslint-disable-next-line vue/match-component-file-name
+ name: 'UnifiedSearchRoot',
+ render: h => h(UnifiedSearch),
+})
diff --git a/core/src/logger.js b/core/src/logger.js
index bd01f58a413..78d51a798e4 100644
--- a/core/src/logger.js
+++ b/core/src/logger.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCurrentUser } from '@nextcloud/auth'
@@ -35,3 +19,8 @@ const getLogger = user => {
}
export default getLogger(getCurrentUser())
+
+export const unifiedSearchLogger = getLoggerBuilder()
+ .setApp('unified-search')
+ .detectUser()
+ .build()
diff --git a/core/src/login.js b/core/src/login.js
index a7d738ee9ef..29affcda762 100644
--- a/core/src/login.js
+++ b/core/src/login.js
@@ -1,71 +1,16 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { loadState } from '@nextcloud/initial-state'
-import queryString from 'query-string'
import Vue from 'vue'
// eslint-disable-next-line no-unused-vars
-import OC from './OC/index' // TODO: Not needed but L10n breaks if removed
+import OC from './OC/index.js' // TODO: Not needed but L10n breaks if removed
import LoginView from './views/Login.vue'
-import Nextcloud from './mixins/Nextcloud'
-
-const query = queryString.parse(location.search)
-if (query.clear === '1') {
- try {
- window.localStorage.clear()
- window.sessionStorage.clear()
- console.debug('Browser storage cleared')
- } catch (e) {
- console.error('Could not clear browser storage', e)
- }
-}
+import Nextcloud from './mixins/Nextcloud.js'
Vue.mixin(Nextcloud)
-const fromStateOr = (key, orValue) => {
- try {
- return loadState('core', key)
- } catch (e) {
- return orValue
- }
-}
-
const View = Vue.extend(LoginView)
-new View({
- propsData: {
- errors: fromStateOr('loginErrors', []),
- messages: fromStateOr('loginMessages', []),
- redirectUrl: fromStateOr('loginRedirectUrl', undefined),
- username: fromStateOr('loginUsername', ''),
- throttleDelay: fromStateOr('loginThrottleDelay', 0),
- invertedColors: OCA.Theming && OCA.Theming.inverted,
- canResetPassword: fromStateOr('loginCanResetPassword', false),
- resetPasswordLink: fromStateOr('loginResetPasswordLink', ''),
- autoCompleteAllowed: fromStateOr('loginAutocomplete', true),
- resetPasswordTarget: fromStateOr('resetPasswordTarget', ''),
- resetPasswordUser: fromStateOr('resetPasswordUser', ''),
- directLogin: query.direct === '1',
- hasPasswordless: fromStateOr('webauthn-available', false),
- isHttps: window.location.protocol === 'https:',
- hasPublicKeyCredential: typeof (window.PublicKeyCredential) !== 'undefined',
- },
-}).$mount('#login')
+new View().$mount('#login')
diff --git a/core/src/main.js b/core/src/main.js
index 492ac37c4ac..2d88f15562b 100644
--- a/core/src/main.js
+++ b/core/src/main.js
@@ -1,37 +1,25 @@
/**
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import $ from 'jquery'
-import 'core-js/stable'
-import 'regenerator-runtime/runtime'
-import './Polyfill/index'
+import 'core-js/stable/index.js'
+import 'regenerator-runtime/runtime.js'
// If you remove the line below, tests won't pass
// eslint-disable-next-line no-unused-vars
-import OC from './OC/index'
+import OC from './OC/index.js'
-import './globals'
-import './jquery/index'
-import { initCore } from './init'
-import { registerAppsSlideToggle } from './OC/apps'
+import './globals.js'
+import './jquery/index.js'
+import { initCore } from './init.js'
+import { registerAppsSlideToggle } from './OC/apps.js'
+import { getCSPNonce } from '@nextcloud/auth'
+import { generateUrl } from '@nextcloud/router'
+import Axios from '@nextcloud/axios'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
window.addEventListener('DOMContentLoaded', function() {
initCore()
@@ -41,6 +29,23 @@ window.addEventListener('DOMContentLoaded', function() {
if (window.history.pushState) {
window.onpopstate = _.bind(OC.Util.History._onPopState, OC.Util.History)
} else {
- $(window).on('hashchange', _.bind(OC.Util.History._onPopState, OC.Util.History))
+ window.onhashchange = _.bind(OC.Util.History._onPopState, OC.Util.History)
+ }
+})
+
+// Fix error "CSRF check failed"
+document.addEventListener('DOMContentLoaded', function() {
+ const form = document.getElementById('password-input-form')
+ if (form) {
+ form.addEventListener('submit', async function(event) {
+ event.preventDefault()
+ const requestToken = document.getElementById('requesttoken')
+ if (requestToken) {
+ const url = generateUrl('/csrftoken')
+ const resp = await Axios.get(url)
+ requestToken.value = resp.data.token
+ }
+ form.submit()
+ })
}
})
diff --git a/core/src/maintenance.js b/core/src/maintenance.js
index 3abc20ea8fc..e66b14a88f5 100644
--- a/core/src/maintenance.js
+++ b/core/src/maintenance.js
@@ -1,22 +1,6 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Axios from '@nextcloud/axios'
diff --git a/core/src/mixins/Nextcloud.js b/core/src/mixins/Nextcloud.js
index 3ca755b3052..3a94f85d2c6 100644
--- a/core/src/mixins/Nextcloud.js
+++ b/core/src/mixins/Nextcloud.js
@@ -1,26 +1,10 @@
-/*
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import L10n from '../OC/l10n'
-import OC from '../OC/index'
+import L10n from '../OC/l10n.js'
+import OC from '../OC/index.js'
export default {
data() {
diff --git a/core/src/mixins/auth.js b/core/src/mixins/auth.js
new file mode 100644
index 00000000000..f5b9365516e
--- /dev/null
+++ b/core/src/mixins/auth.js
@@ -0,0 +1,19 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+export default {
+
+ computed: {
+ userNameInputLengthIs255() {
+ return this.user.length >= 255
+ },
+ userInputHelperText() {
+ if (this.userNameInputLengthIs255) {
+ return t('core', 'Email length is at max (255)')
+ }
+ return undefined
+ },
+ },
+}
diff --git a/core/src/public-page-menu.ts b/core/src/public-page-menu.ts
new file mode 100644
index 00000000000..b290d1d03e9
--- /dev/null
+++ b/core/src/public-page-menu.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import PublicPageMenu from './views/PublicPageMenu.vue'
+
+__webpack_nonce__ = getCSPNonce()
+
+const View = Vue.extend(PublicPageMenu)
+const instance = new View()
+instance.$mount('#public-page-menu')
diff --git a/core/src/public-page-user-menu.ts b/core/src/public-page-user-menu.ts
new file mode 100644
index 00000000000..25024271fb5
--- /dev/null
+++ b/core/src/public-page-user-menu.ts
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import PublicPageUserMenu from './views/PublicPageUserMenu.vue'
+
+__webpack_nonce__ = getCSPNonce()
+
+const View = Vue.extend(PublicPageUserMenu)
+const instance = new View()
+instance.$mount('#public-page-user-menu')
diff --git a/core/src/public.ts b/core/src/public.ts
new file mode 100644
index 00000000000..ce4af8aa2ac
--- /dev/null
+++ b/core/src/public.ts
@@ -0,0 +1,26 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+const body = document.body
+const footer = document.querySelector('footer')
+let prevHeight = footer?.offsetHeight
+
+const onResize: ResizeObserverCallback = (entries) => {
+ for (const entry of entries) {
+ const height = entry.contentRect.height
+ if (height === prevHeight) {
+ return
+ }
+ prevHeight = height
+ body.style.setProperty('--footer-height', `${height}px`)
+ }
+}
+
+if (footer) {
+ new ResizeObserver(onResize)
+ .observe(footer, {
+ box: 'border-box', // <footer> is border-box
+ })
+}
diff --git a/core/src/recommendedapps.js b/core/src/recommendedapps.js
index ef9071eb42c..13f16436ed3 100644
--- a/core/src/recommendedapps.js
+++ b/core/src/recommendedapps.js
@@ -1,36 +1,17 @@
/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { getRequestToken } from '@nextcloud/auth'
-import { generateFilePath } from '@nextcloud/router'
+import { getCSPNonce } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import Vue from 'vue'
-import logger from './logger'
-import RecommendedApps from './components/setup/RecommendedApps'
+import logger from './logger.js'
+import RecommendedApps from './components/setup/RecommendedApps.vue'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
-// eslint-disable-next-line camelcase
-__webpack_public_path__ = generateFilePath('core', '', 'js/')
+__webpack_nonce__ = getCSPNonce()
Vue.mixin({
methods: {
diff --git a/core/src/services/BrowserStorageService.js b/core/src/services/BrowserStorageService.js
new file mode 100644
index 00000000000..b7d34bf1716
--- /dev/null
+++ b/core/src/services/BrowserStorageService.js
@@ -0,0 +1,11 @@
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getBuilder } from '@nextcloud/browser-storage'
+
+export default getBuilder('core')
+ .clearOnLogout()
+ .persist()
+ .build()
diff --git a/core/src/services/BrowsersListService.js b/core/src/services/BrowsersListService.js
new file mode 100644
index 00000000000..77f217a86ac
--- /dev/null
+++ b/core/src/services/BrowsersListService.js
@@ -0,0 +1,13 @@
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getUserAgentRegex } from 'browserslist-useragent-regexp'
+// eslint-disable-next-line n/no-extraneous-import
+import browserslist from 'browserslist'
+import browserslistConfig from '@nextcloud/browserslist-config'
+
+// Generate a regex that matches user agents to detect incompatible browsers
+export const supportedBrowsersRegExp = getUserAgentRegex({ allowHigherVersions: true, browsers: browserslistConfig })
+export const supportedBrowsers = browserslist(browserslistConfig)
diff --git a/core/src/services/LegacyUnifiedSearchService.js b/core/src/services/LegacyUnifiedSearchService.js
new file mode 100644
index 00000000000..5b79c09b8b2
--- /dev/null
+++ b/core/src/services/LegacyUnifiedSearchService.js
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { generateOcsUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+import axios from '@nextcloud/axios'
+
+export const defaultLimit = loadState('unified-search', 'limit-default')
+export const minSearchLength = loadState('unified-search', 'min-search-length', 1)
+export const enableLiveSearch = loadState('unified-search', 'live-search', true)
+
+export const regexFilterIn = /(^|\s)in:([a-z_-]+)/ig
+export const regexFilterNot = /(^|\s)-in:([a-z_-]+)/ig
+
+/**
+ * Create a cancel token
+ *
+ * @return {import('axios').CancelTokenSource}
+ */
+const createCancelToken = () => axios.CancelToken.source()
+
+/**
+ * Get the list of available search providers
+ *
+ * @return {Promise<Array>}
+ */
+export async function getTypes() {
+ try {
+ const { data } = await axios.get(generateOcsUrl('search/providers'), {
+ params: {
+ // Sending which location we're currently at
+ from: window.location.pathname.replace('/index.php', '') + window.location.search,
+ },
+ })
+ if ('ocs' in data && 'data' in data.ocs && Array.isArray(data.ocs.data) && data.ocs.data.length > 0) {
+ // Providers are sorted by the api based on their order key
+ return data.ocs.data
+ }
+ } catch (error) {
+ console.error(error)
+ }
+ return []
+}
+
+/**
+ * Get the list of available search providers
+ *
+ * @param {object} options destructuring object
+ * @param {string} options.type the type to search
+ * @param {string} options.query the search
+ * @param {number|string|undefined} options.cursor the offset for paginated searches
+ * @return {object} {request: Promise, cancel: Promise}
+ */
+export function search({ type, query, cursor }) {
+ /**
+ * Generate an axios cancel token
+ */
+ const cancelToken = createCancelToken()
+
+ const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
+ cancelToken: cancelToken.token,
+ params: {
+ term: query,
+ cursor,
+ // Sending which location we're currently at
+ from: window.location.pathname.replace('/index.php', '') + window.location.search,
+ },
+ })
+
+ return {
+ request,
+ cancel: cancelToken.cancel,
+ }
+}
diff --git a/core/src/services/UnifiedSearchService.js b/core/src/services/UnifiedSearchService.js
index eb91f18f8c9..7067c994c90 100644
--- a/core/src/services/UnifiedSearchService.js
+++ b/core/src/services/UnifiedSearchService.js
@@ -1,47 +1,27 @@
/**
- * @copyright 2020, John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-import { generateOcsUrl } from '@nextcloud/router'
-import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
-
-export const defaultLimit = loadState('unified-search', 'limit-default')
-export const minSearchLength = 2
-export const regexFilterIn = /[^-]in:([a-z_-]+)/ig
-export const regexFilterNot = /-in:([a-z_-]+)/ig
+import { getCurrentUser } from '@nextcloud/auth'
/**
* Create a cancel token
- * @returns {CancelTokenSource}
+ *
+ * @return {import('axios').CancelTokenSource}
*/
const createCancelToken = () => axios.CancelToken.source()
/**
* Get the list of available search providers
*
- * @returns {Array}
+ * @return {Promise<Array>}
*/
-export async function getTypes() {
+export async function getProviders() {
try {
- const { data } = await axios.get(generateOcsUrl('search', 2) + 'providers', {
+ const { data } = await axios.get(generateOcsUrl('search/providers'), {
params: {
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
@@ -60,25 +40,35 @@ export async function getTypes() {
/**
* Get the list of available search providers
*
- * @param {Object} options destructuring object
+ * @param {object} options destructuring object
* @param {string} options.type the type to search
* @param {string} options.query the search
- * @param {int|string|undefined} options.cursor the offset for paginated searches
- * @returns {Object} {request: Promise, cancel: Promise}
+ * @param {number|string|undefined} options.cursor the offset for paginated searches
+ * @param {string} options.since the search
+ * @param {string} options.until the search
+ * @param {string} options.limit the search
+ * @param {string} options.person the search
+ * @param {object} options.extraQueries additional queries to filter search results
+ * @return {object} {request: Promise, cancel: Promise}
*/
-export function search({ type, query, cursor }) {
+export function search({ type, query, cursor, since, until, limit, person, extraQueries = {} }) {
/**
* Generate an axios cancel token
*/
const cancelToken = createCancelToken()
- const request = async() => axios.get(generateOcsUrl('search', 2) + `providers/${type}/search`, {
+ const request = async () => axios.get(generateOcsUrl('search/providers/{type}/search', { type }), {
cancelToken: cancelToken.token,
params: {
term: query,
cursor,
+ since,
+ until,
+ limit,
+ person,
// Sending which location we're currently at
from: window.location.pathname.replace('/index.php', '') + window.location.search,
+ ...extraQueries,
},
})
@@ -87,3 +77,32 @@ export function search({ type, query, cursor }) {
cancel: cancelToken.cancel,
}
}
+
+/**
+ * Get the list of active contacts
+ *
+ * @param {object} filter filter contacts by string
+ * @param {string} filter.searchTerm the query
+ * @return {object} {request: Promise}
+ */
+export async function getContacts({ searchTerm }) {
+ const { data: { contacts } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
+ filter: searchTerm,
+ })
+ /*
+ * Add authenticated user to list of contacts for search filter
+ * If authtenicated user is searching/filtering, do not add them to the list
+ */
+ if (!searchTerm) {
+ let authenticatedUser = getCurrentUser()
+ authenticatedUser = {
+ id: authenticatedUser.uid,
+ fullName: authenticatedUser.displayName,
+ emailAddresses: [],
+ }
+ contacts.unshift(authenticatedUser)
+ return contacts
+ }
+
+ return contacts
+}
diff --git a/core/src/services/WebAuthnAuthenticationService.js b/core/src/services/WebAuthnAuthenticationService.js
deleted file mode 100644
index 91f19177066..00000000000
--- a/core/src/services/WebAuthnAuthenticationService.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * @copyright 2020, Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-import Axios from '@nextcloud/axios'
-import { generateUrl } from '@nextcloud/router'
-
-export function startAuthentication(loginName) {
- const url = generateUrl('/login/webauthn/start')
-
- return Axios.post(url, { loginName })
- .then(resp => resp.data)
-}
-
-export function finishAuthentication(data) {
- const url = generateUrl('/login/webauthn/finish')
-
- return Axios.post(url, { data })
- .then(resp => resp.data)
-}
diff --git a/core/src/services/WebAuthnAuthenticationService.ts b/core/src/services/WebAuthnAuthenticationService.ts
new file mode 100644
index 00000000000..df1837254ad
--- /dev/null
+++ b/core/src/services/WebAuthnAuthenticationService.ts
@@ -0,0 +1,42 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/browser'
+
+import { startAuthentication as startWebauthnAuthentication } from '@simplewebauthn/browser'
+import { generateUrl } from '@nextcloud/router'
+
+import Axios from '@nextcloud/axios'
+import logger from '../logger'
+
+export class NoValidCredentials extends Error {}
+
+/**
+ * Start webautn authentication
+ * This loads the challenge, connects to the authenticator and returns the repose that needs to be sent to the server.
+ *
+ * @param loginName Name to login
+ */
+export async function startAuthentication(loginName: string) {
+ const url = generateUrl('/login/webauthn/start')
+
+ const { data } = await Axios.post<PublicKeyCredentialRequestOptionsJSON>(url, { loginName })
+ if (!data.allowCredentials || data.allowCredentials.length === 0) {
+ logger.error('No valid credentials returned for webauthn')
+ throw new NoValidCredentials()
+ }
+ return await startWebauthnAuthentication({ optionsJSON: data })
+}
+
+/**
+ * Verify webauthn authentication
+ * @param authData The authentication data to sent to the server
+ */
+export async function finishAuthentication(authData: AuthenticationResponseJSON) {
+ const url = generateUrl('/login/webauthn/finish')
+
+ const { data } = await Axios.post(url, { data: JSON.stringify(authData) })
+ return data
+}
diff --git a/core/src/session-heartbeat.js b/core/src/session-heartbeat.js
deleted file mode 100644
index 38db6b49a5c..00000000000
--- a/core/src/session-heartbeat.js
+++ /dev/null
@@ -1,179 +0,0 @@
-/**
- * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-import $ from 'jquery'
-import { emit } from '@nextcloud/event-bus'
-import { loadState } from '@nextcloud/initial-state'
-import { getCurrentUser } from '@nextcloud/auth'
-import { generateUrl } from '@nextcloud/router'
-
-import OC from './OC'
-import { setToken as setRequestToken, getToken as getRequestToken } from './OC/requesttoken'
-
-let config = null
-/**
- * The legacy jsunit tests overwrite OC.config before calling initCore
- * therefore we need to wait with assigning the config fallback until initCore calls initSessionHeartBeat
- */
-const loadConfig = () => {
- try {
- config = loadState('core', 'config')
- } catch (e) {
- // This fallback is just for our legacy jsunit tests since we have no way to mock loadState calls
- config = OC.config
- }
-}
-
-/**
- * session heartbeat (defaults to enabled)
- * @returns {boolean}
- */
-const keepSessionAlive = () => {
- return config.session_keepalive === undefined
- || !!config.session_keepalive
-}
-
-/**
- * get interval in seconds
- * @returns {Number}
- */
-const getInterval = () => {
- let interval = NaN
- if (config.session_lifetime) {
- interval = Math.floor(config.session_lifetime / 2)
- }
-
- // minimum one minute, max 24 hours, default 15 minutes
- return Math.min(
- 24 * 3600,
- Math.max(
- 60,
- isNaN(interval) ? 900 : interval
- )
- )
-}
-
-const getToken = async() => {
- const url = generateUrl('/csrftoken')
-
- // Not using Axios here as Axios is not stubbable with the sinon fake server
- // see https://stackoverflow.com/questions/41516044/sinon-mocha-test-with-async-ajax-calls-didnt-return-promises
- // see js/tests/specs/coreSpec.js for the tests
- const resp = await $.get(url)
-
- return resp.token
-}
-
-const poll = async() => {
- try {
- const token = await getToken()
- setRequestToken(token)
- } catch (e) {
- console.error('session heartbeat failed', e)
- }
-}
-
-const startPolling = () => {
- const interval = setInterval(poll, getInterval() * 1000)
-
- console.info('session heartbeat polling started')
-
- return interval
-}
-
-const registerAutoLogout = () => {
- if (!config.auto_logout || !getCurrentUser()) {
- return
- }
-
- let lastActive = Date.now()
- window.addEventListener('mousemove', e => {
- lastActive = Date.now()
- localStorage.setItem('lastActive', lastActive)
- })
-
- window.addEventListener('touchstart', e => {
- lastActive = Date.now()
- localStorage.setItem('lastActive', lastActive)
- })
-
- window.addEventListener('storage', e => {
- if (e.key !== 'lastActive') {
- return
- }
- lastActive = e.newValue
- })
-
- setInterval(function() {
- const timeout = Date.now() - config.session_lifetime * 1000
- if (lastActive < timeout) {
- console.info('Inactivity timout reached, logging out')
- const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken())
- window.location = logoutUrl
- }
- }, 1000)
-}
-
-/**
- * Calls the server periodically to ensure that session and CSRF
- * token doesn't expire
- */
-export const initSessionHeartBeat = () => {
- loadConfig()
-
- registerAutoLogout()
-
- if (!keepSessionAlive()) {
- console.info('session heartbeat disabled')
- return
- }
- let interval = startPolling()
-
- window.addEventListener('online', async() => {
- console.info('browser is online again, resuming heartbeat')
- interval = startPolling()
- try {
- await poll()
- console.info('session token successfully updated after resuming network')
-
- // Let apps know we're online and requests will have the new token
- emit('networkOnline', {
- success: true,
- })
- } catch (e) {
- console.error('could not update session token after resuming network', e)
-
- // Let apps know we're online but requests might have an outdated token
- emit('networkOnline', {
- success: false,
- })
- }
- })
- window.addEventListener('offline', () => {
- console.info('browser is offline, stopping heartbeat')
-
- // Let apps know we're offline
- emit('networkOffline', {})
-
- clearInterval(interval)
- console.info('session heartbeat polling stopped')
- })
-}
diff --git a/core/src/session-heartbeat.ts b/core/src/session-heartbeat.ts
new file mode 100644
index 00000000000..42a9bfccef7
--- /dev/null
+++ b/core/src/session-heartbeat.ts
@@ -0,0 +1,158 @@
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { emit } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { getCurrentUser } from '@nextcloud/auth'
+import { generateUrl } from '@nextcloud/router'
+import {
+ fetchRequestToken,
+ getRequestToken,
+} from './OC/requesttoken.ts'
+import logger from './logger.js'
+
+interface OcJsConfig {
+ auto_logout: boolean
+ session_keepalive: boolean
+ session_lifetime: number
+}
+
+// This is always set, exception would be e.g. error pages where this is undefined
+const {
+ auto_logout: autoLogout,
+ session_keepalive: keepSessionAlive,
+ session_lifetime: sessionLifetime,
+} = loadState<Partial<OcJsConfig>>('core', 'config', {})
+
+/**
+ * Calls the server periodically to ensure that session and CSRF
+ * token doesn't expire
+ */
+export function initSessionHeartBeat() {
+ registerAutoLogout()
+
+ if (!keepSessionAlive) {
+ logger.info('Session heartbeat disabled')
+ return
+ }
+
+ let interval = startPolling()
+ window.addEventListener('online', async () => {
+ logger.info('Browser is online again, resuming heartbeat')
+
+ interval = startPolling()
+ try {
+ await poll()
+ logger.info('Session token successfully updated after resuming network')
+
+ // Let apps know we're online and requests will have the new token
+ emit('networkOnline', {
+ success: true,
+ })
+ } catch (error) {
+ logger.error('could not update session token after resuming network', { error })
+
+ // Let apps know we're online but requests might have an outdated token
+ emit('networkOnline', {
+ success: false,
+ })
+ }
+ })
+
+ window.addEventListener('offline', () => {
+ logger.info('Browser is offline, stopping heartbeat')
+
+ // Let apps know we're offline
+ emit('networkOffline', {})
+
+ clearInterval(interval)
+ logger.info('Session heartbeat polling stopped')
+ })
+}
+
+/**
+ * Get interval in seconds
+ */
+function getInterval(): number {
+ const interval = sessionLifetime
+ ? Math.floor(sessionLifetime / 2)
+ : 900
+
+ // minimum one minute, max 24 hours, default 15 minutes
+ return Math.min(
+ 24 * 3600,
+ Math.max(
+ 60,
+ interval,
+ ),
+ )
+}
+
+/**
+ * Poll the CSRF token for changes.
+ * This will also extend the current session if needed.
+ */
+async function poll() {
+ try {
+ await fetchRequestToken()
+ } catch (error) {
+ logger.error('session heartbeat failed', { error })
+ }
+}
+
+/**
+ * Start an window interval with the polling as the callback.
+ *
+ * @return The interval id
+ */
+function startPolling(): number {
+ const interval = window.setInterval(poll, getInterval() * 1000)
+
+ logger.info('session heartbeat polling started')
+ return interval
+}
+
+/**
+ * If enabled this will register event listeners to track if a user is active.
+ * If not the user will be automatically logged out after the configured IDLE time.
+ */
+function registerAutoLogout() {
+ if (!autoLogout || !getCurrentUser()) {
+ return
+ }
+
+ let lastActive = Date.now()
+ window.addEventListener('mousemove', () => {
+ lastActive = Date.now()
+ localStorage.setItem('lastActive', JSON.stringify(lastActive))
+ })
+
+ window.addEventListener('touchstart', () => {
+ lastActive = Date.now()
+ localStorage.setItem('lastActive', JSON.stringify(lastActive))
+ })
+
+ window.addEventListener('storage', (event) => {
+ if (event.key !== 'lastActive') {
+ return
+ }
+ if (event.newValue === null) {
+ return
+ }
+ lastActive = JSON.parse(event.newValue)
+ })
+
+ let intervalId = 0
+ const logoutCheck = () => {
+ const timeout = Date.now() - (sessionLifetime ?? 86400) * 1000
+ if (lastActive < timeout) {
+ clearTimeout(intervalId)
+ logger.info('Inactivity timout reached, logging out')
+ const logoutUrl = generateUrl('/logout') + '?requesttoken=' + encodeURIComponent(getRequestToken())
+ window.location.href = logoutUrl
+ }
+ }
+ intervalId = window.setInterval(logoutCheck, 1000)
+}
diff --git a/core/src/store/unified-search-external-filters.js b/core/src/store/unified-search-external-filters.js
new file mode 100644
index 00000000000..55de34b8b2a
--- /dev/null
+++ b/core/src/store/unified-search-external-filters.js
@@ -0,0 +1,17 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { defineStore } from 'pinia'
+
+export const useSearchStore = defineStore('search', {
+ state: () => ({
+ externalFilters: [],
+ }),
+
+ actions: {
+ registerExternalFilter({ id, appId, searchFrom, label, callback, icon }) {
+ this.externalFilters.push({ id, appId, searchFrom, name: label, callback, icon, isPluginFilter: true })
+ },
+ },
+})
diff --git a/core/src/systemtags/merged-systemtags.js b/core/src/systemtags/merged-systemtags.js
index 011544baa08..e4ccb1d3802 100644
--- a/core/src/systemtags/merged-systemtags.js
+++ b/core/src/systemtags/merged-systemtags.js
@@ -1,3 +1,9 @@
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
import './systemtags.js'
import './systemtagmodel.js'
import './systemtagsmappingcollection.js'
diff --git a/core/src/systemtags/systemtagmodel.js b/core/src/systemtags/systemtagmodel.js
index f0f564e012a..1d2cd3ae57d 100644
--- a/core/src/systemtags/systemtagmodel.js
+++ b/core/src/systemtags/systemtagmodel.js
@@ -1,60 +1,58 @@
-/*
- * Copyright (c) 2015
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * @param {object} OC The OC namespace
*/
(function(OC) {
+ if (OC?.Files?.Client) {
+ _.extend(OC.Files.Client, {
+ PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
+ PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign',
+ PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name',
+ PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible',
+ PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable',
+ })
- _.extend(OC.Files.Client, {
- PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
- PROPERTY_CAN_ASSIGN: '{' + OC.Files.Client.NS_OWNCLOUD + '}can-assign',
- PROPERTY_DISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}display-name',
- PROPERTY_USERVISIBLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-visible',
- PROPERTY_USERASSIGNABLE: '{' + OC.Files.Client.NS_OWNCLOUD + '}user-assignable',
- })
-
- /**
- * @class OCA.SystemTags.SystemTagsCollection
- * @classdesc
- *
- * System tag
- *
- */
- const SystemTagModel = OC.Backbone.Model.extend(
- /** @lends OCA.SystemTags.SystemTagModel.prototype */ {
- sync: OC.Backbone.davSync,
+ /**
+ * @class OCA.SystemTags.SystemTagsCollection
+ * @classdesc
+ *
+ * System tag
+ *
+ */
+ const SystemTagModel = OC.Backbone.Model.extend(
+ /** @lends OCA.SystemTags.SystemTagModel.prototype */ {
+ sync: OC.Backbone.davSync,
- defaults: {
- userVisible: true,
- userAssignable: true,
- canAssign: true,
- },
+ defaults: {
+ userVisible: true,
+ userAssignable: true,
+ canAssign: true,
+ },
- davProperties: {
- id: OC.Files.Client.PROPERTY_FILEID,
- name: OC.Files.Client.PROPERTY_DISPLAYNAME,
- userVisible: OC.Files.Client.PROPERTY_USERVISIBLE,
- userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE,
- // read-only, effective permissions computed by the server,
- canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN,
- },
+ davProperties: {
+ id: OC.Files.Client.PROPERTY_FILEID,
+ name: OC.Files.Client.PROPERTY_DISPLAYNAME,
+ userVisible: OC.Files.Client.PROPERTY_USERVISIBLE,
+ userAssignable: OC.Files.Client.PROPERTY_USERASSIGNABLE,
+ // read-only, effective permissions computed by the server,
+ canAssign: OC.Files.Client.PROPERTY_CAN_ASSIGN,
+ },
- parse(data) {
- return {
- id: data.id,
- name: data.name,
- userVisible: data.userVisible === true || data.userVisible === 'true',
- userAssignable: data.userAssignable === true || data.userAssignable === 'true',
- canAssign: data.canAssign === true || data.canAssign === 'true',
- }
- },
- })
+ parse(data) {
+ return {
+ id: data.id,
+ name: data.name,
+ userVisible: data.userVisible === true || data.userVisible === 'true',
+ userAssignable: data.userAssignable === true || data.userAssignable === 'true',
+ canAssign: data.canAssign === true || data.canAssign === 'true',
+ }
+ },
+ })
- OC.SystemTags = OC.SystemTags || {}
- OC.SystemTags.SystemTagModel = SystemTagModel
+ OC.SystemTags = OC.SystemTags || {}
+ OC.SystemTags.SystemTagModel = SystemTagModel
+ }
})(OC)
diff --git a/core/src/systemtags/systemtags.js b/core/src/systemtags/systemtags.js
index 676f1b8de73..ceb4652fe1c 100644
--- a/core/src/systemtags/systemtags.js
+++ b/core/src/systemtags/systemtags.js
@@ -1,14 +1,10 @@
-/* eslint-disable */
-/*
- * Copyright (c) 2016
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable */
import escapeHTML from 'escape-html'
(function(OC) {
@@ -19,34 +15,37 @@ import escapeHTML from 'escape-html'
/**
*
* @param {OC.SystemTags.SystemTagModel|Object|String} tag
- * @returns {jQuery}
+ * @returns {HTMLElement}
*/
getDescriptiveTag: function(tag) {
if (_.isUndefined(tag.name) && !_.isUndefined(tag.toJSON)) {
tag = tag.toJSON()
}
+ var $span = document.createElement('span')
+
if (_.isUndefined(tag.name)) {
- return $('<span>').addClass('non-existing-tag').text(
- t('core', 'Non-existing tag #{tag}', {
+ $span.classList.add('non-existing-tag')
+ $span.textContent = t('core', 'Non-existing tag #{tag}', {
tag: tag
- })
- )
+ })
+ return $span
}
- var $span = $('<span>')
- $span.append(escapeHTML(tag.name))
+ $span.textContent = escapeHTML(tag.name)
var scope
if (!tag.userAssignable) {
- scope = t('core', 'restricted')
+ scope = t('core', 'Restricted')
}
if (!tag.userVisible) {
// invisible also implicitly means not assignable
- scope = t('core', 'invisible')
+ scope = t('core', 'Invisible')
}
if (scope) {
- $span.append($('<em>').text(' (' + scope + ')'))
+ var $scope = document.createElement('em')
+ $scope.textContent = ' (' + scope + ')'
+ $span.appendChild($scope)
}
return $span
}
diff --git a/core/src/systemtags/systemtagscollection.js b/core/src/systemtags/systemtagscollection.js
index fe3f2868558..960d26ed36e 100644
--- a/core/src/systemtags/systemtagscollection.js
+++ b/core/src/systemtags/systemtagscollection.js
@@ -1,14 +1,10 @@
-/* eslint-disable */
-/*
- * Copyright (c) 2015
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable */
(function(OC) {
function filterFunction(model, term) {
@@ -48,7 +44,7 @@
* Lazy fetch.
* Only fetches once, subsequent calls will directly call the success handler.
*
- * @param options
+ * @param {any} options -
* @param [options.force] true to force fetch even if cached entries exist
*
* @see Backbone.Collection#fetch
@@ -56,7 +52,7 @@
fetch: function(options) {
var self = this
options = options || {}
- if (this.fetched || options.force) {
+ if (this.fetched || this.working || options.force) {
// directly call handler
if (options.success) {
options.success(this, null, options)
@@ -66,10 +62,13 @@
return Promise.resolve()
}
+ this.working = true
+
var success = options.success
options = _.extend({}, options)
options.success = function() {
self.fetched = true
+ self.working = false
if (success) {
return success.apply(this, arguments)
}
diff --git a/core/src/systemtags/systemtagsinputfield.js b/core/src/systemtags/systemtagsinputfield.js
index d05ac5899fd..b31d24dd0b5 100644
--- a/core/src/systemtags/systemtagsinputfield.js
+++ b/core/src/systemtags/systemtagsinputfield.js
@@ -1,14 +1,10 @@
-/* eslint-disable */
-/*
- * Copyright (c) 2015
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
+/* eslint-disable */
import templateResult from './templates/result.handlebars'
import templateResultForm from './templates/result_form.handlebars'
import templateSelection from './templates/selection.handlebars'
@@ -42,10 +38,10 @@ import templateSelection from './templates/selection.handlebars'
*
* @param {Object} [options]
* @param {string} [options.objectType=files] object type for which tags are assigned to
- * @param {bool} [options.multiple=false] whether to allow selecting multiple tags
- * @param {bool} [options.allowActions=true] whether tags can be renamed/delete within the dropdown
- * @param {bool} [options.allowCreate=true] whether new tags can be created
- * @param {bool} [options.isAdmin=true] whether the user is an administrator
+ * @param {boolean} [options.multiple=false] whether to allow selecting multiple tags
+ * @param {boolean} [options.allowActions=true] whether tags can be renamed/delete within the dropdown
+ * @param {boolean} [options.allowCreate=true] whether new tags can be created
+ * @param {boolean} [options.isAdmin=true] whether the user is an administrator
* @param {Function} options.initSelection function to convert selection to data
*/
initialize: function(options) {
@@ -162,7 +158,7 @@ import templateSelection from './templates/selection.handlebars'
var $item = $(ev.target).closest('.systemtags-item')
var tagId = $item.attr('data-id')
this.collection.get(tagId).destroy()
- $(ev.target).tooltip('hide')
+ $(ev.target).tooltip('option', 'hide')
$item.closest('.select2-result').remove()
// TODO: spinner
return false
@@ -277,7 +273,7 @@ import templateSelection from './templates/selection.handlebars'
return templateResult(_.extend({
renameTooltip: t('core', 'Rename'),
allowActions: this._allowActions,
- tagMarkup: this._isAdmin ? OC.SystemTags.getDescriptiveTag(data)[0].innerHTML : null,
+ tagMarkup: this._isAdmin ? OC.SystemTags.getDescriptiveTag(data).innerHTML : null,
isAdmin: this._isAdmin
}, data))
},
@@ -290,7 +286,7 @@ import templateSelection from './templates/selection.handlebars'
*/
_formatSelection: function(data) {
return templateSelection(_.extend({
- tagMarkup: this._isAdmin ? OC.SystemTags.getDescriptiveTag(data)[0].innerHTML : null,
+ tagMarkup: this._isAdmin ? OC.SystemTags.getDescriptiveTag(data).innerHTML : null,
isAdmin: this._isAdmin
}, data))
},
diff --git a/core/src/systemtags/systemtagsmappingcollection.js b/core/src/systemtags/systemtagsmappingcollection.js
index 62c9fa69b88..78c23ff67f0 100644
--- a/core/src/systemtags/systemtagsmappingcollection.js
+++ b/core/src/systemtags/systemtagsmappingcollection.js
@@ -1,11 +1,7 @@
-/*
- * Copyright (c) 2015
- *
- * This file is licensed under the Affero General Public License version 3
- * or later.
- *
- * See the COPYING-README file.
- *
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateRemoteUrl } from '@nextcloud/router'
@@ -24,22 +20,22 @@ import { generateRemoteUrl } from '@nextcloud/router'
sync: OC.Backbone.davSync,
/**
- * Use PUT instead of PROPPATCH
- */
+ * Use PUT instead of PROPPATCH
+ */
usePUT: true,
/**
- * Id of the file for which to filter activities by
- *
- * @var int
- */
+ * Id of the file for which to filter activities by
+ *
+ * @member int
+ */
_objectId: null,
/**
- * Type of the object to filter by
- *
- * @var string
- */
+ * Type of the object to filter by
+ *
+ * @member string
+ */
_objectType: 'files',
model: OC.SystemTags.SystemTagModel,
@@ -49,19 +45,19 @@ import { generateRemoteUrl } from '@nextcloud/router'
},
/**
- * Sets the object id to filter by or null for all.
- *
- * @param {int} objectId file id or null
- */
+ * Sets the object id to filter by or null for all.
+ *
+ * @param {number} objectId file id or null
+ */
setObjectId(objectId) {
this._objectId = objectId
},
/**
- * Sets the object type to filter by or null for all.
- *
- * @param {int} objectType file id or null
- */
+ * Sets the object type to filter by or null for all.
+ *
+ * @param {number} objectType file id or null
+ */
setObjectType(objectType) {
this._objectType = objectType
},
diff --git a/core/src/tests/.eslintrc.js b/core/src/tests/.eslintrc.js
new file mode 100644
index 00000000000..598fc5c28b4
--- /dev/null
+++ b/core/src/tests/.eslintrc.js
@@ -0,0 +1,13 @@
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+module.exports = {
+ globals: {
+ jsdom: true,
+ sinon: true,
+ },
+ rules: {
+ 'node/no-unpublished-import': 'off',
+ },
+}
diff --git a/core/src/tests/OC/requesttoken.spec.js b/core/src/tests/OC/requesttoken.spec.js
deleted file mode 100644
index d19a4b8e9c8..00000000000
--- a/core/src/tests/OC/requesttoken.spec.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-import {JSDOM} from 'jsdom'
-import {subscribe, unsubscribe} from '@nextcloud/event-bus'
-
-import {manageToken, setToken} from '../../OC/requesttoken'
-
-describe('request token', () => {
-
- let dom
- let emit
- let manager
- const token = 'abc123'
-
- beforeEach(() => {
- dom = new JSDOM()
- emit = sinon.spy()
- const head = dom.window.document.getElementsByTagName('head')[0]
- head.setAttribute('data-requesttoken', token)
-
- manager = manageToken(dom.window.document, emit)
- })
-
- it('reads the token from the document', () => {
- expect(manager.getToken()).to.equal('abc123')
- })
-
- it('remembers the updated token', () => {
- manager.setToken('bca321')
-
- expect(manager.getToken()).to.equal('bca321')
- })
-
- describe('@nextcloud/auth integration', () => {
- let listener
-
- beforeEach(() => {
- listener = sinon.spy()
-
- subscribe('csrf-token-update', listener)
- })
-
- afterEach(() => {
- unsubscribe('csrf-token-update', listener)
- })
-
- it('fires off an event for @nextcloud/auth', () => {
- setToken('123')
-
- expect(listener).to.have.been.calledWith({token: '123'})
- })
- })
-
-})
diff --git a/core/src/tests/OC/requesttoken.spec.ts b/core/src/tests/OC/requesttoken.spec.ts
new file mode 100644
index 00000000000..8f92dbed153
--- /dev/null
+++ b/core/src/tests/OC/requesttoken.spec.ts
@@ -0,0 +1,147 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { setupServer } from 'msw/node'
+import { http, HttpResponse } from 'msw'
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fetchRequestToken, getRequestToken, setRequestToken } from '../../OC/requesttoken.ts'
+
+const eventbus = vi.hoisted(() => ({ emit: vi.fn() }))
+vi.mock('@nextcloud/event-bus', () => eventbus)
+
+const server = setupServer()
+
+describe('getRequestToken', () => {
+ it('can read the token from DOM', () => {
+ mockToken('tokenmock-123')
+ expect(getRequestToken()).toBe('tokenmock-123')
+ })
+
+ it('can handle missing token', () => {
+ mockToken(undefined)
+ expect(getRequestToken()).toBeUndefined()
+ })
+})
+
+describe('setRequestToken', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('does emit an event on change', () => {
+ setRequestToken('new-token')
+ expect(eventbus.emit).toBeCalledTimes(1)
+ expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
+ })
+
+ it('does set the new token to the DOM', () => {
+ setRequestToken('new-token')
+ expect(document.head.dataset.requesttoken).toBe('new-token')
+ })
+
+ it('does remember the new token', () => {
+ mockToken('old-token')
+ setRequestToken('new-token')
+ expect(getRequestToken()).toBe('new-token')
+ })
+
+ it('throws if the token is not a string', () => {
+ // @ts-expect-error mocking
+ expect(() => setRequestToken(123)).toThrowError('Invalid CSRF token given')
+ })
+
+ it('throws if the token is not valid', () => {
+ expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
+ })
+
+ it('does not emit an event if the token is not valid', () => {
+ expect(() => setRequestToken('')).toThrowError('Invalid CSRF token given')
+ expect(eventbus.emit).not.toBeCalled()
+ })
+})
+
+describe('fetchRequestToken', () => {
+ const successfullCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json({ token: 'new-token' })
+ })
+ const forbiddenCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json([], { status: 403 })
+ })
+ const serverErrorCsrf = http.get('/index.php/csrftoken', () => {
+ return HttpResponse.json([], { status: 500 })
+ })
+ const networkErrorCsrf = http.get('/index.php/csrftoken', () => {
+ return new HttpResponse(null, { type: 'error' })
+ })
+
+ beforeAll(() => {
+ server.listen()
+ })
+
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ it('correctly parses response', async () => {
+ server.use(successfullCsrf)
+
+ mockToken('oldToken')
+ const token = await fetchRequestToken()
+ expect(token).toBe('new-token')
+ })
+
+ it('sets the token', async () => {
+ server.use(successfullCsrf)
+
+ mockToken('oldToken')
+ await fetchRequestToken()
+ expect(getRequestToken()).toBe('new-token')
+ })
+
+ it('does emit an event', async () => {
+ server.use(successfullCsrf)
+
+ await fetchRequestToken()
+ expect(eventbus.emit).toHaveBeenCalledOnce()
+ expect(eventbus.emit).toBeCalledWith('csrf-token-update', { token: 'new-token' })
+ })
+
+ it('handles 403 error due to invalid cookies', async () => {
+ server.use(forbiddenCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
+ expect(getRequestToken()).toBe('oldToken')
+ })
+
+ it('handles server error', async () => {
+ server.use(serverErrorCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrowError('Could not fetch CSRF token from API')
+ expect(getRequestToken()).toBe('oldToken')
+ })
+
+ it('handles network error', async () => {
+ server.use(networkErrorCsrf)
+
+ mockToken('oldToken')
+ await expect(() => fetchRequestToken()).rejects.toThrow()
+ expect(getRequestToken()).toBe('oldToken')
+ })
+})
+
+/**
+ * Mock the request token directly so we can test reading it.
+ *
+ * @param token - The CSRF token to mock
+ */
+function mockToken(token?: string) {
+ if (token === undefined) {
+ delete document.head.dataset.requesttoken
+ } else {
+ document.head.dataset.requesttoken = token
+ }
+}
diff --git a/core/src/tests/OC/session-heartbeat.spec.ts b/core/src/tests/OC/session-heartbeat.spec.ts
new file mode 100644
index 00000000000..61b82d92887
--- /dev/null
+++ b/core/src/tests/OC/session-heartbeat.spec.ts
@@ -0,0 +1,123 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const requestToken = vi.hoisted(() => ({
+ fetchRequestToken: vi.fn<() => Promise<string>>(),
+ setRequestToken: vi.fn<(token: string) => void>(),
+}))
+vi.mock('../../OC/requesttoken.ts', () => requestToken)
+
+const initialState = vi.hoisted(() => ({ loadState: vi.fn() }))
+vi.mock('@nextcloud/initial-state', () => initialState)
+
+describe('Session heartbeat', () => {
+ beforeAll(() => {
+ vi.useFakeTimers()
+ })
+
+ beforeEach(() => {
+ vi.clearAllTimers()
+ vi.resetModules()
+ vi.resetAllMocks()
+ })
+
+ it('sends heartbeat half the session lifetime when heartbeat enabled', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 300,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // less than half, still nothing
+ await vi.advanceTimersByTimeAsync(100 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // reach past half, one call
+ await vi.advanceTimersByTimeAsync(60 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
+
+ // almost there to the next, still one
+ await vi.advanceTimersByTimeAsync(135 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(1)
+
+ // past it, second call
+ await vi.advanceTimersByTimeAsync(5 * 1000)
+ expect(requestToken.fetchRequestToken).toBeCalledTimes(2)
+ })
+
+ it('does not send heartbeat when heartbeat disabled', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: false,
+ session_lifetime: 300,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // less than half, still nothing
+ await vi.advanceTimersByTimeAsync(100 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // more than one, still nothing
+ await vi.advanceTimersByTimeAsync(300 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+ })
+
+ it('limit heartbeat to at least one minute', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 55,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // 30 / 55 seconds
+ await vi.advanceTimersByTimeAsync(30 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // 59 / 55 seconds should not be called except it does not limit
+ await vi.advanceTimersByTimeAsync(29 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // now one minute has passed
+ await vi.advanceTimersByTimeAsync(1000)
+ expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce()
+ })
+
+ it('limit heartbeat to at least one minute', async () => {
+ initialState.loadState.mockImplementationOnce(() => ({
+ session_keepalive: true,
+ session_lifetime: 50 * 60 * 60,
+ }))
+
+ const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
+ initSessionHeartBeat()
+
+ // initial state loaded
+ expect(initialState.loadState).toBeCalledWith('core', 'config', {})
+
+ // 23 hours
+ await vi.advanceTimersByTimeAsync(23 * 60 * 60 * 1000)
+ expect(requestToken.fetchRequestToken).not.toBeCalled()
+
+ // one day - it should be called now
+ await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
+ expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce()
+ })
+})
diff --git a/core/src/tests/components/ContactsMenu/Contact.spec.js b/core/src/tests/components/ContactsMenu/Contact.spec.js
new file mode 100644
index 00000000000..e83f75bfd15
--- /dev/null
+++ b/core/src/tests/components/ContactsMenu/Contact.spec.js
@@ -0,0 +1,44 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it } from 'vitest'
+import { shallowMount } from '@vue/test-utils'
+
+import Contact from '../../../components/ContactsMenu/Contact.vue'
+
+describe('Contact', function() {
+ it('links to the top action', () => {
+ const view = shallowMount(Contact, {
+ propsData: {
+ contact: {
+ id: null,
+ fullName: 'Acosta Lancaster',
+ topAction: {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:deboraoliver%40centrexin.com',
+ },
+ emailAddresses: [],
+ actions: [
+ {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:mathisholland%40virxo.com',
+ },
+ {
+ title: 'Details',
+ icon: 'icon-info',
+ hyperlink: 'https://localhost/index.php/apps/contacts',
+ },
+ ],
+ lastMessage: '',
+ },
+ },
+ })
+
+ expect(view.find('li a').exists()).toBe(true)
+ expect(view.find('li a').attributes('href')).toBe('mailto:deboraoliver%40centrexin.com')
+ })
+})
diff --git a/core/src/tests/setup.js b/core/src/tests/setup.js
deleted file mode 100644
index bec4e11eaea..00000000000
--- a/core/src/tests/setup.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-require('jsdom-global')()
-const chai = require('chai')
-var sinon = require('sinon')
-var sinonChai = require('sinon-chai');
-
-chai.use(sinonChai)
-global.expect = chai.expect
-global.sinon = sinon
-
-// https://github.com/vuejs/vue-test-utils/issues/936
-// better fix for "TypeError: Super expression must either be null or
-// a function" than pinning an old version of prettier.
-//
-// https://github.com/vuejs/vue-cli/issues/2128#issuecomment-453109575
-window.Date = Date
diff --git a/core/src/tests/views/ContactsMenu.spec.js b/core/src/tests/views/ContactsMenu.spec.js
new file mode 100644
index 00000000000..084c3215e47
--- /dev/null
+++ b/core/src/tests/views/ContactsMenu.spec.js
@@ -0,0 +1,143 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { mount, shallowMount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import ContactsMenu from '../../views/ContactsMenu.vue'
+
+const axios = vi.hoisted(() => ({
+ post: vi.fn(),
+}))
+vi.mock('@nextcloud/axios', () => ({ default: axios }))
+
+vi.mock('@nextcloud/auth', () => ({
+ getCurrentUser: () => ({ uid: 'user', isAdmin: false, displayName: 'User' }),
+}))
+
+describe('ContactsMenu', function() {
+ it('is closed by default', () => {
+ const view = shallowMount(ContactsMenu)
+
+ expect(view.vm.contacts).toEqual([])
+ expect(view.vm.loadingText).toBe(undefined)
+ })
+
+ it('shows a loading text', async () => {
+ const view = shallowMount(ContactsMenu)
+ axios.post.mockResolvedValue({
+ data: {
+ contacts: [],
+ contactsAppEnabled: false,
+ },
+ })
+
+ const opening = view.vm.handleOpen()
+
+ expect(view.vm.contacts).toEqual([])
+ expect(view.vm.loadingText).toBe('Loading your contacts …')
+ await opening
+ })
+
+ it('shows error view when contacts can not be loaded', async () => {
+ const view = mount(ContactsMenu)
+ axios.post.mockResolvedValue({})
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ try {
+ await view.vm.handleOpen()
+
+ throw new Error('should not be reached')
+ } catch (error) {
+ expect(console.error).toHaveBeenCalled()
+ console.error.mockRestore()
+ expect(view.vm.error).toBe(true)
+ expect(view.vm.contacts).toEqual([])
+ expect(view.text()).toContain('Could not load your contacts')
+ }
+ })
+
+ it('shows text when there are no contacts', async () => {
+ const view = mount(ContactsMenu)
+ axios.post.mockResolvedValueOnce({
+ data: {
+ contacts: [],
+ contactsAppEnabled: false,
+ },
+ })
+
+ await view.vm.handleOpen()
+
+ expect(view.vm.error).toBe(false)
+ expect(view.vm.contacts).toEqual([])
+ expect(view.vm.loadingText).toBe(undefined)
+ expect(view.text()).toContain('No contacts found')
+ })
+
+ it('shows contacts', async () => {
+ const view = mount(ContactsMenu)
+ axios.post.mockResolvedValue({
+ data: {
+ contacts: [
+ {
+ id: null,
+ fullName: 'Acosta Lancaster',
+ topAction: {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:deboraoliver%40centrexin.com',
+ },
+ actions: [
+ {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:mathisholland%40virxo.com',
+ },
+ {
+ title: 'Details',
+ icon: 'icon-info',
+ hyperlink: 'https://localhost/index.php/apps/contacts',
+ },
+ ],
+ lastMessage: '',
+ emailAddresses: [],
+ },
+ {
+ id: null,
+ fullName: 'Adeline Snider',
+ topAction: {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:ceciliasoto%40essensia.com',
+ },
+ actions: [
+ {
+ title: 'Mail',
+ icon: 'icon-mail',
+ hyperlink: 'mailto:pearliesellers%40inventure.com',
+ },
+ {
+ title: 'Details',
+ icon: 'icon-info',
+ hyperlink: 'https://localhost/index.php/apps/contacts',
+ },
+ ],
+ lastMessage: 'cu',
+ emailAddresses: [],
+ },
+ ],
+ contactsAppEnabled: true,
+ },
+ })
+
+ await view.vm.handleOpen()
+
+ expect(view.vm.error).toBe(false)
+ expect(view.vm.contacts.length).toBe(2)
+ expect(view.text()).toContain('Acosta Lancaster')
+ expect(view.text()).toContain('Adeline Snider')
+ expect(view.text()).toContain('Show all contacts')
+ })
+})
diff --git a/core/src/twofactor-request-token.ts b/core/src/twofactor-request-token.ts
new file mode 100644
index 00000000000..868ceec01e9
--- /dev/null
+++ b/core/src/twofactor-request-token.ts
@@ -0,0 +1,25 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { onRequestTokenUpdate } from '@nextcloud/auth'
+import { getBaseUrl } from '@nextcloud/router'
+
+document.addEventListener('DOMContentLoaded', () => {
+ onRequestTokenUpdate((token) => {
+ const cancelLink = window.document.getElementById('cancel-login')
+ if (!cancelLink) {
+ return
+ }
+
+ const href = cancelLink.getAttribute('href')
+ if (!href) {
+ return
+ }
+
+ const parsedHref = new URL(href, getBaseUrl())
+ parsedHref.searchParams.set('requesttoken', token)
+ cancelLink.setAttribute('href', parsedHref.pathname + parsedHref.search)
+ })
+})
diff --git a/core/src/types/navigation.d.ts b/core/src/types/navigation.d.ts
new file mode 100644
index 00000000000..5698aab205e
--- /dev/null
+++ b/core/src/types/navigation.d.ts
@@ -0,0 +1,30 @@
+/*!
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/** See NavigationManager */
+export interface INavigationEntry {
+ /** Navigation id */
+ id: string
+ /** If this is the currently active app */
+ active: boolean
+ /** Order where this entry should be shown */
+ order: number
+ /** Target of the navigation entry */
+ href: string
+ /** The icon used for the naviation entry */
+ icon: string
+ /** Type of the navigation entry ('link' vs 'settings') */
+ type: 'link' | 'settings'
+ /** Localized name of the navigation entry */
+ name: string
+ /** Whether this is the default app */
+ default?: boolean
+ /** App that registered this navigation entry (not necessarly the same as the id) */
+ app?: string
+ /** If this app has unread notification */
+ unread: number
+ /** True when the link should be opened in a new tab */
+ target?: boolean
+}
diff --git a/core/src/unified-search.js b/core/src/unified-search.js
deleted file mode 100644
index 0d977383586..00000000000
--- a/core/src/unified-search.js
+++ /dev/null
@@ -1,58 +0,0 @@
-/**
- * @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @author John Molakvoæ <skjnldsv@protonmail.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- */
-
-import { generateFilePath } from '@nextcloud/router'
-import { getLoggerBuilder } from '@nextcloud/logger'
-import { getRequestToken } from '@nextcloud/auth'
-import { translate as t, translatePlural as n } from '@nextcloud/l10n'
-import Vue from 'vue'
-
-import UnifiedSearch from './views/UnifiedSearch.vue'
-
-// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
-
-// eslint-disable-next-line camelcase
-__webpack_public_path__ = generateFilePath('core', '', 'js/')
-
-const logger = getLoggerBuilder()
- .setApp('unified-search')
- .detectUser()
- .build()
-
-Vue.mixin({
- data() {
- return {
- logger,
- }
- },
- methods: {
- t,
- n,
- },
-})
-
-export default new Vue({
- el: '#unified-search',
- // eslint-disable-next-line vue/match-component-file-name
- name: 'UnifiedSearchRoot',
- render: h => h(UnifiedSearch),
-})
diff --git a/core/src/unified-search.ts b/core/src/unified-search.ts
new file mode 100644
index 00000000000..a13b1036da1
--- /dev/null
+++ b/core/src/unified-search.ts
@@ -0,0 +1,63 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+import { getCSPNonce } from '@nextcloud/auth'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+import { createPinia, PiniaVuePlugin } from 'pinia'
+import Vue from 'vue'
+
+import UnifiedSearch from './views/UnifiedSearch.vue'
+import { useSearchStore } from '../src/store/unified-search-external-filters.js'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+const logger = getLoggerBuilder()
+ .setApp('unified-search')
+ .detectUser()
+ .build()
+
+Vue.mixin({
+ data() {
+ return {
+ logger,
+ }
+ },
+ methods: {
+ t,
+ n,
+ },
+})
+
+// Define type structure for unified searc action
+interface UnifiedSearchAction {
+ id: string;
+ appId: string;
+ searchFrom: string;
+ label: string;
+ icon: string;
+ callback: () => void;
+}
+
+// Register the add/register filter action API globally
+window.OCA = window.OCA || {}
+window.OCA.UnifiedSearch = {
+ registerFilterAction: ({ id, appId, searchFrom, label, callback, icon }: UnifiedSearchAction) => {
+ const searchStore = useSearchStore()
+ searchStore.registerExternalFilter({ id, appId, searchFrom, label, callback, icon })
+ },
+}
+
+Vue.use(PiniaVuePlugin)
+const pinia = createPinia()
+
+export default new Vue({
+ el: '#unified-search',
+ pinia,
+ // eslint-disable-next-line vue/match-component-file-name
+ name: 'UnifiedSearchRoot',
+ render: h => h(UnifiedSearch),
+})
diff --git a/core/src/unsupported-browser-redirect.js b/core/src/unsupported-browser-redirect.js
new file mode 100644
index 00000000000..64620afa085
--- /dev/null
+++ b/core/src/unsupported-browser-redirect.js
@@ -0,0 +1,16 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+if (!window.TESTING && !OC?.config?.no_unsupported_browser_warning) {
+ window.addEventListener('DOMContentLoaded', async function() {
+ const { testSupportedBrowser } = await import('./utils/RedirectUnsupportedBrowsers.js')
+ testSupportedBrowser()
+ })
+}
diff --git a/core/src/unsupported-browser.js b/core/src/unsupported-browser.js
new file mode 100644
index 00000000000..d54b1c8fb24
--- /dev/null
+++ b/core/src/unsupported-browser.js
@@ -0,0 +1,23 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { generateUrl } from '@nextcloud/router'
+import Vue from 'vue'
+
+import { browserStorageKey } from './utils/RedirectUnsupportedBrowsers.js'
+import browserStorage from './services/BrowserStorageService.js'
+import UnsupportedBrowser from './views/UnsupportedBrowser.vue'
+
+// If the ignore token is set, redirect
+if (browserStorage.getItem(browserStorageKey) === 'true') {
+ window.location = generateUrl('/')
+}
+
+export default new Vue({
+ el: '#unsupported-browser',
+ // eslint-disable-next-line vue/match-component-file-name
+ name: 'UnsupportedBrowserRoot',
+ render: h => h(UnsupportedBrowser),
+})
diff --git a/core/src/utils/ClipboardFallback.ts b/core/src/utils/ClipboardFallback.ts
new file mode 100644
index 00000000000..b374f9d0a44
--- /dev/null
+++ b/core/src/utils/ClipboardFallback.ts
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { t } from '@nextcloud/l10n'
+
+/**
+ *
+ * @param text
+ */
+function unsecuredCopyToClipboard(text) {
+ const textArea = document.createElement('textarea')
+ const textAreaContent = document.createTextNode(text)
+ textArea.appendChild(textAreaContent)
+ document.body.appendChild(textArea)
+
+ textArea.focus({ preventScroll: true })
+ textArea.select()
+
+ try {
+ // This is a fallback for browsers that do not support the Clipboard API
+ // execCommand is deprecated, but it is the only way to copy text to the clipboard in some browsers
+ document.execCommand('copy')
+ } catch (err) {
+ window.prompt(t('core', 'Clipboard not available, please copy manually'), text)
+ console.error('[ERROR] core: files Unable to copy to clipboard', err)
+ }
+
+ document.body.removeChild(textArea)
+}
+
+/**
+ *
+ */
+function initFallbackClipboardAPI() {
+ if (!window.navigator?.clipboard?.writeText) {
+ console.info('[INFO] core: Clipboard API not available, using fallback')
+ Object.defineProperty(window.navigator, 'clipboard', {
+ value: {
+ writeText: unsecuredCopyToClipboard,
+ },
+ writable: false,
+ })
+ }
+}
+
+export { initFallbackClipboardAPI }
diff --git a/core/src/utils/RedirectUnsupportedBrowsers.js b/core/src/utils/RedirectUnsupportedBrowsers.js
new file mode 100644
index 00000000000..2880d051ca2
--- /dev/null
+++ b/core/src/utils/RedirectUnsupportedBrowsers.js
@@ -0,0 +1,40 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { generateUrl } from '@nextcloud/router'
+import { supportedBrowsersRegExp } from '../services/BrowsersListService.js'
+import browserStorage from '../services/BrowserStorageService.js'
+import logger from '../logger.js'
+
+export const browserStorageKey = 'unsupported-browser-ignore'
+const redirectPath = generateUrl('/unsupported')
+
+const isBrowserOverridden = browserStorage.getItem(browserStorageKey) === 'true'
+
+/**
+ * Test the current browser user agent against our official browserslist config
+ * and redirect if unsupported
+ */
+export const testSupportedBrowser = function() {
+ if (supportedBrowsersRegExp.test(navigator.userAgent)) {
+ logger.debug('this browser is officially supported ! 🚀')
+ return
+ }
+
+ // If incompatible BUT ignored, let's keep going
+ if (isBrowserOverridden) {
+ logger.debug('this browser is NOT supported but has been manually overridden ! ⚠️')
+ return
+ }
+
+ // If incompatible, NOT overridden AND NOT already on the warning page,
+ // redirect to the unsupported warning page
+ if (window.location.pathname.indexOf(redirectPath) === -1) {
+ const redirectUrl = window.location.href.replace(window.location.origin, '')
+ const base64Param = Buffer.from(redirectUrl).toString('base64')
+ history.pushState(null, null, `${redirectPath}?redirect_url=${base64Param}`)
+ window.location.reload()
+ }
+}
diff --git a/core/src/utils/xhr-request.js b/core/src/utils/xhr-request.js
new file mode 100644
index 00000000000..7f074a857a6
--- /dev/null
+++ b/core/src/utils/xhr-request.js
@@ -0,0 +1,133 @@
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCurrentUser } from '@nextcloud/auth'
+import { generateUrl, getRootUrl } from '@nextcloud/router'
+import logger from '../logger.js'
+
+/**
+ *
+ * @param {string} url the URL to check
+ * @return {boolean}
+ */
+const isRelativeUrl = (url) => {
+ return !url.startsWith('https://') && !url.startsWith('http://')
+}
+
+/**
+ * @param {string} url The URL to check
+ * @return {boolean} true if the URL points to this nextcloud instance
+ */
+const isNextcloudUrl = (url) => {
+ const nextcloudBaseUrl = window.location.protocol + '//' + window.location.host + getRootUrl()
+ // if the URL is absolute and starts with the baseUrl+rootUrl
+ // OR if the URL is relative and starts with rootUrl
+ return url.startsWith(nextcloudBaseUrl)
+ || (isRelativeUrl(url) && url.startsWith(getRootUrl()))
+}
+
+/**
+ * Check if a user was logged in but is now logged-out.
+ * If this is the case then the user will be forwarded to the login page.
+ * @return {Promise<void>}
+ */
+async function checkLoginStatus() {
+ // skip if no logged in user
+ if (getCurrentUser() === null) {
+ return
+ }
+
+ // skip if already running
+ if (checkLoginStatus.running === true) {
+ return
+ }
+
+ // only run one request in parallel
+ checkLoginStatus.running = true
+
+ try {
+ // We need to check this as a 401 in the first place could also come from other reasons
+ const { status } = await window.fetch(generateUrl('/apps/files'))
+ if (status === 401) {
+ console.warn('User session was terminated, forwarding to login page.')
+ await wipeBrowserStorages()
+ window.location = generateUrl('/login?redirect_url={url}', {
+ url: window.location.pathname + window.location.search + window.location.hash,
+ })
+ }
+ } catch (error) {
+ console.warn('Could not check login-state')
+ } finally {
+ delete checkLoginStatus.running
+ }
+}
+
+/**
+ * Clear all Browser storages connected to current origin.
+ * @return {Promise<void>}
+ */
+export async function wipeBrowserStorages() {
+ try {
+ window.localStorage.clear()
+ window.sessionStorage.clear()
+ const indexedDBList = await window.indexedDB.databases()
+ for (const indexedDB of indexedDBList) {
+ await window.indexedDB.deleteDatabase(indexedDB.name)
+ }
+ logger.debug('Browser storages cleared')
+ } catch (error) {
+ logger.error('Could not clear browser storages', { error })
+ }
+}
+
+/**
+ * Intercept XMLHttpRequest and fetch API calls to add X-Requested-With header
+ *
+ * This is also done in @nextcloud/axios but not all requests pass through that
+ */
+export const interceptRequests = () => {
+ XMLHttpRequest.prototype.open = (function(open) {
+ return function(method, url, async) {
+ open.apply(this, arguments)
+ if (isNextcloudUrl(url)) {
+ if (!this.getResponseHeader('X-Requested-With')) {
+ this.setRequestHeader('X-Requested-With', 'XMLHttpRequest')
+ }
+ this.addEventListener('loadend', function() {
+ if (this.status === 401) {
+ checkLoginStatus()
+ }
+ })
+ }
+ }
+ })(XMLHttpRequest.prototype.open)
+
+ window.fetch = (function(fetch) {
+ return async (resource, options) => {
+ // fetch allows the `input` to be either a Request object or any stringifyable value
+ if (!isNextcloudUrl(resource.url ?? resource.toString())) {
+ return await fetch(resource, options)
+ }
+ if (!options) {
+ options = {}
+ }
+ if (!options.headers) {
+ options.headers = new Headers()
+ }
+
+ if (options.headers instanceof Headers && !options.headers.has('X-Requested-With')) {
+ options.headers.append('X-Requested-With', 'XMLHttpRequest')
+ } else if (options.headers instanceof Object && !options.headers['X-Requested-With']) {
+ options.headers['X-Requested-With'] = 'XMLHttpRequest'
+ }
+
+ const response = await fetch(resource, options)
+ if (response.status === 401) {
+ checkLoginStatus()
+ }
+ return response
+ }
+ })(window.fetch)
+}
diff --git a/core/src/views/AccountMenu.vue b/core/src/views/AccountMenu.vue
new file mode 100644
index 00000000000..5b7ead636bd
--- /dev/null
+++ b/core/src/views/AccountMenu.vue
@@ -0,0 +1,247 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcHeaderMenu id="user-menu"
+ class="account-menu"
+ is-nav
+ :aria-label="t('core', 'Settings menu')"
+ :description="avatarDescription">
+ <template #trigger>
+ <!-- The `key` is a hack as NcAvatar does not handle updating the preloaded status on show status change -->
+ <NcAvatar :key="String(showUserStatus)"
+ class="account-menu__avatar"
+ disable-menu
+ disable-tooltip
+ :show-user-status="showUserStatus"
+ :user="currentUserId"
+ :preloaded-user-status="userStatus" />
+ </template>
+ <ul class="account-menu__list">
+ <AccountMenuProfileEntry :id="profileEntry.id"
+ :name="profileEntry.name"
+ :href="profileEntry.href"
+ :active="profileEntry.active" />
+ <AccountMenuEntry v-for="entry in otherEntries"
+ :id="entry.id"
+ :key="entry.id"
+ :name="entry.name"
+ :href="entry.href"
+ :active="entry.active"
+ :icon="entry.icon" />
+ </ul>
+ </NcHeaderMenu>
+</template>
+
+<script lang="ts">
+import { getCurrentUser } from '@nextcloud/auth'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { getCapabilities } from '@nextcloud/capabilities'
+import { defineComponent } from 'vue'
+import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js'
+
+import axios from '@nextcloud/axios'
+import logger from '../logger.js'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import AccountMenuProfileEntry from '../components/AccountMenu/AccountMenuProfileEntry.vue'
+import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
+
+interface ISettingsNavigationEntry {
+ /**
+ * id of the entry, used as HTML ID, for example, "settings"
+ */
+ id: string
+ /**
+ * Label of the entry, for example, "Personal Settings"
+ */
+ name: string
+ /**
+ * Icon of the entry, for example, "/apps/settings/img/personal.svg"
+ */
+ icon: string
+ /**
+ * Type of the entry
+ */
+ type: 'settings'|'link'|'guest'
+ /**
+ * Link of the entry, for example, "/settings/user"
+ */
+ href: string
+ /**
+ * Whether the entry is active
+ */
+ active: boolean
+ /**
+ * Order of the entry
+ */
+ order: number
+ /**
+ * Number of unread pf this items
+ */
+ unread: number
+ /**
+ * Classes for custom styling
+ */
+ classes: string
+}
+
+const USER_DEFINABLE_STATUSES = getAllStatusOptions()
+
+export default defineComponent({
+ name: 'AccountMenu',
+
+ components: {
+ AccountMenuEntry,
+ AccountMenuProfileEntry,
+ NcAvatar,
+ NcHeaderMenu,
+ },
+
+ setup() {
+ const settingsNavEntries = loadState<Record<string, ISettingsNavigationEntry>>('core', 'settingsNavEntries', {})
+ const { profile: profileEntry, ...otherEntries } = settingsNavEntries
+
+ return {
+ currentDisplayName: getCurrentUser()?.displayName ?? getCurrentUser()!.uid,
+ currentUserId: getCurrentUser()!.uid,
+
+ profileEntry,
+ otherEntries,
+
+ t,
+ }
+ },
+
+ data() {
+ return {
+ showUserStatus: false,
+ userStatus: {
+ status: null,
+ icon: null,
+ message: null,
+ },
+ }
+ },
+
+ computed: {
+ translatedUserStatus() {
+ return {
+ ...this.userStatus,
+ status: this.translateStatus(this.userStatus.status),
+ }
+ },
+
+ avatarDescription() {
+ const description = [
+ t('core', 'Avatar of {displayName}', { displayName: this.currentDisplayName }),
+ ...Object.values(this.translatedUserStatus).filter(Boolean),
+ ].join(' — ')
+ return description
+ },
+ },
+
+ async created() {
+ if (!getCapabilities()?.user_status?.enabled) {
+ return
+ }
+
+ const url = generateOcsUrl('/apps/user_status/api/v1/user_status')
+ try {
+ const response = await axios.get(url)
+ const { status, icon, message } = response.data.ocs.data
+ this.userStatus = { status, icon, message }
+ } catch (e) {
+ logger.error('Failed to load user status')
+ }
+ this.showUserStatus = true
+ },
+
+ mounted() {
+ subscribe('user_status:status.updated', this.handleUserStatusUpdated)
+ emit('core:user-menu:mounted')
+ },
+
+ methods: {
+ handleUserStatusUpdated(state) {
+ if (this.currentUserId === state.userId) {
+ this.userStatus = {
+ status: state.status,
+ icon: state.icon,
+ message: state.message,
+ }
+ }
+ },
+
+ translateStatus(status) {
+ const statusMap = Object.fromEntries(
+ USER_DEFINABLE_STATUSES.map(({ type, label }) => [type, label]),
+ )
+ if (statusMap[status]) {
+ return statusMap[status]
+ }
+ return status
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+:deep(#header-menu-user-menu) {
+ padding: 0 !important;
+}
+
+.account-menu {
+ &__avatar {
+ --account-menu-outline: var(--border-width-input) solid color-mix(in srgb, var(--color-background-plain-text), transparent 75%);
+ outline: var(--account-menu-outline);
+ position: fixed;
+ // do not apply the alpha mask on the avatar div
+ mask: none !important;
+
+ &:hover {
+ --account-menu-outline: none;
+ // Add hover styles similar to the focus-visible style
+ border: var(--border-width-input-focused) solid var(--color-background-plain-text);
+ }
+ }
+
+ &__list {
+ display: inline-flex;
+ flex-direction: column;
+ padding-block: var(--default-grid-baseline) 0;
+ padding-inline: 0 var(--default-grid-baseline);
+
+ > :deep(li) {
+ box-sizing: border-box;
+ // basically "fit-content"
+ flex: 0 1;
+ }
+ }
+
+ // Ensure we do not waste space, as the header menu sets a default width of 350px
+ :deep(.header-menu__content) {
+ width: fit-content !important;
+ }
+
+ :deep(button) {
+ // Normally header menus are slightly translucent when not active
+ // this is generally ok but for the avatar this is weird so fix the opacity
+ opacity: 1 !important;
+
+ // The avatar is just the "icon" of the button
+ // So we add the focus-visible manually
+ &:focus-visible {
+ .account-menu__avatar {
+ --account-menu-outline: none;
+ border: var(--border-width-input-focused) solid var(--color-background-plain-text);
+ }
+ }
+ }
+}
+</style>
diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue
new file mode 100644
index 00000000000..924ddcea56b
--- /dev/null
+++ b/core/src/views/ContactsMenu.vue
@@ -0,0 +1,233 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <NcHeaderMenu id="contactsmenu"
+ class="contactsmenu"
+ :aria-label="t('core', 'Search contacts')"
+ @open="handleOpen">
+ <template #trigger>
+ <NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
+ </template>
+ <div class="contactsmenu__menu">
+ <div class="contactsmenu__menu__input-wrapper">
+ <NcTextField id="contactsmenu__menu__search"
+ ref="contactsMenuInput"
+ :value.sync="searchTerm"
+ trailing-button-icon="close"
+ :label="t('core', 'Search contacts')"
+ :trailing-button-label="t('core','Reset search')"
+ :show-trailing-button="searchTerm !== ''"
+ :placeholder="t('core', 'Search contacts …')"
+ class="contactsmenu__menu__search"
+ @input="onInputDebounced"
+ @trailing-button-click="onReset" />
+ </div>
+ <NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcEmptyContent>
+ <NcEmptyContent v-else-if="loadingText" :name="loadingText">
+ <template #icon>
+ <NcLoadingIcon />
+ </template>
+ </NcEmptyContent>
+ <NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcEmptyContent>
+ <div v-else class="contactsmenu__menu__content">
+ <div id="contactsmenu-contacts">
+ <ul>
+ <Contact v-for="contact in contacts" :key="contact.id" :contact="contact" />
+ </ul>
+ </div>
+ <div v-if="contactsAppEnabled" class="contactsmenu__menu__content__footer">
+ <NcButton type="tertiary" :href="contactsAppURL">
+ {{ t('core', 'Show all contacts') }}
+ </NcButton>
+ </div>
+ <div v-else-if="canInstallApp" class="contactsmenu__menu__content__footer">
+ <NcButton type="tertiary" :href="contactsAppMgmtURL">
+ {{ t('core', 'Install the Contacts app') }}
+ </NcButton>
+ </div>
+ </div>
+ </div>
+ </NcHeaderMenu>
+</template>
+
+<script>
+import { mdiContacts, mdiMagnify } from '@mdi/js'
+import { generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { t } from '@nextcloud/l10n'
+import axios from '@nextcloud/axios'
+import debounce from 'debounce'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import Contact from '../components/ContactsMenu/Contact.vue'
+import logger from '../logger.js'
+import Nextcloud from '../mixins/Nextcloud.js'
+
+export default {
+ name: 'ContactsMenu',
+
+ components: {
+ Contact,
+ NcButton,
+ NcEmptyContent,
+ NcHeaderMenu,
+ NcIconSvgWrapper,
+ NcLoadingIcon,
+ NcTextField,
+ },
+
+ mixins: [Nextcloud],
+
+ setup() {
+ return {
+ mdiContacts,
+ mdiMagnify,
+ }
+ },
+
+ data() {
+ const user = getCurrentUser()
+ return {
+ contactsAppEnabled: false,
+ contactsAppURL: generateUrl('/apps/contacts'),
+ contactsAppMgmtURL: generateUrl('/settings/apps/social/contacts'),
+ canInstallApp: user.isAdmin,
+ contacts: [],
+ loadingText: undefined,
+ error: false,
+ searchTerm: '',
+ }
+ },
+
+ methods: {
+ async handleOpen() {
+ await this.getContacts('')
+ },
+ async getContacts(searchTerm) {
+ if (searchTerm === '') {
+ this.loadingText = t('core', 'Loading your contacts …')
+ } else {
+ this.loadingText = t('core', 'Looking for {term} …', {
+ term: searchTerm,
+ })
+ }
+
+ // Let the user try a different query if the previous one failed
+ this.error = false
+
+ try {
+ const { data: { contacts, contactsAppEnabled } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
+ filter: searchTerm,
+ })
+ this.contacts = contacts
+ this.contactsAppEnabled = contactsAppEnabled
+ this.loadingText = undefined
+ } catch (error) {
+ logger.error('could not load contacts', {
+ error,
+ searchTerm,
+ })
+ this.error = true
+ }
+ },
+ onInputDebounced: debounce(function() {
+ this.getContacts(this.searchTerm)
+ }, 500),
+
+ /**
+ * Reset the search state
+ */
+ onReset() {
+ this.searchTerm = ''
+ this.contacts = []
+ this.focusInput()
+ },
+
+ /**
+ * Focus the search input on next tick
+ */
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.contactsMenuInput.focus()
+ this.$refs.contactsMenuInput.select()
+ })
+ },
+
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.contactsmenu {
+ overflow-y: hidden;
+
+ &__trigger-icon {
+ color: var(--color-background-plain-text) !important;
+ }
+
+ &__menu {
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: calc(50px * 6 + 2px + 26px);
+ max-height: inherit;
+
+ label[for="contactsmenu__menu__search"] {
+ font-weight: bold;
+ font-size: 19px;
+ margin-inline-start: 13px;
+ }
+
+ &__input-wrapper {
+ padding: 10px;
+ z-index: 2;
+ top: 0;
+ }
+
+ &__search {
+ width: 100%;
+ height: 34px;
+ margin-top: 0!important;
+ }
+
+ &__content {
+ overflow-y: auto;
+ margin-top: 10px;
+ flex: 1 1 auto;
+
+ &__footer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+ }
+
+ a {
+ &:focus-visible {
+ box-shadow: inset 0 0 0 2px var(--color-main-text) !important; // override rule in core/css/headers.scss #header a:focus-visible
+ }
+ }
+ }
+
+ :deep(.empty-content) {
+ margin: 0 !important;
+ }
+}
+</style>
diff --git a/core/src/views/LegacyUnifiedSearch.vue b/core/src/views/LegacyUnifiedSearch.vue
new file mode 100644
index 00000000000..1277970ba0e
--- /dev/null
+++ b/core/src/views/LegacyUnifiedSearch.vue
@@ -0,0 +1,848 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcHeaderMenu id="unified-search"
+ class="unified-search"
+ :exclude-click-outside-selectors="['.popover']"
+ :open.sync="open"
+ :aria-label="ariaLabel"
+ @open="onOpen"
+ @close="onClose">
+ <!-- Header icon -->
+ <template #trigger>
+ <Magnify class="unified-search__trigger-icon" :size="20" />
+ </template>
+
+ <!-- Search form & filters wrapper -->
+ <div class="unified-search__input-wrapper">
+ <div class="unified-search__input-row">
+ <NcTextField ref="input"
+ :value.sync="query"
+ trailing-button-icon="close"
+ :label="ariaLabel"
+ :trailing-button-label="t('core','Reset search')"
+ :show-trailing-button="query !== ''"
+ aria-describedby="unified-search-desc"
+ class="unified-search__form-input"
+ :class="{'unified-search__form-input--with-reset': !!query}"
+ :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
+ @trailing-button-click="onReset"
+ @input="onInputDebounced" />
+ <p id="unified-search-desc" class="hidden-visually">
+ {{ t('core', 'Search starts once you start typing and results may be reached with the arrow keys') }}
+ </p>
+
+ <!-- Search filters -->
+ <NcActions v-if="availableFilters.length > 1"
+ class="unified-search__filters"
+ placement="bottom-end"
+ container=".unified-search__input-wrapper">
+ <!-- FIXME use element ref for container after https://github.com/nextcloud/nextcloud-vue/pull/3462 -->
+ <NcActionButton v-for="filter in availableFilters"
+ :key="filter"
+ icon="icon-filter"
+ @click.stop="onClickFilter(`in:${filter}`)">
+ {{ t('core', 'Search for {name} only', { name: typesMap[filter] }) }}
+ </NcActionButton>
+ </NcActions>
+ </div>
+ </div>
+
+ <template v-if="!hasResults">
+ <!-- Loading placeholders -->
+ <SearchResultPlaceholders v-if="isLoading" />
+
+ <NcEmptyContent v-else-if="isValidQuery"
+ :title="validQueryTitle">
+ <template #icon>
+ <Magnify />
+ </template>
+ </NcEmptyContent>
+
+ <NcEmptyContent v-else-if="!isLoading || isShortQuery"
+ :title="t('core', 'Start typing to search')"
+ :description="shortQueryDescription">
+ <template #icon>
+ <Magnify />
+ </template>
+ </NcEmptyContent>
+ </template>
+
+ <!-- Grouped search results -->
+ <template v-for="({list, type}, typesIndex) in orderedResults" v-else>
+ <h2 :key="type" class="unified-search__results-header">
+ {{ typesMap[type] }}
+ </h2>
+ <ul :key="type"
+ class="unified-search__results"
+ :class="`unified-search__results-${type}`"
+ :aria-label="typesMap[type]">
+ <!-- Search results -->
+ <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
+ <SearchResult v-bind="result"
+ :query="query"
+ :focused="focused === 0 && typesIndex === 0 && index === 0"
+ @focus="setFocusedIndex" />
+ </li>
+
+ <!-- Load more button -->
+ <li>
+ <SearchResult v-if="!reached[type]"
+ class="unified-search__result-more"
+ :title="loading[type]
+ ? t('core', 'Loading more results …')
+ : t('core', 'Load more results')"
+ :icon-class="loading[type] ? 'icon-loading-small' : ''"
+ @click.prevent.stop="loadMore(type)"
+ @focus="setFocusedIndex" />
+ </li>
+ </ul>
+ </template>
+ </NcHeaderMenu>
+</template>
+
+<script>
+import debounce from 'debounce'
+import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
+import { showError } from '@nextcloud/dialogs'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import Magnify from 'vue-material-design-icons/Magnify.vue'
+
+import SearchResult from '../components/UnifiedSearch/LegacySearchResult.vue'
+import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders.vue'
+
+import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot, enableLiveSearch } from '../services/LegacyUnifiedSearchService.js'
+
+const REQUEST_FAILED = 0
+const REQUEST_OK = 1
+const REQUEST_CANCELED = 2
+
+export default {
+ name: 'LegacyUnifiedSearch',
+
+ components: {
+ Magnify,
+ NcActionButton,
+ NcActions,
+ NcEmptyContent,
+ NcHeaderMenu,
+ SearchResult,
+ SearchResultPlaceholders,
+ NcTextField,
+ },
+
+ data() {
+ return {
+ types: [],
+
+ // Cursors per types
+ cursors: {},
+ // Various search limits per types
+ limits: {},
+ // Loading types
+ loading: {},
+ // Reached search types
+ reached: {},
+ // Pending cancellable requests
+ requests: [],
+ // List of all results
+ results: {},
+
+ query: '',
+ focused: null,
+ triggered: false,
+
+ defaultLimit,
+ minSearchLength,
+ enableLiveSearch,
+
+ open: false,
+ }
+ },
+
+ computed: {
+ typesIDs() {
+ return this.types.map(type => type.id)
+ },
+ typesNames() {
+ return this.types.map(type => type.name)
+ },
+ typesMap() {
+ return this.types.reduce((prev, curr) => {
+ prev[curr.id] = curr.name
+ return prev
+ }, {})
+ },
+
+ ariaLabel() {
+ return t('core', 'Search')
+ },
+
+ /**
+ * Is there any result to display
+ *
+ * @return {boolean}
+ */
+ hasResults() {
+ return Object.keys(this.results).length !== 0
+ },
+
+ /**
+ * Return ordered results
+ *
+ * @return {Array}
+ */
+ orderedResults() {
+ return this.typesIDs
+ .filter(type => type in this.results)
+ .map(type => ({
+ type,
+ list: this.results[type],
+ }))
+ },
+
+ /**
+ * Available filters
+ * We only show filters that are available on the results
+ *
+ * @return {string[]}
+ */
+ availableFilters() {
+ return Object.keys(this.results)
+ },
+
+ /**
+ * Applied filters
+ *
+ * @return {string[]}
+ */
+ usedFiltersIn() {
+ let match
+ const filters = []
+ while ((match = regexFilterIn.exec(this.query)) !== null) {
+ filters.push(match[2])
+ }
+ return filters
+ },
+
+ /**
+ * Applied anti filters
+ *
+ * @return {string[]}
+ */
+ usedFiltersNot() {
+ let match
+ const filters = []
+ while ((match = regexFilterNot.exec(this.query)) !== null) {
+ filters.push(match[2])
+ }
+ return filters
+ },
+
+ /**
+ * Valid query empty content title
+ *
+ * @return {string}
+ */
+ validQueryTitle() {
+ return this.triggered
+ ? t('core', 'No results for {query}', { query: this.query })
+ : t('core', 'Press Enter to start searching')
+ },
+
+ /**
+ * Short query empty content description
+ *
+ * @return {string}
+ */
+ shortQueryDescription() {
+ if (!this.isShortQuery) {
+ return ''
+ }
+
+ return n('core',
+ 'Please enter {minSearchLength} character or more to search',
+ 'Please enter {minSearchLength} characters or more to search',
+ this.minSearchLength,
+ { minSearchLength: this.minSearchLength })
+ },
+
+ /**
+ * Is the current search too short
+ *
+ * @return {boolean}
+ */
+ isShortQuery() {
+ return this.query && this.query.trim().length < minSearchLength
+ },
+
+ /**
+ * Is the current search valid
+ *
+ * @return {boolean}
+ */
+ isValidQuery() {
+ return this.query && this.query.trim() !== '' && !this.isShortQuery
+ },
+
+ /**
+ * Have we reached the end of all types searches
+ *
+ * @return {boolean}
+ */
+ isDoneSearching() {
+ return Object.values(this.reached).every(state => state === false)
+ },
+
+ /**
+ * Is there any search in progress
+ *
+ * @return {boolean}
+ */
+ isLoading() {
+ return Object.values(this.loading).some(state => state === true)
+ },
+ },
+
+ async created() {
+ this.types = await getTypes()
+ this.logger.debug('Unified Search initialized with the following providers', this.types)
+ },
+
+ beforeDestroy() {
+ unsubscribe('files:navigation:changed', this.onNavigationChange)
+ },
+
+ mounted() {
+ // subscribe in mounted, as onNavigationChange relys on $el
+ subscribe('files:navigation:changed', this.onNavigationChange)
+
+ if (OCP.Accessibility.disableKeyboardShortcuts()) {
+ return
+ }
+
+ document.addEventListener('keydown', (event) => {
+ // if not already opened, allows us to trigger default browser on second keydown
+ if (event.ctrlKey && event.code === 'KeyF' && !this.open) {
+ event.preventDefault()
+ this.open = true
+ } else if (event.ctrlKey && event.key === 'f' && this.open) {
+ // User wants to use the native browser search, so we close ours again
+ this.open = false
+ }
+
+ // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
+ if (this.open) {
+ // If arrow down, focus next result
+ if (event.key === 'ArrowDown') {
+ this.focusNext(event)
+ }
+
+ // If arrow up, focus prev result
+ if (event.key === 'ArrowUp') {
+ this.focusPrev(event)
+ }
+ }
+ })
+ },
+
+ methods: {
+ async onOpen() {
+ // Update types list in the background
+ this.types = await getTypes()
+ },
+ onClose() {
+ emit('nextcloud:unified-search.close')
+ },
+
+ onNavigationChange() {
+ this.$el?.querySelector?.('form[role="search"]')?.reset?.()
+ },
+
+ /**
+ * Reset the search state
+ */
+ onReset() {
+ emit('nextcloud:unified-search.reset')
+ this.logger.debug('Search reset')
+ this.query = ''
+ this.resetState()
+ this.focusInput()
+ },
+ async resetState() {
+ this.cursors = {}
+ this.limits = {}
+ this.reached = {}
+ this.results = {}
+ this.focused = null
+ this.triggered = false
+ await this.cancelPendingRequests()
+ },
+
+ /**
+ * Cancel any ongoing searches
+ */
+ async cancelPendingRequests() {
+ // Cloning so we can keep processing other requests
+ const requests = this.requests.slice(0)
+ this.requests = []
+
+ // Cancel all pending requests
+ await Promise.all(requests.map(cancel => cancel()))
+ },
+
+ /**
+ * Focus the search input on next tick
+ */
+ focusInput() {
+ this.$nextTick(() => {
+ this.$refs.input.focus()
+ this.$refs.input.select()
+ })
+ },
+
+ /**
+ * If we have results already, open first one
+ * If not, trigger the search again
+ */
+ onInputEnter() {
+ if (this.hasResults) {
+ const results = this.getResultsList()
+ results[0].click()
+ return
+ }
+ this.onInput()
+ },
+
+ /**
+ * Start searching on input
+ */
+ async onInput() {
+ // emit the search query
+ emit('nextcloud:unified-search.search', { query: this.query })
+
+ // Do not search if not long enough
+ if (this.query.trim() === '' || this.isShortQuery) {
+ for (const type of this.typesIDs) {
+ this.$delete(this.results, type)
+ }
+ return
+ }
+
+ let types = this.typesIDs
+ let query = this.query
+
+ // Filter out types
+ if (this.usedFiltersNot.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
+ }
+
+ // Only use those filters if any and check if they are valid
+ if (this.usedFiltersIn.length > 0) {
+ types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
+ }
+
+ // Remove any filters from the query
+ query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
+
+ // Reset search if the query changed
+ await this.resetState()
+ this.triggered = true
+
+ if (!types.length) {
+ // no results since no types were selected
+ this.logger.error('No types to search in')
+ return
+ }
+
+ this.$set(this.loading, 'all', true)
+ this.logger.debug(`Searching ${query} in`, types)
+
+ Promise.all(types.map(async type => {
+ try {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.$set(this.results, type, data.ocs.data.entries)
+ } else {
+ this.$delete(this.results, type)
+ }
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ } else if (!data.ocs.data.isPaginated) {
+ // If no cursor and no pagination, we save the default amount
+ // provided by server's initial state `defaultLimit`
+ this.$set(this.limits, type, this.defaultLimit)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+
+ // If none already focused, focus the first rendered result
+ if (this.focused === null) {
+ this.focused = 0
+ }
+ return REQUEST_OK
+ } catch (error) {
+ this.$delete(this.results, type)
+
+ // If this is not a cancelled throw
+ if (error.response && error.response.status) {
+ this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
+ showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
+ return REQUEST_FAILED
+ }
+ return REQUEST_CANCELED
+ }
+ })).then(results => {
+ // Do not declare loading finished if the request have been cancelled
+ // This means another search was triggered and we're therefore still loading
+ if (results.some(result => result === REQUEST_CANCELED)) {
+ return
+ }
+ // We finished all searches
+ this.loading = {}
+ })
+ },
+ onInputDebounced: enableLiveSearch
+ ? debounce(function(e) {
+ this.onInput(e)
+ }, 500)
+ : function() {
+ this.triggered = false
+ },
+
+ /**
+ * Load more results for the provided type
+ *
+ * @param {string} type type
+ */
+ async loadMore(type) {
+ // If already loading, ignore
+ if (this.loading[type]) {
+ return
+ }
+
+ if (this.cursors[type]) {
+ // Init cancellable request
+ const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
+ this.requests.push(cancel)
+
+ // Fetch results
+ const { data } = await request()
+
+ // Save cursor if any
+ if (data.ocs.data.cursor) {
+ this.$set(this.cursors, type, data.ocs.data.cursor)
+ }
+
+ // Process results
+ if (data.ocs.data.entries.length > 0) {
+ this.results[type].push(...data.ocs.data.entries)
+ }
+
+ // Check if we reached end of pagination
+ if (data.ocs.data.entries.length < this.defaultLimit) {
+ this.$set(this.reached, type, true)
+ }
+ } else {
+ // If no cursor, we might have all the results already,
+ // let's fake pagination and show the next xxx entries
+ if (this.limits[type] && this.limits[type] >= 0) {
+ this.limits[type] += this.defaultLimit
+
+ // Check if we reached end of pagination
+ if (this.limits[type] >= this.results[type].length) {
+ this.$set(this.reached, type, true)
+ }
+ }
+ }
+
+ // Focus result after render
+ if (this.focused !== null) {
+ this.$nextTick(() => {
+ this.focusIndex(this.focused)
+ })
+ }
+ },
+
+ /**
+ * Return a subset of the array if the search provider
+ * doesn't supports pagination
+ *
+ * @param {Array} list the results
+ * @param {string} type the type
+ * @return {Array}
+ */
+ limitIfAny(list, type) {
+ if (type in this.limits) {
+ return list.slice(0, this.limits[type])
+ }
+ return list
+ },
+
+ getResultsList() {
+ return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
+ },
+
+ /**
+ * Focus the first result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusFirst(event) {
+ const results = this.getResultsList()
+ if (results && results.length > 0) {
+ if (event) {
+ event.preventDefault()
+ }
+ this.focused = 0
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the next result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusNext(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the last, focus the next one
+ if (results && results.length > 0 && this.focused + 1 < results.length) {
+ event.preventDefault()
+ this.focused++
+ this.focusIndex(this.focused)
+ }
+ },
+
+ /**
+ * Focus the previous result if any
+ *
+ * @param {Event} event the keydown event
+ */
+ focusPrev(event) {
+ if (this.focused === null) {
+ this.focusFirst(event)
+ return
+ }
+
+ const results = this.getResultsList()
+ // If we're not focusing the first, focus the previous one
+ if (results && results.length > 0 && this.focused > 0) {
+ event.preventDefault()
+ this.focused--
+ this.focusIndex(this.focused)
+ }
+
+ },
+
+ /**
+ * Focus the specified result index if it exists
+ *
+ * @param {number} index the result index
+ */
+ focusIndex(index) {
+ const results = this.getResultsList()
+ if (results && results[index]) {
+ results[index].focus()
+ }
+ },
+
+ /**
+ * Set the current focused element based on the target
+ *
+ * @param {Event} event the focus event
+ */
+ setFocusedIndex(event) {
+ const entry = event.target
+ const results = this.getResultsList()
+ const index = [...results].findIndex(search => search === entry)
+ if (index > -1) {
+ // let's not use focusIndex as the entry is already focused
+ this.focused = index
+ }
+ },
+
+ onClickFilter(filter) {
+ this.query = `${this.query} ${filter}`
+ .replace(/ {2}/g, ' ')
+ .trim()
+ this.onInput()
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$margin: 10px;
+$input-height: 34px;
+$input-padding: 10px;
+
+.unified-search {
+ &__trigger-icon {
+ color: var(--color-background-plain-text) !important;
+ }
+
+ &__input-wrapper {
+ position: sticky;
+ // above search results
+ z-index: 2;
+ top: 0;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ background-color: var(--color-main-background);
+
+ label[for="unified-search__input"] {
+ align-self: flex-start;
+ font-weight: bold;
+ font-size: 19px;
+ margin-inline-start: 13px;
+ }
+ }
+
+ &__input-row {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ }
+
+ &__filters {
+ margin-block: $margin;
+ margin-inline: math.div($margin, 2) 0;
+ padding-top: 5px;
+ ul {
+ display: inline-flex;
+ justify-content: space-between;
+ }
+ }
+
+ &__form {
+ position: relative;
+ width: 100%;
+ margin: $margin 0;
+
+ // Loading spinner
+ &::after {
+ inset-inline-start: auto $input-padding;
+ }
+
+ &-input,
+ &-reset {
+ margin: math.div($input-padding, 2);
+ }
+
+ &-input {
+ width: 100%;
+ height: $input-height;
+ padding: $input-padding;
+
+ &:focus,
+ &:focus-visible,
+ &:active {
+ border-color: 2px solid var(--color-main-text) !important;
+ box-shadow: 0 0 0 2px var(--color-main-background) !important;
+ }
+
+ &,
+ &[placeholder],
+ &::placeholder {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ // Hide webkit clear search
+ &::-webkit-search-decoration,
+ &::-webkit-search-cancel-button,
+ &::-webkit-search-results-button,
+ &::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+ }
+ }
+
+ &-reset,
+ &-submit {
+ position: absolute;
+ top: 0;
+ inset-inline-end: 4px;
+ width: $input-height - $input-padding;
+ height: $input-height - $input-padding;
+ min-height: 30px;
+ padding: 0;
+ opacity: .5;
+ border: none;
+ background-color: transparent;
+ margin-inline-end: 0;
+
+ &:hover,
+ &:focus,
+ &:active {
+ opacity: 1;
+ }
+ }
+
+ &-submit {
+ inset-inline-end: 28px;
+ }
+ }
+
+ &__results {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ &-header {
+ display: block;
+ margin: $margin;
+ margin-bottom: $margin - 4px;
+ margin-inline-start: 13px;
+ color: var(--color-primary-element);
+ font-size: 19px;
+ font-weight: bold;
+ }
+ }
+
+ :deep(.unified-search__result-more) {
+ color: var(--color-text-maxcontrast);
+ }
+
+ .empty-content {
+ margin: 10vh 0;
+
+ :deep(.empty-content__title) {
+ font-weight: normal;
+ font-size: var(--default-font-size);
+ text-align: center;
+ }
+ }
+}
+
+</style>
diff --git a/core/src/views/Login.vue b/core/src/views/Login.vue
index 0a5708bb5c3..a6fe8442779 100644
--- a/core/src/views/Login.vue
+++ b/core/src/views/Login.vue
@@ -1,181 +1,189 @@
<!--
- - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -->
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <div>
- <transition name="fade" mode="out-in">
- <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''"
- key="login">
- <LoginForm
- :username.sync="user"
- :redirect-url="redirectUrl"
- :direct-login="directLogin"
- :messages="messages"
- :errors="errors"
- :throttle-delay="throttleDelay"
- :inverted-colors="invertedColors"
- :auto-complete-allowed="autoCompleteAllowed"
- @submit="loading = true" />
- <a v-if="canResetPassword && resetPasswordLink !== ''"
- id="lost-password"
- :href="resetPasswordLink">
- {{ t('core', 'Forgot password?') }}
- </a>
- <a v-else-if="canResetPassword && !resetPassword"
- id="lost-password"
- :href="resetPasswordLink"
- @click.prevent="resetPassword = true">
- {{ t('core', 'Forgot password?') }}
- </a>
- <br>
- <a v-if="hasPasswordless" @click.prevent="passwordlessLogin = true">
- {{ t('core', 'Log in with a device') }}
- </a>
- </div>
- <div v-else-if="!loading && passwordlessLogin"
- key="reset"
- class="login-additional">
- <PasswordLessLoginForm
- :username.sync="user"
- :redirect-url="redirectUrl"
- :inverted-colors="invertedColors"
- :auto-complete-allowed="autoCompleteAllowed"
- :is-https="isHttps"
- :has-public-key-credential="hasPublicKeyCredential"
- @submit="loading = true" />
- <a @click.prevent="passwordlessLogin = false">
- {{ t('core', 'Back') }}
- </a>
- </div>
- <div v-else-if="!loading && canResetPassword"
- key="reset"
- class="login-additional">
- <div class="lost-password-container">
- <ResetPassword v-if="resetPassword"
- :username.sync="user"
- :reset-password-link="resetPasswordLink"
- :inverted-colors="invertedColors"
- @abort="resetPassword = false" />
+ <div class="guest-box login-box">
+ <template v-if="!hideLoginForm || directLogin">
+ <transition name="fade" mode="out-in">
+ <div v-if="!passwordlessLogin && !resetPassword && resetPasswordTarget === ''" class="login-box__wrapper">
+ <LoginForm :username.sync="user"
+ :redirect-url="redirectUrl"
+ :direct-login="directLogin"
+ :messages="messages"
+ :errors="errors"
+ :throttle-delay="throttleDelay"
+ :auto-complete-allowed="autoCompleteAllowed"
+ :email-states="emailStates"
+ @submit="loading = true" />
+ <NcButton v-if="hasPasswordless"
+ type="tertiary"
+ wide
+ @click.prevent="passwordlessLogin = true">
+ {{ t('core', 'Log in with a device') }}
+ </NcButton>
+ <NcButton v-if="canResetPassword && resetPasswordLink !== ''"
+ id="lost-password"
+ :href="resetPasswordLink"
+ type="tertiary-no-background"
+ wide>
+ {{ t('core', 'Forgot password?') }}
+ </NcButton>
+ <NcButton v-else-if="canResetPassword && !resetPassword"
+ id="lost-password"
+ type="tertiary"
+ wide
+ @click.prevent="resetPassword = true">
+ {{ t('core', 'Forgot password?') }}
+ </NcButton>
</div>
- </div>
- <div v-else-if="resetPasswordTarget !== ''">
- <UpdatePassword :username.sync="user"
- :reset-password-target="resetPasswordTarget"
- :inverted-colors="invertedColors"
- @done="passwordResetFinished" />
- </div>
- </transition>
+ <div v-else-if="!loading && passwordlessLogin"
+ key="reset-pw-less"
+ class="login-additional login-box__wrapper">
+ <PasswordLessLoginForm :username.sync="user"
+ :redirect-url="redirectUrl"
+ :auto-complete-allowed="autoCompleteAllowed"
+ :is-https="isHttps"
+ :is-localhost="isLocalhost"
+ @submit="loading = true" />
+ <NcButton type="tertiary"
+ :aria-label="t('core', 'Back to login form')"
+ :wide="true"
+ @click="passwordlessLogin = false">
+ {{ t('core', 'Back') }}
+ </NcButton>
+ </div>
+ <div v-else-if="!loading && canResetPassword"
+ key="reset-can-reset"
+ class="login-additional">
+ <div class="lost-password-container">
+ <ResetPassword v-if="resetPassword"
+ :username.sync="user"
+ :reset-password-link="resetPasswordLink"
+ @abort="resetPassword = false" />
+ </div>
+ </div>
+ <div v-else-if="resetPasswordTarget !== ''">
+ <UpdatePassword :username.sync="user"
+ :reset-password-target="resetPasswordTarget"
+ @done="passwordResetFinished" />
+ </div>
+ </transition>
+ </template>
+ <template v-else>
+ <transition name="fade" mode="out-in">
+ <NcNoteCard type="info" :title="t('core', 'Login form is disabled.')">
+ {{ t('core', 'The Nextcloud login form is disabled. Use another login option if available or contact your administration.') }}
+ </NcNoteCard>
+ </transition>
+ </template>
+
+ <div id="alternative-logins" class="login-box__alternative-logins">
+ <NcButton v-for="(alternativeLogin, index) in alternativeLogins"
+ :key="index"
+ type="secondary"
+ :wide="true"
+ :class="[alternativeLogin.class]"
+ role="link"
+ :href="alternativeLogin.href">
+ {{ alternativeLogin.name }}
+ </NcButton>
+ </div>
</div>
</template>
<script>
+import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
+
+import queryString from 'query-string'
+
import LoginForm from '../components/login/LoginForm.vue'
import PasswordLessLoginForm from '../components/login/PasswordLessLoginForm.vue'
import ResetPassword from '../components/login/ResetPassword.vue'
import UpdatePassword from '../components/login/UpdatePassword.vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import { wipeBrowserStorages } from '../utils/xhr-request.js'
+
+const query = queryString.parse(location.search)
+if (query.clear === '1') {
+ wipeBrowserStorages()
+}
export default {
name: 'Login',
+
components: {
LoginForm,
PasswordLessLoginForm,
ResetPassword,
UpdatePassword,
+ NcButton,
+ NcNoteCard,
},
- props: {
- username: {
- type: String,
- default: '',
- },
- redirectUrl: {
- type: String,
- },
- errors: {
- type: Array,
- default: () => [],
- },
- messages: {
- type: Array,
- default: () => [],
- },
- throttleDelay: {
- type: Number,
- },
- canResetPassword: {
- type: Boolean,
- default: false,
- },
- resetPasswordLink: {
- type: String,
- },
- resetPasswordTarget: {
- type: String,
- },
- invertedColors: {
- type: Boolean,
- default: false,
- },
- autoCompleteAllowed: {
- type: Boolean,
- default: true,
- },
- directLogin: {
- type: Boolean,
- default: false,
- },
- hasPasswordless: {
- type: Boolean,
- default: false,
- },
- isHttps: {
- type: Boolean,
- default: false,
- },
- hasPublicKeyCredential: {
- type: Boolean,
- default: false,
- },
- },
+
data() {
return {
loading: false,
- user: this.username,
+ user: loadState('core', 'loginUsername', ''),
passwordlessLogin: false,
resetPassword: false,
+
+ // Initial data
+ errors: loadState('core', 'loginErrors', []),
+ messages: loadState('core', 'loginMessages', []),
+ redirectUrl: loadState('core', 'loginRedirectUrl', false),
+ throttleDelay: loadState('core', 'loginThrottleDelay', 0),
+ canResetPassword: loadState('core', 'loginCanResetPassword', false),
+ resetPasswordLink: loadState('core', 'loginResetPasswordLink', ''),
+ autoCompleteAllowed: loadState('core', 'loginAutocomplete', true),
+ resetPasswordTarget: loadState('core', 'resetPasswordTarget', ''),
+ resetPasswordUser: loadState('core', 'resetPasswordUser', ''),
+ directLogin: query.direct === '1',
+ hasPasswordless: loadState('core', 'webauthn-available', false),
+ countAlternativeLogins: loadState('core', 'countAlternativeLogins', false),
+ alternativeLogins: loadState('core', 'alternativeLogins', []),
+ isHttps: window.location.protocol === 'https:',
+ isLocalhost: window.location.hostname === 'localhost',
+ hideLoginForm: loadState('core', 'hideLoginForm', false),
+ emailStates: loadState('core', 'emailStates', []),
}
},
+
methods: {
passwordResetFinished() {
- this.resetPasswordTarget = ''
- this.directLogin = true
+ window.location.href = generateUrl('login')
},
},
}
</script>
-<style>
- .fade-enter-active, .fade-leave-active {
- transition: opacity .3s;
+<style scoped lang="scss">
+.login-box {
+ // Same size as dashboard panels
+ width: 320px;
+ box-sizing: border-box;
+
+ &__wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: calc(2 * var(--default-grid-baseline));
}
- .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
- opacity: 0;
+
+ &__alternative-logins {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
}
+}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity .3s;
+}
+
+.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
+ opacity: 0;
+}
</style>
diff --git a/core/src/views/PublicPageMenu.vue b/core/src/views/PublicPageMenu.vue
new file mode 100644
index 00000000000..a05f3a6b889
--- /dev/null
+++ b/core/src/views/PublicPageMenu.vue
@@ -0,0 +1,131 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <div class="public-page-menu__wrapper">
+ <NcButton v-if="primaryAction"
+ id="public-page-menu--primary"
+ class="public-page-menu__primary"
+ :href="primaryAction.href"
+ type="primary"
+ @click="openDialogIfNeeded">
+ <template v-if="primaryAction.icon" #icon>
+ <div :class="['icon', primaryAction.icon, 'public-page-menu__primary-icon']" />
+ </template>
+ {{ primaryAction.label }}
+ </NcButton>
+
+ <NcHeaderMenu v-if="secondaryActions.length > 0"
+ id="public-page-menu"
+ :aria-label="t('core', 'More actions')"
+ :open.sync="showMenu">
+ <template #trigger>
+ <IconMore :size="20" />
+ </template>
+ <ul :aria-label="t('core', 'More actions')"
+ class="public-page-menu"
+ role="menu">
+ <component :is="getComponent(entry)"
+ v-for="entry, index in secondaryActions"
+ :key="index"
+ v-bind="entry"
+ @click="showMenu = false" />
+ </ul>
+ </NcHeaderMenu>
+ </div>
+</template>
+
+<script setup lang="ts">
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
+import { spawnDialog } from '@nextcloud/vue/functions/dialog'
+import { computed, ref, type Ref } from 'vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import IconMore from 'vue-material-design-icons/DotsHorizontal.vue'
+import PublicPageMenuEntry from '../components/PublicPageMenu/PublicPageMenuEntry.vue'
+import PublicPageMenuCustomEntry from '../components/PublicPageMenu/PublicPageMenuCustomEntry.vue'
+import PublicPageMenuExternalEntry from '../components/PublicPageMenu/PublicPageMenuExternalEntry.vue'
+import PublicPageMenuExternalDialog from '../components/PublicPageMenu/PublicPageMenuExternalDialog.vue'
+import PublicPageMenuLinkEntry from '../components/PublicPageMenu/PublicPageMenuLinkEntry.vue'
+
+interface IPublicPageMenu {
+ id: string
+ label: string
+ href: string
+ icon?: string
+ html?: string
+ details?: string
+}
+
+const menuEntries = loadState<Array<IPublicPageMenu>>('core', 'public-page-menu')
+
+/** used to conditionally close the menu when clicking entry */
+const showMenu = ref(false)
+
+const isMobile = useIsSmallMobile() as Readonly<Ref<boolean>>
+/** The primary menu action - only showed when not on mobile */
+const primaryAction = computed(() => isMobile.value ? undefined : menuEntries[0])
+/** All other secondary actions (including primary action on mobile) */
+const secondaryActions = computed(() => isMobile.value ? menuEntries : menuEntries.slice(1))
+
+/**
+ * Get the render component for an entry
+ * @param entry The entry to get the component for
+ */
+function getComponent(entry: IPublicPageMenu) {
+ if ('html' in entry) {
+ return PublicPageMenuCustomEntry
+ }
+ switch (entry.id) {
+ case 'save':
+ return PublicPageMenuExternalEntry
+ case 'directLink':
+ return PublicPageMenuLinkEntry
+ default:
+ return PublicPageMenuEntry
+ }
+}
+
+/**
+ * Open the "federated share" dialog if needed
+ */
+function openDialogIfNeeded() {
+ if (primaryAction.value?.id !== 'save') {
+ return
+ }
+ spawnDialog(PublicPageMenuExternalDialog, { label: primaryAction.value.label })
+}
+</script>
+
+<style scoped lang="scss">
+.public-page-menu {
+ box-sizing: border-box;
+
+ > :deep(*) {
+ box-sizing: border-box;
+ }
+
+ &__wrapper {
+ display: flex;
+ flex-direction: row;
+ gap: var(--default-grid-baseline);
+ }
+
+ &__primary {
+ height: var(--default-clickable-area);
+ margin-block: calc((var(--header-height) - var(--default-clickable-area)) / 2);
+
+ // Ensure the correct focus-visible color is used (as this is rendered directly on the background(-image))
+ &:focus-visible {
+ border-color: var(--color-background-plain-text) !important;
+ }
+ }
+
+ &__primary-icon {
+ filter: var(--primary-invert-if-bright);
+ }
+}
+</style>
diff --git a/core/src/views/PublicPageUserMenu.vue b/core/src/views/PublicPageUserMenu.vue
new file mode 100644
index 00000000000..7bd6521e7aa
--- /dev/null
+++ b/core/src/views/PublicPageUserMenu.vue
@@ -0,0 +1,138 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcHeaderMenu id="public-page-user-menu"
+ class="public-page-user-menu"
+ is-nav
+ :aria-label="t('core', 'User menu')"
+ :description="avatarDescription">
+ <template #trigger>
+ <NcAvatar class="public-page-user-menu__avatar"
+ disable-menu
+ disable-tooltip
+ is-guest
+ :user="displayName || '?'" />
+ </template>
+
+ <!-- Privacy notice -->
+ <NcNoteCard class="public-page-user-menu__list-note"
+ :text="privacyNotice"
+ type="info" />
+
+ <ul class="public-page-user-menu__list">
+ <!-- Nickname dialog -->
+ <AccountMenuEntry id="set-nickname"
+ :name="!displayName ? t('core', 'Set public name') : t('core', 'Change public name')"
+ href="#"
+ @click.prevent.stop="setNickname">
+ <template #icon>
+ <IconAccount />
+ </template>
+ </AccountMenuEntry>
+ </ul>
+ </NcHeaderMenu>
+</template>
+
+<script lang="ts">
+import type { NextcloudUser } from '@nextcloud/auth'
+
+import '@nextcloud/dialogs/style.css'
+import { defineComponent } from 'vue'
+import { getGuestUser } from '@nextcloud/auth'
+import { showGuestUserPrompt } from '@nextcloud/dialogs'
+import { subscribe } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import IconAccount from 'vue-material-design-icons/AccountOutline.vue'
+
+import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
+
+export default defineComponent({
+ name: 'PublicPageUserMenu',
+ components: {
+ AccountMenuEntry,
+ IconAccount,
+ NcAvatar,
+ NcHeaderMenu,
+ NcNoteCard,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ return {
+ displayName: getGuestUser().displayName,
+ }
+ },
+
+ computed: {
+ avatarDescription(): string {
+ return t('core', 'User menu')
+ },
+
+ privacyNotice(): string {
+ return this.displayName
+ ? t('core', 'You will be identified as {user} by the account owner.', { user: this.displayName })
+ : t('core', 'You are currently not identified.')
+ },
+ },
+
+ mounted() {
+ subscribe('user:info:changed', (user: NextcloudUser) => {
+ this.displayName = user.displayName || ''
+ })
+ },
+
+ methods: {
+ setNickname() {
+ showGuestUserPrompt({
+ nickname: this.displayName,
+ cancellable: true,
+ })
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.public-page-user-menu {
+ &, * {
+ box-sizing: border-box;
+ }
+
+ // Ensure we do not waste space, as the header menu sets a default width of 350px
+ :deep(.header-menu__content) {
+ width: fit-content !important;
+ }
+
+ &__list-note {
+ padding-block: 5px !important;
+ padding-inline: 5px !important;
+ max-width: 300px;
+ margin: 5px !important;
+ margin-bottom: 0 !important;
+ }
+
+ &__list {
+ display: inline-flex;
+ flex-direction: column;
+ padding-block: var(--default-grid-baseline) 0;
+ width: 100%;
+
+ > :deep(li) {
+ box-sizing: border-box;
+ // basically "fit-content"
+ flex: 0 1;
+ }
+ }
+}
+</style>
diff --git a/core/src/views/Setup.cy.ts b/core/src/views/Setup.cy.ts
new file mode 100644
index 00000000000..f252801c4d8
--- /dev/null
+++ b/core/src/views/Setup.cy.ts
@@ -0,0 +1,369 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import type { SetupConfig, SetupLinks } from '../install'
+import SetupView from './Setup.vue'
+
+import '../../css/guest.css'
+
+const defaultConfig = Object.freeze({
+ adminlogin: '',
+ adminpass: '',
+ dbuser: '',
+ dbpass: '',
+ dbname: '',
+ dbtablespace: '',
+ dbhost: '',
+ dbtype: '',
+ databases: {
+ sqlite: 'SQLite',
+ mysql: 'MySQL/MariaDB',
+ pgsql: 'PostgreSQL',
+ },
+ directory: '',
+ hasAutoconfig: false,
+ htaccessWorking: true,
+ serverRoot: '/var/www/html',
+ errors: [],
+}) as SetupConfig
+
+const links = {
+ adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install',
+ adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install',
+ adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration',
+} as SetupLinks
+
+describe('Default setup page', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders default config', () => {
+ cy.mockInitialState('core', 'config', defaultConfig)
+ cy.mount(SetupView)
+
+ cy.get('[data-cy-setup-form]').scrollIntoView()
+ cy.get('[data-cy-setup-form]').should('be.visible')
+
+ // Single note is the footer help
+ cy.get('[data-cy-setup-form-note]')
+ .should('have.length', 1)
+ .should('be.visible')
+ cy.get('[data-cy-setup-form-note]').should('contain', 'See the documentation')
+
+ // DB radio selectors
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('exist')
+ .find('input')
+ .should('be.checked')
+
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"]').should('exist')
+ cy.get('[data-cy-setup-form-field="dbtype-pgsql"]').should('exist')
+ cy.get('[data-cy-setup-form-field="dbtype-oci"]').should('not.exist')
+
+ // Sqlite warning
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('be.visible')
+
+ // admin login, password, data directory and 3 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 6)
+ })
+
+ it('Renders single DB sqlite', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ sqlite: 'SQLite',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // No DB radio selectors if only sqlite
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('not.exist')
+
+ // Two warnings: sqlite and single db support
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('be.visible')
+ cy.get('[data-cy-setup-form-db-note="single-db"]')
+ .should('be.visible')
+
+ // Admin login, password and data directory
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 3)
+ })
+
+ it('Renders single DB mysql', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ mysql: 'MySQL/MariaDB',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // No DB radio selectors if only mysql
+ cy.get('[data-cy-setup-form-field^="dbtype"]')
+ .should('not.exist')
+
+ // Single db support warning
+ cy.get('[data-cy-setup-form-db-note="single-db"]')
+ .should('be.visible')
+ .invoke('html')
+ .should('contains', links.adminSourceInstall)
+
+ // No SQLite warning
+ cy.get('[data-cy-setup-form-db-note="sqlite"]')
+ .should('not.exist')
+
+ // Admin login, password, data directory, db user,
+ // db password, db name and db host
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 7)
+ })
+
+ it('Changes fields from sqlite to mysql then oci', () => {
+ const config = {
+ ...defaultConfig,
+ databases: {
+ sqlite: 'SQLite',
+ mysql: 'MySQL/MariaDB',
+ pgsql: 'PostgreSQL',
+ oci: 'Oracle',
+ },
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // SQLite selected
+ cy.get('[data-cy-setup-form-field="dbtype-sqlite"]')
+ .should('be.visible')
+ .find('input')
+ .should('be.checked')
+
+ // Admin login, password, data directory and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 7)
+
+ // Change to MySQL
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"]').click()
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').should('be.checked')
+
+ // Admin login, password, data directory, db user, db password,
+ // db name, db host and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 11)
+
+ // Change to Oracle
+ cy.get('[data-cy-setup-form-field="dbtype-oci"]').click()
+ cy.get('[data-cy-setup-form-field="dbtype-oci"] input').should('be.checked')
+
+ // Admin login, password, data directory, db user, db password,
+ // db name, db table space, db host and 4 DB radio selectors
+ cy.get('[data-cy-setup-form-field]')
+ .should('be.visible')
+ .should('have.length', 12)
+ cy.get('[data-cy-setup-form-field="dbtablespace"]')
+ .should('be.visible')
+ })
+})
+
+describe('Setup page with errors and warning', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders error from backend', () => {
+ const config = {
+ ...defaultConfig,
+ errors: [
+ {
+ error: 'Error message',
+ hint: 'Error hint',
+ },
+ ],
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Error message and hint
+ cy.get('[data-cy-setup-form-note="error"]')
+ .should('be.visible')
+ .should('have.length', 1)
+ .should('contain', 'Error message')
+ .should('contain', 'Error hint')
+ })
+
+ it('Renders errors from backend', () => {
+ const config = {
+ ...defaultConfig,
+ errors: [
+ 'Error message 1',
+ {
+ error: 'Error message',
+ hint: 'Error hint',
+ },
+ ],
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Error message and hint
+ cy.get('[data-cy-setup-form-note="error"]')
+ .should('be.visible')
+ .should('have.length', 2)
+ cy.get('[data-cy-setup-form-note="error"]').eq(0)
+ .should('contain', 'Error message 1')
+ cy.get('[data-cy-setup-form-note="error"]').eq(1)
+ .should('contain', 'Error message')
+ .should('contain', 'Error hint')
+ })
+
+ it('Renders all the submitted fields on error', () => {
+ const config = {
+ ...defaultConfig,
+ adminlogin: 'admin',
+ adminpass: 'password',
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ cy.get('input[data-cy-setup-form-field="adminlogin"]')
+ .should('have.value', 'admin')
+ cy.get('input[data-cy-setup-form-field="adminpass"]')
+ .should('have.value', 'password')
+ cy.get('[data-cy-setup-form-field="dbtype-mysql"] input')
+ .should('be.checked')
+ cy.get('input[data-cy-setup-form-field="dbname"]')
+ .should('have.value', 'nextcloud')
+ cy.get('input[data-cy-setup-form-field="dbuser"]')
+ .should('have.value', 'nextcloud')
+ cy.get('input[data-cy-setup-form-field="dbpass"]')
+ .should('have.value', 'password')
+ cy.get('input[data-cy-setup-form-field="dbhost"]')
+ .should('have.value', 'localhost')
+ cy.get('input[data-cy-setup-form-field="directory"]')
+ .should('have.value', '/var/www/html/nextcloud')
+ })
+
+ it('Renders the htaccess warning', () => {
+ const config = {
+ ...defaultConfig,
+ htaccessWorking: false,
+ }
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ cy.get('[data-cy-setup-form-note="htaccess"]')
+ .should('be.visible')
+ .should('contain', 'Security warning')
+ .invoke('html')
+ .should('contains', links.adminInstall)
+ })
+})
+
+describe('Setup page with autoconfig', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Renders autoconfig', () => {
+ const config = {
+ ...defaultConfig,
+ hasAutoconfig: true,
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Autoconfig info note
+ cy.get('[data-cy-setup-form-note="autoconfig"]')
+ .should('be.visible')
+ .should('contain', 'Autoconfig file detected')
+
+ // Database and storage section is hidden as already set in autoconfig
+ cy.get('[data-cy-setup-form-advanced-config]').should('be.visible')
+ .invoke('attr', 'open')
+ .should('equal', undefined)
+
+ // Oracle tablespace is hidden
+ cy.get('[data-cy-setup-form-field="dbtablespace"]')
+ .should('not.exist')
+ })
+})
+
+describe('Submit a full form sends the data', () => {
+ beforeEach(() => {
+ cy.mockInitialState('core', 'links', links)
+ })
+
+ afterEach(() => cy.unmockInitialState())
+
+ it('Submits a full form', () => {
+ const config = {
+ ...defaultConfig,
+ adminlogin: 'admin',
+ adminpass: 'password',
+ dbname: 'nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbhost: 'localhost',
+ dbtablespace: 'tablespace',
+ directory: '/var/www/html/nextcloud',
+ } as SetupConfig
+
+ cy.intercept('POST', '**', {
+ delay: 2000,
+ }).as('setup')
+
+ cy.mockInitialState('core', 'config', config)
+ cy.mount(SetupView)
+
+ // Not chaining breaks the test as the POST prevents the element from being retrieved twice
+ // eslint-disable-next-line cypress/unsafe-to-chain-command
+ cy.get('[data-cy-setup-form-submit]')
+ .click()
+ .invoke('attr', 'disabled')
+ .should('equal', 'disabled', { timeout: 500 })
+
+ cy.wait('@setup')
+ .its('request.body')
+ .should('deep.equal', new URLSearchParams({
+ adminlogin: 'admin',
+ adminpass: 'password',
+ directory: '/var/www/html/nextcloud',
+ dbtype: 'mysql',
+ dbuser: 'nextcloud',
+ dbpass: 'password',
+ dbname: 'nextcloud',
+ dbhost: 'localhost',
+ }).toString())
+ })
+})
diff --git a/core/src/views/Setup.vue b/core/src/views/Setup.vue
new file mode 100644
index 00000000000..50ec0da9035
--- /dev/null
+++ b/core/src/views/Setup.vue
@@ -0,0 +1,460 @@
+<!--
+ - SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <form ref="form"
+ class="setup-form"
+ :class="{ 'setup-form--loading': loading }"
+ action=""
+ data-cy-setup-form
+ method="POST"
+ @submit="onSubmit">
+ <!-- Autoconfig info -->
+ <NcNoteCard v-if="config.hasAutoconfig"
+ :heading="t('core', 'Autoconfig file detected')"
+ data-cy-setup-form-note="autoconfig"
+ type="success">
+ {{ t('core', 'The setup form below is pre-filled with the values from the config file.') }}
+ </NcNoteCard>
+
+ <!-- Htaccess warning -->
+ <NcNoteCard v-if="config.htaccessWorking === false"
+ :heading="t('core', 'Security warning')"
+ data-cy-setup-form-note="htaccess"
+ type="warning">
+ <p v-html="htaccessWarning" />
+ </NcNoteCard>
+
+ <!-- Various errors -->
+ <NcNoteCard v-for="(error, index) in errors"
+ :key="index"
+ :heading="error.heading"
+ data-cy-setup-form-note="error"
+ type="error">
+ {{ error.message }}
+ </NcNoteCard>
+
+ <!-- Admin creation -->
+ <fieldset class="setup-form__administration">
+ <legend>{{ t('core', 'Create administration account') }}</legend>
+
+ <!-- Username -->
+ <NcTextField v-model="config.adminlogin"
+ :label="t('core', 'Administration account name')"
+ data-cy-setup-form-field="adminlogin"
+ name="adminlogin"
+ required />
+
+ <!-- Password -->
+ <NcPasswordField v-model="config.adminpass"
+ :label="t('core', 'Administration account password')"
+ data-cy-setup-form-field="adminpass"
+ name="adminpass"
+ required />
+
+ <!-- Password entropy -->
+ <NcNoteCard v-show="config.adminpass !== ''" :type="passwordHelperType">
+ {{ passwordHelperText }}
+ </NcNoteCard>
+ </fieldset>
+
+ <!-- Autoconfig toggle -->
+ <details :open="!isValidAutoconfig" data-cy-setup-form-advanced-config>
+ <summary>{{ t('core', 'Storage & database') }}</summary>
+
+ <!-- Data folder -->
+ <fieldset class="setup-form__data-folder">
+ <NcTextField v-model="config.directory"
+ :label="t('core', 'Data folder')"
+ :placeholder="config.serverRoot + '/data'"
+ required
+ autocomplete="off"
+ autocapitalize="none"
+ data-cy-setup-form-field="directory"
+ name="directory"
+ spellcheck="false" />
+ </fieldset>
+
+ <!-- Database -->
+ <fieldset class="setup-form__database">
+ <legend>{{ t('core', 'Database configuration') }}</legend>
+
+ <!-- Database type select -->
+ <fieldset class="setup-form__database-type">
+ <p v-if="!firstAndOnlyDatabase" :class="`setup-form__database-type-select--${DBTypeGroupDirection}`" class="setup-form__database-type-select">
+ <NcCheckboxRadioSwitch v-for="(name, db) in config.databases"
+ :key="db"
+ v-model="config.dbtype"
+ :button-variant="true"
+ :data-cy-setup-form-field="`dbtype-${db}`"
+ :value="db"
+ :button-variant-grouped="DBTypeGroupDirection"
+ name="dbtype"
+ type="radio">
+ {{ name }}
+ </NcCheckboxRadioSwitch>
+ </p>
+
+ <NcNoteCard v-else data-cy-setup-form-db-note="single-db" type="warning">
+ {{ t('core', 'Only {firstAndOnlyDatabase} is available.', { firstAndOnlyDatabase }) }}<br>
+ {{ t('core', 'Install and activate additional PHP modules to choose other database types.') }}<br>
+ <a :href="links.adminSourceInstall" target="_blank" rel="noreferrer noopener">
+ {{ t('core', 'For more details check out the documentation.') }} ↗
+ </a>
+ </NcNoteCard>
+
+ <NcNoteCard v-if="config.dbtype === 'sqlite'"
+ :heading="t('core', 'Performance warning')"
+ data-cy-setup-form-db-note="sqlite"
+ type="warning">
+ {{ t('core', 'You chose SQLite as database.') }}<br>
+ {{ t('core', 'SQLite should only be used for minimal and development instances. For production we recommend a different database backend.') }}<br>
+ {{ t('core', 'If you use clients for file syncing, the use of SQLite is highly discouraged.') }}
+ </NcNoteCard>
+ </fieldset>
+
+ <!-- Database configuration -->
+ <fieldset v-if="config.dbtype !== 'sqlite'">
+ <NcTextField v-model="config.dbuser"
+ :label="t('core', 'Database user')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbuser"
+ name="dbuser"
+ spellcheck="false"
+ required />
+
+ <NcPasswordField v-model="config.dbpass"
+ :label="t('core', 'Database password')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbpass"
+ name="dbpass"
+ spellcheck="false"
+ required />
+
+ <NcTextField v-model="config.dbname"
+ :label="t('core', 'Database name')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbname"
+ name="dbname"
+ pattern="[0-9a-zA-Z\$_\-]+"
+ spellcheck="false"
+ required />
+
+ <NcTextField v-if="config.dbtype === 'oci'"
+ v-model="config.dbtablespace"
+ :label="t('core', 'Database tablespace')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbtablespace"
+ name="dbtablespace"
+ spellcheck="false" />
+
+ <NcTextField v-model="config.dbhost"
+ :helper-text="t('core', 'Please specify the port number along with the host name (e.g., localhost:5432).')"
+ :label="t('core', 'Database host')"
+ :placeholder="t('core', 'localhost')"
+ autocapitalize="none"
+ autocomplete="off"
+ data-cy-setup-form-field="dbhost"
+ name="dbhost"
+ spellcheck="false" />
+ </fieldset>
+ </fieldset>
+ </details>
+
+ <!-- Submit -->
+ <NcButton class="setup-form__button"
+ :class="{ 'setup-form__button--loading': loading }"
+ :disabled="loading"
+ :loading="loading"
+ :wide="true"
+ alignment="center-reverse"
+ data-cy-setup-form-submit
+ native-type="submit"
+ type="primary">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconArrowRight v-else />
+ </template>
+ {{ loading ? t('core', 'Installing …') : t('core', 'Install') }}
+ </NcButton>
+
+ <!-- Help note -->
+ <NcNoteCard data-cy-setup-form-note="help" type="info">
+ {{ t('core', 'Need help?') }}
+ <a target="_blank" rel="noreferrer noopener" :href="links.adminInstall">{{ t('core', 'See the documentation') }} ↗</a>
+ </NcNoteCard>
+ </form>
+</template>
+<script lang="ts">
+import type { DbType, SetupConfig, SetupLinks } from '../install'
+
+import { defineComponent } from 'vue'
+import { loadState } from '@nextcloud/initial-state'
+import { t } from '@nextcloud/l10n'
+import DomPurify from 'dompurify'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
+import NcTextField from '@nextcloud/vue/components/NcTextField'
+
+import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+
+enum PasswordStrength {
+ VeryWeak,
+ Weak,
+ Moderate,
+ Strong,
+ VeryStrong,
+ ExtremelyStrong,
+}
+
+const checkPasswordEntropy = (password: string = ''): PasswordStrength => {
+ const uniqueCharacters = new Set(password)
+ const entropy = parseInt(Math.log2(Math.pow(parseInt(uniqueCharacters.size.toString()), password.length)).toFixed(2))
+ if (entropy < 16) {
+ return PasswordStrength.VeryWeak
+ } else if (entropy < 31) {
+ return PasswordStrength.Weak
+ } else if (entropy < 46) {
+ return PasswordStrength.Moderate
+ } else if (entropy < 61) {
+ return PasswordStrength.Strong
+ } else if (entropy < 76) {
+ return PasswordStrength.VeryStrong
+ }
+
+ return PasswordStrength.ExtremelyStrong
+}
+
+export default defineComponent({
+ name: 'Setup',
+
+ components: {
+ IconArrowRight,
+ NcButton,
+ NcCheckboxRadioSwitch,
+ NcLoadingIcon,
+ NcNoteCard,
+ NcPasswordField,
+ NcTextField,
+ },
+
+ setup() {
+ return {
+ t,
+ }
+ },
+
+ data() {
+ return {
+ config: {} as SetupConfig,
+ links: {} as SetupLinks,
+ isValidAutoconfig: false,
+ loading: false,
+ }
+ },
+
+ computed: {
+ passwordHelperText(): string {
+ if (this.config?.adminpass === '') {
+ return ''
+ }
+
+ const passwordStrength = checkPasswordEntropy(this.config?.adminpass)
+ switch (passwordStrength) {
+ case PasswordStrength.VeryWeak:
+ return t('core', 'Password is too weak')
+ case PasswordStrength.Weak:
+ return t('core', 'Password is weak')
+ case PasswordStrength.Moderate:
+ return t('core', 'Password is average')
+ case PasswordStrength.Strong:
+ return t('core', 'Password is strong')
+ case PasswordStrength.VeryStrong:
+ return t('core', 'Password is very strong')
+ case PasswordStrength.ExtremelyStrong:
+ return t('core', 'Password is extremely strong')
+ }
+
+ return t('core', 'Unknown password strength')
+ },
+ passwordHelperType() {
+ if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Moderate) {
+ return 'error'
+ }
+ if (checkPasswordEntropy(this.config?.adminpass) < PasswordStrength.Strong) {
+ return 'warning'
+ }
+ return 'success'
+ },
+
+ firstAndOnlyDatabase(): string|null {
+ const dbNames = Object.values(this.config?.databases || {})
+ if (dbNames.length === 1) {
+ return dbNames[0]
+ }
+
+ return null
+ },
+
+ DBTypeGroupDirection() {
+ const databases = Object.keys(this.config?.databases || {})
+ // If we have more than 3 databases, we want to display them vertically
+ if (databases.length > 3) {
+ return 'vertical'
+ }
+ return 'horizontal'
+ },
+
+ htaccessWarning(): string {
+ // We use v-html, let's make sure we're safe
+ const message = [
+ t('core', 'Your data directory and files are probably accessible from the internet because the <code>.htaccess</code> file does not work.'),
+ t('core', 'For information how to properly configure your server, please {linkStart}see the documentation{linkEnd}', {
+ linkStart: '<a href="' + this.links.adminInstall + '" target="_blank" rel="noreferrer noopener">',
+ linkEnd: '</a>',
+ }, { escape: false }),
+ ].join('<br>')
+ return DomPurify.sanitize(message)
+ },
+
+ errors() {
+ return (this.config?.errors || []).map(error => {
+ if (typeof error === 'string') {
+ return {
+ heading: '',
+ message: error,
+ }
+ }
+
+ // f no hint is set, we don't want to show a heading
+ if (error.hint === '') {
+ return {
+ heading: '',
+ message: error.error,
+ }
+ }
+
+ return {
+ heading: error.error,
+ message: error.hint,
+ }
+ })
+ },
+ },
+
+ beforeMount() {
+ // Needs to only read the state once we're mounted
+ // for Cypress to be properly initialized.
+ this.config = loadState<SetupConfig>('core', 'config')
+ this.links = loadState<SetupLinks>('core', 'links')
+
+ },
+
+ mounted() {
+ // Set the first database type as default if none is set
+ if (this.config.dbtype === '') {
+ this.config.dbtype = Object.keys(this.config.databases).at(0) as DbType
+ }
+
+ // Validate the legitimacy of the autoconfig
+ if (this.config.hasAutoconfig) {
+ const form = this.$refs.form as HTMLFormElement
+
+ // Check the form without the administration account fields
+ form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
+ input.removeAttribute('required')
+ })
+
+ if (form.checkValidity() && this.config.errors.length === 0) {
+ this.isValidAutoconfig = true
+ } else {
+ this.isValidAutoconfig = false
+ }
+
+ // Restore the required attribute
+ // Check the form without the administration account fields
+ form.querySelectorAll('input[name="adminlogin"], input[name="adminpass"]').forEach(input => {
+ input.setAttribute('required', 'true')
+ })
+ }
+ },
+
+ methods: {
+ async onSubmit() {
+ this.loading = true
+ },
+ },
+})
+</script>
+<style lang="scss">
+form {
+ padding: calc(3 * var(--default-grid-baseline));
+ color: var(--color-main-text);
+ border-radius: var(--border-radius-container);
+ background-color: var(--color-main-background-blur);
+ box-shadow: 0 0 10px var(--color-box-shadow);
+ -webkit-backdrop-filter: var(--filter-background-blur);
+ backdrop-filter: var(--filter-background-blur);
+
+ max-width: 300px;
+ margin-bottom: 30px;
+
+ > fieldset:first-child,
+ > .notecard:first-child {
+ margin-top: 0;
+ }
+
+ > .notecard:last-child {
+ margin-bottom: 0;
+ }
+
+ fieldset,
+ details {
+ margin-block: 1rem;
+ }
+
+ .setup-form__button:not(.setup-form__button--loading) {
+ .material-design-icon {
+ transition: all linear var(--animation-quick);
+ }
+
+ &:hover .material-design-icon {
+ transform: translateX(0.2em);
+ }
+ }
+
+ // Db select required styling
+ .setup-form__database-type-select {
+ display: flex;
+ &--vertical {
+ flex-direction: column;
+ }
+ }
+
+}
+
+code {
+ background-color: var(--color-background-dark);
+ margin-top: 1rem;
+ padding: 0 0.3em;
+ border-radius: var(--border-radius);
+}
+
+// Various overrides
+.input-field {
+ margin-block-start: 1rem !important;
+}
+
+.notecard__heading {
+ font-size: inherit !important;
+}
+</style>
diff --git a/core/src/views/UnifiedSearch.vue b/core/src/views/UnifiedSearch.vue
index 45e373ade71..103e47b0425 100644
--- a/core/src/views/UnifiedSearch.vue
+++ b/core/src/views/UnifiedSearch.vue
@@ -1,754 +1,182 @@
- <!--
- - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @author John Molakvoæ <skjnldsv@protonmail.com>
- -
- - @license GNU AGPL version 3 or any later version
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU Affero General Public License as
- - published by the Free Software Foundation, either version 3 of the
- - License, or (at your option) any later version.
- -
- - This program is distributed in the hope that it will be useful,
- - but WITHOUT ANY WARRANTY; without even the implied warranty of
- - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- - GNU Affero General Public License for more details.
- -
- - You should have received a copy of the GNU Affero General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
<template>
- <HeaderMenu id="unified-search"
- class="unified-search"
- exclude-click-outside-classes="popover"
- :open.sync="open"
- @open="onOpen"
- @close="onClose">
- <!-- Header icon -->
- <template #trigger>
- <Magnify class="unified-search__trigger" :size="20" fill-color="var(--color-primary-text)" />
- </template>
-
- <!-- Search form & filters wrapper -->
- <div class="unified-search__input-wrapper">
- <form class="unified-search__form"
- role="search"
- :class="{'icon-loading-small': isLoading}"
- @submit.prevent.stop="onInputEnter"
- @reset.prevent.stop="onReset">
- <!-- Search input -->
- <input ref="input"
- v-model="query"
- class="unified-search__form-input"
- type="search"
- :class="{'unified-search__form-input--with-reset': !!query}"
- :placeholder="t('core', 'Search {types} …', { types: typesNames.join(', ') })"
- @input="onInputDebounced"
- @keypress.enter.prevent.stop="onInputEnter">
-
- <!-- Reset search button -->
- <input v-if="!!query && !isLoading"
- type="reset"
- class="unified-search__form-reset icon-close"
- :aria-label="t('core','Reset search')"
- value="">
- </form>
-
- <!-- Search filters -->
- <Actions v-if="availableFilters.length > 1" class="unified-search__filters" placement="bottom">
- <ActionButton v-for="type in availableFilters"
- :key="type"
- icon="icon-filter"
- :title="t('core', 'Search for {name} only', { name: typesMap[type] })"
- @click="onClickFilter(`in:${type}`)">
- {{ `in:${type}` }}
- </ActionButton>
- </Actions>
- </div>
-
- <template v-if="!hasResults">
- <!-- Loading placeholders -->
- <SearchResultPlaceholders v-if="isLoading" />
-
- <EmptyContent v-else-if="isValidQuery" icon="icon-search">
- {{ t('core', 'No results for {query}', {query}) }}
- </EmptyContent>
-
- <EmptyContent v-else-if="!isLoading || isShortQuery" icon="icon-search">
- {{ t('core', 'Start typing to search') }}
- <template v-if="isShortQuery" #desc>
- {{ n('core',
- 'Please enter {minSearchLength} character or more to search',
- 'Please enter {minSearchLength} characters or more to search',
- minSearchLength,
- {minSearchLength}) }}
- </template>
- </EmptyContent>
- </template>
-
- <!-- Grouped search results -->
- <template v-else>
- <ul v-for="({list, type}, typesIndex) in orderedResults"
- :key="type"
- class="unified-search__results"
- :class="`unified-search__results-${type}`"
- :aria-label="typesMap[type]">
- <!-- Search results -->
- <li v-for="(result, index) in limitIfAny(list, type)" :key="result.resourceUrl">
- <SearchResult v-bind="result"
- :query="query"
- :focused="focused === 0 && typesIndex === 0 && index === 0"
- @focus="setFocusedIndex" />
- </li>
-
- <!-- Load more button -->
- <li>
- <SearchResult v-if="!reached[type]"
- class="unified-search__result-more"
- :title="loading[type]
- ? t('core', 'Loading more results …')
- : t('core', 'Load more results')"
- :icon-class="loading[type] ? 'icon-loading-small' : ''"
- @click.prevent="loadMore(type)"
- @focus="setFocusedIndex" />
- </li>
- </ul>
- </template>
- </HeaderMenu>
+ <div class="unified-search-menu">
+ <NcHeaderButton v-show="!showLocalSearch"
+ :aria-label="t('core', 'Unified search')"
+ @click="toggleUnifiedSearch">
+ <template #icon>
+ <NcIconSvgWrapper :path="mdiMagnify" />
+ </template>
+ </NcHeaderButton>
+ <UnifiedSearchLocalSearchBar v-if="supportsLocalSearch"
+ :open.sync="showLocalSearch"
+ :query.sync="queryText"
+ @global-search="openModal" />
+ <UnifiedSearchModal :local-search="supportsLocalSearch"
+ :query.sync="queryText"
+ :open.sync="showUnifiedSearch" />
+ </div>
</template>
-<script>
-import { emit } from '@nextcloud/event-bus'
-import { minSearchLength, getTypes, search, defaultLimit, regexFilterIn, regexFilterNot } from '../services/UnifiedSearchService'
-import { showError } from '@nextcloud/dialogs'
-import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
-import Actions from '@nextcloud/vue/dist/Components/Actions'
+<script lang="ts">
+import { mdiMagnify } from '@mdi/js'
+import { emit, subscribe } from '@nextcloud/event-bus'
+import { t } from '@nextcloud/l10n'
+import { useBrowserLocation } from '@vueuse/core'
import debounce from 'debounce'
-import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
-import Magnify from 'vue-material-design-icons/Magnify'
-
-import HeaderMenu from '../components/HeaderMenu'
-import SearchResult from '../components/UnifiedSearch/SearchResult'
-import SearchResultPlaceholders from '../components/UnifiedSearch/SearchResultPlaceholders'
-
-const REQUEST_FAILED = 0
-const REQUEST_OK = 1
-const REQUEST_CANCELED = 2
-
-export default {
+import { defineComponent } from 'vue'
+import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
+import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
+import UnifiedSearchModal from '../components/UnifiedSearch/UnifiedSearchModal.vue'
+import UnifiedSearchLocalSearchBar from '../components/UnifiedSearch/UnifiedSearchLocalSearchBar.vue'
+import logger from '../logger.js'
+
+export default defineComponent({
name: 'UnifiedSearch',
components: {
- ActionButton,
- Actions,
- EmptyContent,
- HeaderMenu,
- Magnify,
- SearchResult,
- SearchResultPlaceholders,
+ NcHeaderButton,
+ NcIconSvgWrapper,
+ UnifiedSearchModal,
+ UnifiedSearchLocalSearchBar,
},
- data() {
- return {
- types: [],
-
- // Cursors per types
- cursors: {},
- // Various search limits per types
- limits: {},
- // Loading types
- loading: {},
- // Reached search types
- reached: {},
- // Pending cancellable requests
- requests: [],
- // List of all results
- results: {},
+ setup() {
+ const currentLocation = useBrowserLocation()
- query: '',
- focused: null,
+ return {
+ currentLocation,
- defaultLimit,
- minSearchLength,
+ mdiMagnify,
+ t,
+ }
+ },
- open: false,
+ data() {
+ return {
+ /** The current search query */
+ queryText: '',
+ /** Open state of the modal */
+ showUnifiedSearch: false,
+ /** Open state of the local search bar */
+ showLocalSearch: false,
}
},
computed: {
- typesIDs() {
- return this.types.map(type => type.id)
- },
- typesNames() {
- return this.types.map(type => type.name)
- },
- typesMap() {
- return this.types.reduce((prev, curr) => {
- prev[curr.id] = curr.name
- return prev
- }, {})
- },
-
- /**
- * Is there any result to display
- * @returns {boolean}
- */
- hasResults() {
- return Object.keys(this.results).length !== 0
- },
-
- /**
- * Return ordered results
- * @returns {Array}
- */
- orderedResults() {
- return this.typesIDs
- .filter(type => type in this.results)
- .map(type => ({
- type,
- list: this.results[type],
- }))
- },
-
- /**
- * Available filters
- * We only show filters that are available on the results
- * @returns {string[]}
- */
- availableFilters() {
- return Object.keys(this.results)
- },
-
- /**
- * Applied filters
- * @returns {string[]}
- */
- usedFiltersIn() {
- let match
- const filters = []
- while ((match = regexFilterIn.exec(this.query)) !== null) {
- filters.push(match[1])
- }
- return filters
- },
-
- /**
- * Applied anti filters
- * @returns {string[]}
- */
- usedFiltersNot() {
- let match
- const filters = []
- while ((match = regexFilterNot.exec(this.query)) !== null) {
- filters.push(match[1])
- }
- return filters
- },
-
- /**
- * Is the current search too short
- * @returns {boolean}
- */
- isShortQuery() {
- return this.query && this.query.trim().length < minSearchLength
- },
-
/**
- * Is the current search valid
- * @returns {boolean}
+ * Debounce emitting the search query by 250ms
*/
- isValidQuery() {
- return this.query && this.query.trim() !== '' && !this.isShortQuery
+ debouncedQueryUpdate() {
+ return debounce(this.emitUpdatedQuery, 250)
},
/**
- * Have we reached the end of all types searches
- * @returns {boolean}
+ * Current page (app) supports local in-app search
*/
- isDoneSearching() {
- return Object.values(this.reached).every(state => state === false)
+ supportsLocalSearch() {
+ // TODO: Make this an API
+ const providerPaths = ['/settings/users', '/apps/deck', '/settings/apps']
+ return providerPaths.some((path) => this.currentLocation.pathname?.includes?.(path))
},
+ },
+ watch: {
/**
- * Is there any search in progress
- * @returns {boolean}
+ * Emit the updated query as eventbus events
+ * (This is debounced)
*/
- isLoading() {
- return Object.values(this.loading).some(state => state === true)
+ queryText() {
+ this.debouncedQueryUpdate()
},
},
- async created() {
- this.types = await getTypes()
- this.logger.debug('Unified Search initialized with the following providers', this.types)
- },
-
mounted() {
- document.addEventListener('keydown', (event) => {
- // if not already opened, allows us to trigger default browser on second keydown
- if (event.ctrlKey && event.key === 'f' && !this.open) {
- event.preventDefault()
- this.open = true
- this.focusInput()
- }
-
- // https://www.w3.org/WAI/GL/wiki/Using_ARIA_menus
- if (this.open) {
- // If arrow down, focus next result
- if (event.key === 'ArrowDown') {
- this.focusNext(event)
- }
+ // register keyboard listener for search shortcut
+ if (window.OCP.Accessibility.disableKeyboardShortcuts() === false) {
+ window.addEventListener('keydown', this.onKeyDown)
+ }
- // If arrow up, focus prev result
- if (event.key === 'ArrowUp') {
- this.focusPrev(event)
- }
- }
+ // Allow external reset of the search / close local search
+ subscribe('nextcloud:unified-search:reset', () => {
+ this.showLocalSearch = false
+ this.queryText = ''
})
- },
-
- methods: {
- async onOpen() {
- this.focusInput()
- // Update types list in the background
- this.types = await getTypes()
- },
- onClose() {
- emit('nextcloud:unified-search.close')
- },
-
- /**
- * Reset the search state
- */
- onReset() {
- emit('nextcloud:unified-search.reset')
- this.logger.debug('Search reset')
- this.query = ''
- this.resetState()
- this.focusInput()
- },
- async resetState() {
- this.cursors = {}
- this.limits = {}
- this.reached = {}
- this.results = {}
- this.focused = null
- await this.cancelPendingRequests()
- },
-
- /**
- * Cancel any ongoing searches
- */
- async cancelPendingRequests() {
- // Cloning so we can keep processing other requests
- const requests = this.requests.slice(0)
- this.requests = []
-
- // Cancel all pending requests
- await Promise.all(requests.map(cancel => cancel()))
- },
-
- /**
- * Focus the search input on next tick
- */
- focusInput() {
- this.$nextTick(() => {
- this.$refs.input.focus()
- this.$refs.input.select()
- })
- },
-
- /**
- * If we have results already, open first one
- * If not, trigger the search again
- */
- onInputEnter() {
- if (this.hasResults) {
- const results = this.getResultsList()
- results[0].click()
- return
- }
- this.onInput()
- },
-
- /**
- * Start searching on input
- */
- async onInput() {
- // emit the search query
- emit('nextcloud:unified-search.search', { query: this.query })
-
- // Do not search if not long enough
- if (this.query.trim() === '' || this.isShortQuery) {
- return
- }
-
- let types = this.typesIDs
- let query = this.query
- // Filter out types
- if (this.usedFiltersNot.length > 0) {
- types = this.typesIDs.filter(type => this.usedFiltersNot.indexOf(type) === -1)
- }
-
- // Only use those filters if any and check if they are valid
- if (this.usedFiltersIn.length > 0) {
- types = this.typesIDs.filter(type => this.usedFiltersIn.indexOf(type) > -1)
- }
-
- // Remove any filters from the query
- query = query.replace(regexFilterIn, '').replace(regexFilterNot, '')
-
- // Reset search if the query changed
- await this.resetState()
- this.$set(this.loading, 'all', true)
- this.logger.debug(`Searching ${query} in`, types)
-
- Promise.all(types.map(async type => {
- try {
- // Init cancellable request
- const { request, cancel } = search({ type, query })
- this.requests.push(cancel)
-
- // Fetch results
- const { data } = await request()
-
- // Process results
- if (data.ocs.data.entries.length > 0) {
- this.$set(this.results, type, data.ocs.data.entries)
- } else {
- this.$delete(this.results, type)
- }
-
- // Save cursor if any
- if (data.ocs.data.cursor) {
- this.$set(this.cursors, type, data.ocs.data.cursor)
- } else if (!data.ocs.data.isPaginated) {
- // If no cursor and no pagination, we save the default amount
- // provided by server's initial state `defaultLimit`
- this.$set(this.limits, type, this.defaultLimit)
- }
-
- // Check if we reached end of pagination
- if (data.ocs.data.entries.length < this.defaultLimit) {
- this.$set(this.reached, type, true)
- }
-
- // If none already focused, focus the first rendered result
- if (this.focused === null) {
- this.focused = 0
- }
- return REQUEST_OK
- } catch (error) {
- this.$delete(this.results, type)
-
- // If this is not a cancelled throw
- if (error.response && error.response.status) {
- this.logger.error(`Error searching for ${this.typesMap[type]}`, error)
- showError(this.t('core', 'An error occurred while searching for {type}', { type: this.typesMap[type] }))
- return REQUEST_FAILED
- }
- return REQUEST_CANCELED
- }
- })).then(results => {
- // Do not declare loading finished if the request have been cancelled
- // This means another search was triggered and we're therefore still loading
- if (results.some(result => result === REQUEST_CANCELED)) {
- return
- }
- // We finished all searches
- this.loading = {}
- })
- },
- onInputDebounced: debounce(function(e) {
- this.onInput(e)
- }, 200),
-
- /**
- * Load more results for the provided type
- * @param {String} type type
- */
- async loadMore(type) {
- // If already loading, ignore
- if (this.loading[type]) {
- return
- }
-
- if (this.cursors[type]) {
- // Init cancellable request
- const { request, cancel } = search({ type, query: this.query, cursor: this.cursors[type] })
- this.requests.push(cancel)
-
- // Fetch results
- const { data } = await request()
-
- // Save cursor if any
- if (data.ocs.data.cursor) {
- this.$set(this.cursors, type, data.ocs.data.cursor)
- }
-
- // Process results
- if (data.ocs.data.entries.length > 0) {
- this.results[type].push(...data.ocs.data.entries)
- }
-
- // Check if we reached end of pagination
- if (data.ocs.data.entries.length < this.defaultLimit) {
- this.$set(this.reached, type, true)
- }
- } else
-
- // If no cursor, we might have all the results already,
- // let's fake pagination and show the next xxx entries
- if (this.limits[type] && this.limits[type] >= 0) {
- this.limits[type] += this.defaultLimit
-
- // Check if we reached end of pagination
- if (this.limits[type] >= this.results[type].length) {
- this.$set(this.reached, type, true)
- }
- }
-
- // Focus result after render
- if (this.focused !== null) {
- this.$nextTick(() => {
- this.focusIndex(this.focused)
- })
- }
- },
+ // Deprecated events to be removed
+ subscribe('nextcloud:unified-search:reset', () => {
+ emit('nextcloud:unified-search.reset', { query: '' })
+ })
+ subscribe('nextcloud:unified-search:search', ({ query }) => {
+ emit('nextcloud:unified-search.search', { query })
+ })
- /**
- * Return a subset of the array if the search provider
- * doesn't supports pagination
- *
- * @param {Array} list the results
- * @param {string} type the type
- * @returns {Array}
- */
- limitIfAny(list, type) {
- if (type in this.limits) {
- return list.slice(0, this.limits[type])
- }
- return list
- },
+ // all done
+ logger.debug('Unified search initialized!')
+ },
- getResultsList() {
- return this.$el.querySelectorAll('.unified-search__results .unified-search__result')
- },
+ beforeDestroy() {
+ // keep in mind to remove the event listener
+ window.removeEventListener('keydown', this.onKeyDown)
+ },
+ methods: {
/**
- * Focus the first result if any
- * @param {Event} event the keydown event
+ * Handle the key down event to open search on `ctrl + F`
+ * @param event The keyboard event
*/
- focusFirst(event) {
- const results = this.getResultsList()
- if (results && results.length > 0) {
- if (event) {
+ onKeyDown(event: KeyboardEvent) {
+ if (event.ctrlKey && event.key === 'f') {
+ // only handle search if not already open - in this case the browser native search should be used
+ if (!this.showLocalSearch && !this.showUnifiedSearch) {
event.preventDefault()
}
- this.focused = 0
- this.focusIndex(this.focused)
+ this.toggleUnifiedSearch()
}
},
/**
- * Focus the next result if any
- * @param {Event} event the keydown event
+ * Toggle the local search if available - otherwise open the unified search modal
*/
- focusNext(event) {
- if (this.focused === null) {
- this.focusFirst(event)
- return
- }
-
- const results = this.getResultsList()
- // If we're not focusing the last, focus the next one
- if (results && results.length > 0 && this.focused + 1 < results.length) {
- event.preventDefault()
- this.focused++
- this.focusIndex(this.focused)
+ toggleUnifiedSearch() {
+ if (this.supportsLocalSearch) {
+ this.showLocalSearch = !this.showLocalSearch
+ } else {
+ this.showUnifiedSearch = !this.showUnifiedSearch
+ this.showLocalSearch = false
}
},
/**
- * Focus the previous result if any
- * @param {Event} event the keydown event
+ * Open the unified search modal
*/
- focusPrev(event) {
- if (this.focused === null) {
- this.focusFirst(event)
- return
- }
-
- const results = this.getResultsList()
- // If we're not focusing the first, focus the previous one
- if (results && results.length > 0 && this.focused > 0) {
- event.preventDefault()
- this.focused--
- this.focusIndex(this.focused)
- }
-
- },
-
- /**
- * Focus the specified result index if it exists
- * @param {number} index the result index
- */
- focusIndex(index) {
- const results = this.getResultsList()
- if (results && results[index]) {
- results[index].focus()
- }
+ openModal() {
+ this.showUnifiedSearch = true
+ this.showLocalSearch = false
},
/**
- * Set the current focused element based on the target
- * @param {Event} event the focus event
+ * Emit the updated search query as eventbus events
*/
- setFocusedIndex(event) {
- const entry = event.target
- const results = this.getResultsList()
- const index = [...results].findIndex(search => search === entry)
- if (index > -1) {
- // let's not use focusIndex as the entry is already focused
- this.focused = index
+ emitUpdatedQuery() {
+ if (this.queryText === '') {
+ emit('nextcloud:unified-search:reset')
+ } else {
+ emit('nextcloud:unified-search:search', { query: this.queryText })
}
},
-
- onClickFilter(filter) {
- this.query = `${this.query} ${filter}`
- .replace(/ {2}/g, ' ')
- .trim()
- this.onInput()
- },
},
-}
+})
</script>
<style lang="scss" scoped>
-$margin: 10px;
-$input-height: 34px;
-$input-padding: 6px;
-
-.unified-search {
- &__trigger {
- width: 20px;
- height: 20px;
- }
-
- &__input-wrapper {
- position: sticky;
- // above search results
- z-index: 2;
- top: 0;
- display: inline-flex;
- align-items: center;
- width: 100%;
- background-color: var(--color-main-background);
- }
-
- &__filters {
- margin: $margin / 2 $margin;
- ul {
- display: inline-flex;
- justify-content: space-between;
- }
- }
-
- &__form {
- position: relative;
- width: 100%;
- margin: $margin;
-
- // Loading spinner
- &::after {
- right: $input-padding;
- left: auto;
- }
-
- &-input,
- &-reset {
- margin: $input-padding / 2;
- }
-
- &-input {
- width: 100%;
- height: $input-height;
- padding: $input-padding;
-
- &,
- &[placeholder],
- &::placeholder {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- // Hide webkit clear search
- &::-webkit-search-decoration,
- &::-webkit-search-cancel-button,
- &::-webkit-search-results-button,
- &::-webkit-search-results-decoration {
- -webkit-appearance: none;
- }
-
- // Ellipsis earlier if reset button is here
- .icon-loading-small &,
- &--with-reset {
- padding-right: $input-height;
- }
- }
-
- &-reset {
- position: absolute;
- top: 0;
- right: 0;
- width: $input-height - $input-padding;
- height: $input-height - $input-padding;
- padding: 0;
- opacity: .5;
- border: none;
- background-color: transparent;
- margin-right: 0;
-
- &:hover,
- &:focus,
- &:active {
- opacity: 1;
- }
- }
- }
-
- &__filters {
- margin-right: $margin / 2;
- }
-
- &__results {
- &::before {
- display: block;
- margin: $margin;
- margin-left: $margin + $input-padding;
- content: attr(aria-label);
- color: var(--color-primary-element);
- }
- }
-
- .unified-search__result-more::v-deep {
- color: var(--color-text-maxcontrast);
- }
-
- .empty-content {
- margin: 10vh 0;
-
- ::v-deep .empty-content__title {
- font-weight: normal;
- font-size: var(--default-font-size);
- padding: 0 15px;
- text-align: center;
- }
- }
+// this is needed to allow us overriding component styles (focus-visible)
+.unified-search-menu {
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
-
</style>
diff --git a/core/src/views/UnsupportedBrowser.vue b/core/src/views/UnsupportedBrowser.vue
new file mode 100644
index 00000000000..408cccf61e9
--- /dev/null
+++ b/core/src/views/UnsupportedBrowser.vue
@@ -0,0 +1,187 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="content-unsupported-browser guest-box">
+ <NcEmptyContent>
+ {{ t('core', 'This browser is not supported') }}
+ <template #icon>
+ <Web />
+ </template>
+ <template #action>
+ <div>
+ <h2>
+ {{ t('core', 'Your browser is not supported. Please upgrade to a newer version or a supported one.') }}
+ </h2>
+ <NcButton class="content-unsupported-browser__continue" type="primary" @click="forceBrowsing">
+ {{ t('core', 'Continue with this unsupported browser') }}
+ </NcButton>
+ </div>
+
+ <ul class="content-unsupported-browser__list">
+ <h3>{{ t('core', 'Supported versions') }}</h3>
+ <li v-for="browser in formattedBrowsersList" :key="browser">
+ {{ browser }}
+ </li>
+ </ul>
+ </template>
+ </NcEmptyContent>
+ </div>
+</template>
+
+<script>
+// eslint-disable-next-line n/no-extraneous-import
+import { agents } from 'caniuse-lite/dist/unpacker/agents.js'
+import { generateUrl, getRootUrl } from '@nextcloud/router'
+import { translate as t, translatePlural as n } from '@nextcloud/l10n'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import Web from 'vue-material-design-icons/Web.vue'
+
+import { browserStorageKey } from '../utils/RedirectUnsupportedBrowsers.js'
+import { supportedBrowsers } from '../services/BrowsersListService.js'
+import browserStorage from '../services/BrowserStorageService.js'
+import logger from '../logger.js'
+
+logger.debug('Supported browsers', { supportedBrowsers })
+
+export default {
+ name: 'UnsupportedBrowser',
+ components: {
+ Web,
+ NcButton,
+ NcEmptyContent,
+ },
+
+ computed: {
+ isMobile() {
+ return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
+ },
+
+ /**
+ * Filter out or include mobile/desktop browsers depending
+ * on the current user platform/device
+ */
+ filteredSupportedBrowsers() {
+ return supportedBrowsers.filter(browser => {
+ if (!browser) {
+ return false
+ }
+
+ if (this.isMobile) {
+ return this.isMobileBrowser(browser)
+ }
+ return !this.isMobileBrowser(browser)
+ })
+ },
+
+ formattedBrowsersList() {
+ const list = {}
+
+ // supportedBrowsers is generated by webpack at compilation time
+ this.filteredSupportedBrowsers.forEach(browser => {
+ const [id, version] = browser.split(' ')
+ if (!list[id] || list[id] < parseFloat(version, 10)) {
+ list[id] = parseFloat(version, 10)
+ }
+ })
+
+ return Object.keys(list).map(id => {
+ if (!agents[id]?.browser) {
+ return null
+ }
+
+ const version = list[id]
+ const name = agents[id]?.browser
+ return this.t('core', '{name} version {version} and above', {
+ name, version,
+ })
+ }).filter(entry => entry !== null)
+ },
+ },
+
+ methods: {
+ t,
+ n,
+
+ // Set the flag allowing this browser and redirect to home
+ forceBrowsing() {
+ browserStorage.setItem(browserStorageKey, true)
+
+ // Redirect if there is the data
+ const urlParams = new URLSearchParams(window.location.search)
+ if (urlParams.has('redirect_url')) {
+ let redirectPath = Buffer.from(urlParams.get('redirect_url'), 'base64').toString() || '/'
+
+ // remove index.php and double slashes
+ redirectPath = redirectPath
+ .replace('index.php', '')
+ .replace(getRootUrl(), '')
+ .replace(/\/\//g, '/')
+
+ // if we have a valid redirect url, use it
+ if (redirectPath.startsWith('/')) {
+ window.location = generateUrl(redirectPath)
+ return
+ }
+ }
+
+ // else redirect to root
+ window.location = generateUrl('/')
+ },
+
+ /**
+ * Detect if the browserslist browser is a mobile one
+ * https://github.com/browserslist/browserslist#query-composition
+ *
+ * @param {string} browser a valid browserlist browser. e.g `and_chr 90`
+ */
+ isMobileBrowser(browser) {
+ browser = browser.toLowerCase()
+ return browser.includes('and_')
+ || browser.includes('android')
+ || browser.includes('ios_')
+ || browser.includes('mobile')
+ || browser.includes('_mob')
+ || browser.includes('samsung')
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+$spacing: 30px;
+
+.content-unsupported-browser {
+ display: flex;
+ justify-content: center;
+ width: 400px;
+ max-width: calc(90vw - 2 * $spacing);
+ margin: auto;
+ padding: $spacing;
+
+ .empty-content {
+ margin: 0;
+
+ :deep(.empty-content__icon) {
+ opacity: 1;
+ }
+ }
+
+ &__continue {
+ display: block;
+ margin: $spacing auto;
+ }
+
+ &__list {
+ margin-top: 2 * $spacing;
+ margin-bottom: $spacing;
+ li {
+ text-align: start;
+ }
+ }
+}
+
+</style>