Create task from talk message

Signed-off-by: Jakob Röhrl <jakob.roehrl@web.de>
This commit is contained in:
Jakob Röhrl 2021-06-23 15:12:30 +02:00 committed by Raimund Schlüßler
parent 4873158062
commit 033d851712
No known key found for this signature in database
GPG Key ID: 036FA7EB1A599178
11 changed files with 449 additions and 41 deletions

1
css/tasks-talk.scss Normal file
View File

@ -0,0 +1 @@
@include icon-black-white('tasks', 'tasks', 1);

View File

@ -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 {

View File

@ -0,0 +1,58 @@
<?php
/**
* @copyright Copyright (c) 2021 Jakob Röhrl <jakob.roehrl@web.de>
*
* @author Julius Härtl <jus@bitgrid.net>
* @author Jakob Röhrl <jakob.roehrl@web.de>
*
* @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/>.
*
*/
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');
}
}
}

View File

@ -30,7 +30,7 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
:disabled="isDisabled"
:options="calendars"
:value="calendar"
:placeholder="$t('tasks', 'Select a calendar')"
:placeholder="translate('tasks', 'Select a calendar')"
@select="change">
<template slot="singleLabel" slot-scope="scope">
<CalendarPickerOption v-bind="scope.option" />
@ -43,7 +43,7 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
color=""
owner=""
:is-shared-with-me="false"
:display-name="$t('tasks', 'No calendar matches the search.')" />
:display-name="translate('tasks', 'No calendar matches the search.')" />
</template>
</Multiselect>
</div>
@ -52,6 +52,7 @@ License along with this library. If not, see <http://www.gnu.org/licenses/>.
<script>
import CalendarPickerOption from './CalendarPickerOption.vue'
import { translate } from '@nextcloud/l10n'
import Multiselect from '@nextcloud/vue/dist/Components/Multiselect'
export default {
@ -62,7 +63,7 @@ export default {
props: {
calendar: {
type: Object,
required: true,
default: null,
},
calendars: {
type: Array,
@ -79,6 +80,7 @@ export default {
},
},
methods: {
translate,
/**
* TODO: this should emit the calendar id instead
* @param {Object} newCalendar The selected calendar

View File

@ -0,0 +1,206 @@
<!--
Nextcloud - Tasks
@author Julius Härtl
@copyright 2021 Julius Härtl <jus@bitgrid.net>
@author Jakob Röhrl
@copyright 2021 Jakob Röhrl <jakob.roehrl@web.de>
@author Raimund Schlüßler
@copyright 2021 Raimund Schlüßler <raimund.schluessler@mailbox.org>
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/>.
-->
<template>
<Modal class="task-selector" @close="close">
<div class="modal-scroller">
<div v-if="!creating && !created" id="modal-inner" :class="{ 'icon-loading': loading }">
<h3>{{ t('tasks', 'Create a new task') }}</h3>
<CalendarPickerItem
:disabled="loading"
:calendar="pendingCalendar"
:calendars="writableCalendars"
@changeCalendar="changeCalendar" />
<input v-model="pendingTitle"
type="text"
:placeholder="t('tasks', 'Create a new task')"
:disabled="loading">
<textarea v-model="pendingDescription"
:disabled="loading" />
<div class="modal-buttons">
<button @click="close">
{{ t('tasks', 'Cancel') }}
</button>
<button :disabled="loading"
class="primary"
@click="addTask">
{{ t('tasks', 'Create task') }}
</button>
</div>
</div>
<div v-else id="modal-inner">
<EmptyContent v-if="creating" icon="icon-loading">
{{ t('tasks', 'Creating the new task…') }}
</EmptyContent>
<EmptyContent v-else-if="created" icon="icon-checkmark">
{{ t('tasks', '"{task}" was added to "{calendar}"', { task: pendingTitle, calendar: pendingCalendar.displayName }, undefined, { sanitize: false, escape: false }) }}
<template #desc>
<button class="primary" @click="openNewTask">
{{ t('tasks', 'Open task') }}
</button>
<button @click="close">
{{ t('tasks', 'Close') }}
</button>
</template>
</EmptyContent>
</div>
</div>
</Modal>
</template>
<script>
import CalendarPickerItem from './AppSidebar/CalendarPickerItem.vue'
import client from '../services/cdav.js'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import Modal from '@nextcloud/vue/dist/Components/Modal'
import { mapGetters, mapActions } from 'vuex'
export default {
name: 'TaskCreateDialog',
components: {
CalendarPickerItem,
EmptyContent,
Modal,
},
props: {
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
},
data() {
return {
pendingTitle: '',
pendingDescription: '',
pendingCalendar: null,
loading: true,
creating: false,
created: false,
newTask: null,
}
},
computed: {
...mapGetters({
writableCalendars: 'getSortedWritableCalendars',
defaultCalendar: 'getDefaultCalendar',
}),
},
beforeMount() {
this.fetchCalendars()
},
mounted() {
this.pendingTitle = this.title
this.pendingDescription = this.description
},
methods: {
...mapActions([
'createTask',
]),
t,
changeCalendar(calendar) {
this.pendingCalendar = calendar
},
close() {
this.$root.$emit('close')
},
async fetchCalendars() {
this.loading = true
await client.connect({ enableCalDAV: true })
await this.$store.dispatch('fetchCurrentUserPrincipal')
await this.$store.dispatch('getCalendars')
// TODO: Would be good to select the default calendar instead of the first one
this.pendingCalendar = this.writableCalendars[0]
this.loading = false
},
async addTask() {
this.creating = true
const task = {
summary: this.pendingTitle,
note: this.pendingDescription,
calendar: this.pendingCalendar,
}
this.newTask = await this.createTask(task)
this.creating = false
this.created = true
},
openNewTask() {
window.location = generateUrl('apps/tasks') + `/#/calendars/${this.pendingCalendar.id}/tasks/${this.newTask.uri}`
},
},
}
</script>
<style lang="scss" scoped>
.modal-scroller {
overflow: scroll;
max-height: calc(80vh - 40px);
margin: 10px;
}
#modal-inner {
width: 90vw;
max-width: 400px;
padding: 10px;
min-height: 200px;
}
input, textarea {
width: 100%;
margin-bottom: 10px !important;
}
.modal-buttons {
display: flex;
justify-content: flex-end;
}
.task-selector::v-deep .modal-container {
overflow: visible !important;
}
</style>

50
src/helpers/selector.js Normal file
View File

@ -0,0 +1,50 @@
/*
* @copyright Copyright (c) 2021 Jakob Röhrl <jakob.roehrl@web.de>
*
* @author Julius Härtl <jus@bitgrid.net>
* @author Jakob Röhrl <jakob.roehrl@web.de>
*
* @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 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,
}

View File

@ -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)
},
/**

View File

@ -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'))
})
}

70
src/talk.js Normal file
View File

@ -0,0 +1,70 @@
/**
* Nextcloud - Tasks
*
* @author Julius Härtl
* @copyright 2021 Julius Härtl <jus@bitgrid.net>
*
* @author Jakob Röhrl
* @copyright 2021 Jakob Röhrl <jakob.roehrl@web.de>
*
* @author Raimund Schlüßler
* @copyright 2021 Raimund Schlüßler <raimund.schluessler@mailbox.org>
*
* 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/>.
*
*/
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')
}
},
})
})

View File

@ -24,4 +24,14 @@ export class OC {
return false
}
L10N = {
translate(app, text) {
return text
},
translatePlural(app, text) {
return text
},
}
}

View File

@ -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