diff --git a/css/tasks-talk.scss b/css/tasks-talk.scss new file mode 100644 index 00000000..6b66c779 --- /dev/null +++ b/css/tasks-talk.scss @@ -0,0 +1 @@ +@include icon-black-white('tasks', 'tasks', 1); diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index f4fdb7a7..0178e552 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -23,10 +23,12 @@ namespace OCA\Tasks\AppInfo; use OCA\Tasks\Dashboard\TasksWidget; +use OCA\Tasks\Listeners\BeforeTemplateRenderedListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; class Application extends App implements IBootstrap { @@ -42,6 +44,8 @@ class Application extends App implements IBootstrap { public function register(IRegistrationContext $context): void { $context->registerDashboardWidget(TasksWidget::class); + + $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); } public function boot(IBootContext $context): void { diff --git a/lib/Listeners/BeforeTemplateRenderedListener.php b/lib/Listeners/BeforeTemplateRenderedListener.php new file mode 100644 index 00000000..48809bb2 --- /dev/null +++ b/lib/Listeners/BeforeTemplateRenderedListener.php @@ -0,0 +1,58 @@ + + * + * @author Julius Härtl + * @author Jakob Röhrl + * + * @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 . + * + */ + +declare(strict_types=1); + + +namespace OCA\Tasks\Listeners; + +use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\IRequest; +use OCP\Util; + +class BeforeTemplateRenderedListener implements IEventListener { + private $request; + + public function __construct(IRequest $request) { + $this->request = $request; + } + + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent)) { + return; + } + + if (!$event->isLoggedIn()) { + return; + } + + $pathInfo = $this->request->getPathInfo(); + if (strpos($pathInfo, '/call/') === 0 || strpos($pathInfo, '/apps/spreed') === 0) { + Util::addScript('tasks', 'tasks-talk'); + Util::addStyle('tasks', 'tasks-talk'); + } + } +} diff --git a/src/components/AppSidebar/CalendarPickerItem.vue b/src/components/AppSidebar/CalendarPickerItem.vue index 8d26433e..ec376bf5 100644 --- a/src/components/AppSidebar/CalendarPickerItem.vue +++ b/src/components/AppSidebar/CalendarPickerItem.vue @@ -30,7 +30,7 @@ License along with this library. If not, see . :disabled="isDisabled" :options="calendars" :value="calendar" - :placeholder="$t('tasks', 'Select a calendar')" + :placeholder="translate('tasks', 'Select a calendar')" @select="change"> @@ -52,6 +52,7 @@ License along with this library. If not, see . + + diff --git a/src/helpers/selector.js b/src/helpers/selector.js new file mode 100644 index 00000000..46b75037 --- /dev/null +++ b/src/helpers/selector.js @@ -0,0 +1,50 @@ +/* + * @copyright Copyright (c) 2021 Jakob Röhrl + * + * @author Julius Härtl + * @author Jakob Röhrl + * + * @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 . + * + */ +import Vue from 'vue' +import store from '../store/store.js' + +const buildSelector = (selector, propsData = {}) => { + return new Promise((resolve, reject) => { + const container = document.createElement('div') + document.getElementById('body-user').append(container) + const View = Vue.extend(selector) + const ComponentVM = new View({ + propsData, + store, + }).$mount(container) + ComponentVM.$root.$on('close', () => { + ComponentVM.$el.remove() + ComponentVM.$destroy() + reject(new Error('Selection canceled')) + }) + ComponentVM.$root.$on('select', (id) => { + ComponentVM.$el.remove() + ComponentVM.$destroy() + resolve(id) + }) + }) +} + +export { + buildSelector, +} diff --git a/src/store/calendars.js b/src/store/calendars.js index 469ebc1a..f0d7ea6b 100644 --- a/src/store/calendars.js +++ b/src/store/calendars.js @@ -320,7 +320,14 @@ const mutations = { */ addCalendar(state, calendar) { // extend the calendar to the default model - state.calendars.push(Object.assign({}, calendarModel, calendar)) + calendar = Object.assign({}, calendarModel, calendar) + // Only add the calendar if it is not already present + if (state.calendars.some(cal => { + return cal.id === calendar.id + })) { + return + } + state.calendars.push(calendar) }, /** diff --git a/src/store/tasks.js b/src/store/tasks.js index 36fd814c..c395cb57 100644 --- a/src/store/tasks.js +++ b/src/store/tasks.js @@ -28,6 +28,7 @@ import { findVTODObyUid } from './cdav-requests.js' import { showError } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' +import { translate as t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import ICAL from 'ical.js' @@ -702,36 +703,34 @@ const actions = { const vData = ICAL.stringify(task.jCal) if (!task.dav) { - await task.calendar.dav.createVObject(vData) - .then((response) => { - Vue.set(task, 'dav', response) - task.syncStatus = new SyncStatus('success', OCA.Tasks.$t('tasks', 'Successfully created the task.')) - context.commit('appendTask', task) - context.commit('addTaskToCalendar', task) - const parent = context.getters.getTaskByUid(task.related) - context.commit('addTaskToParent', { task, parent }) + const response = await task.calendar.dav.createVObject(vData) + Vue.set(task, 'dav', response) + task.syncStatus = new SyncStatus('success', t('tasks', 'Successfully created the task.')) + context.commit('appendTask', task) + context.commit('addTaskToCalendar', task) + const parent = context.getters.getTaskByUid(task.related) + context.commit('addTaskToParent', { task, parent }) - // Open the details view for the new task - const calendarId = context.rootState.route.params.calendarId - const collectionId = context.rootState.route.params.collectionId - // Only open the details view if there is enough space or if it is already open. - if (document.documentElement.clientWidth >= 768 || context.rootState.route.params.taskId !== undefined) { - if (calendarId) { - router.push({ name: 'calendarsTask', params: { calendarId, taskId: task.uri } }) - } else if (collectionId) { - if (collectionId === 'week') { - router.push({ - name: 'collectionsParamTask', - params: { collectionId, taskId: task.uri, collectionParam: '0' }, - }) - } else { - router.push({ name: 'collectionsTask', params: { collectionId, taskId: task.uri } }) - } - } + // In case the task is created in Talk, we don't have a route + // Only open the details view if there is enough space or if it is already open. + if (context.rootState.route !== undefined && (document.documentElement.clientWidth >= 768 || context.rootState.route?.params.taskId !== undefined)) { + // Open the details view for the new task + const calendarId = context.rootState.route.params.calendarId + const collectionId = context.rootState.route.params.collectionId + if (calendarId) { + router.push({ name: 'calendarsTask', params: { calendarId, taskId: task.uri } }) + } else if (collectionId) { + if (collectionId === 'week') { + router.push({ + name: 'collectionsParamTask', + params: { collectionId, taskId: task.uri, collectionParam: '0' }, + }) + } else { + router.push({ name: 'collectionsTask', params: { collectionId, taskId: task.uri } }) } - - }) - .catch((error) => { throw error }) + } + } + return task } }, @@ -783,7 +782,7 @@ const actions = { }) .catch((error) => { console.debug(error) - task.syncStatus = new SyncStatus('error', OCA.Tasks.$t('tasks', 'Could not delete the task.')) + task.syncStatus = new SyncStatus('error', t('tasks', 'Could not delete the task.')) }) } else { deleteTaskFromStore() @@ -875,10 +874,10 @@ const actions = { if (!task.conflict) { task.dav.data = vCalendar - task.syncStatus = new SyncStatus('sync', OCA.Tasks.$t('tasks', 'Synchronizing to the server.')) + task.syncStatus = new SyncStatus('sync', t('tasks', 'Synchronizing to the server.')) return task.dav.update() .then((response) => { - task.syncStatus = new SyncStatus('success', OCA.Tasks.$t('tasks', 'Task successfully saved to server.')) + task.syncStatus = new SyncStatus('success', t('tasks', 'Task successfully saved to server.')) }) .catch((error) => { // Wrong etag, we most likely have a conflict @@ -886,13 +885,13 @@ const actions = { // Saving the new etag so that the user can manually // trigger a fetchCompleteData without any further errors task.conflict = error.xhr.getResponseHeader('etag') - task.syncStatus = new SyncStatus('conflict', OCA.Tasks.$t('tasks', 'Could not update the task because it was changed on the server. Please click to refresh it, local changes will be discarded.')) + task.syncStatus = new SyncStatus('conflict', t('tasks', 'Could not update the task because it was changed on the server. Please click to refresh it, local changes will be discarded.')) } else { - task.syncStatus = new SyncStatus('error', OCA.Tasks.$t('tasks', 'Could not update the task.')) + task.syncStatus = new SyncStatus('error', t('tasks', 'Could not update the task.')) } }) } else { - task.syncStatus = new SyncStatus('conflict', OCA.Tasks.$t('tasks', 'Could not update the task because it was changed on the server. Please click to refresh it, local changes will be discarded.')) + task.syncStatus = new SyncStatus('conflict', t('tasks', 'Could not update the task because it was changed on the server. Please click to refresh it, local changes will be discarded.')) } }, @@ -1304,7 +1303,7 @@ const actions = { return task.dav.fetchCompleteData() .then((response) => { const newTask = new Task(task.dav.data, task.calendar) - task.syncStatus = new SyncStatus('success', OCA.Tasks.$t('tasks', 'Successfully updated the task.')) + task.syncStatus = new SyncStatus('success', t('tasks', 'Successfully updated the task.')) task.conflict = false context.commit('updateTask', newTask) }) @@ -1376,11 +1375,11 @@ const actions = { // Remove the task from the calendar, add it to the new one context.commit('addTaskToCalendar', task) context.commit('appendTask', task) - task.syncStatus = new SyncStatus('success', OCA.Tasks.$t('tasks', 'Task successfully moved to new calendar.')) + task.syncStatus = new SyncStatus('success', t('tasks', 'Task successfully moved to new calendar.')) }) .catch((error) => { console.error(error) - showError(OCA.Tasks.$t('tasks', 'An error occurred')) + showError(t('tasks', 'An error occurred')) }) } diff --git a/src/talk.js b/src/talk.js new file mode 100644 index 00000000..4b6b10d8 --- /dev/null +++ b/src/talk.js @@ -0,0 +1,70 @@ +/** + * Nextcloud - Tasks + * + * @author Julius Härtl + * @copyright 2021 Julius Härtl + * + * @author Jakob Röhrl + * @copyright 2021 Jakob Röhrl + * + * @author Raimund Schlüßler + * @copyright 2021 Raimund Schlüßler + * + * 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 . + * + */ + +import TaskCreateDialog from './components/TaskCreateDialog' +import { buildSelector } from './helpers/selector' + +import { getRequestToken } from '@nextcloud/auth' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import { generateUrl, generateFilePath } from '@nextcloud/router' + +import Vue from 'vue' + +// eslint-disable-next-line +__webpack_nonce__ = btoa(getRequestToken()) + +// eslint-disable-next-line +__webpack_public_path__ = generateFilePath('tasks', '', 'js/') + +Vue.prototype.t = t +Vue.prototype.n = n +Vue.prototype.OC = OC + +window.addEventListener('DOMContentLoaded', () => { + if (!window.OCA?.Talk?.registerMessageAction) { + return + } + + window.OCA.Talk.registerMessageAction({ + label: t('tasks', 'Create a task'), + icon: 'icon-tasks', + async callback({ message: { message, actorDisplayName }, metadata: { name: conversationName, token: conversationToken } }) { + const shortenedMessageCandidate = message.replace(/^(.{255}[^\s]*).*/, '$1') + const shortenedMessage = shortenedMessageCandidate === '' ? message.substr(0, 255) : shortenedMessageCandidate + try { + await buildSelector(TaskCreateDialog, { + title: shortenedMessage, + description: message + '\n\n' + '[' + + t('tasks', 'Message from {author} in {conversationName}', { author: actorDisplayName, conversationName }) + + '](' + generateUrl('/call/' + conversationToken) + ')', + }) + } catch (e) { + console.debug('Task creation dialog was canceled') + } + }, + }) +}) diff --git a/tests/javascript/unit/OC.js b/tests/javascript/unit/OC.js index 9864cf7b..6968f685 100644 --- a/tests/javascript/unit/OC.js +++ b/tests/javascript/unit/OC.js @@ -24,4 +24,14 @@ export class OC { return false } + L10N = { + translate(app, text) { + return text + }, + + translatePlural(app, text) { + return text + }, + } + } diff --git a/webpack.js b/webpack.js index 55a56183..b8f15fb0 100644 --- a/webpack.js +++ b/webpack.js @@ -4,6 +4,7 @@ const path = require('path') webpackConfig.entry = { ...webpackConfig.entry, dashboard: path.join(__dirname, 'src', 'dashboard.js'), + talk: path.join(__dirname, 'src', 'talk.js'), } module.exports = webpackConfig