diff options
author | Richard Steinmetz <richard@steinmetz.cloud> | 2022-01-21 18:42:03 +0300 |
---|---|---|
committer | Richard Steinmetz <richard@steinmetz.cloud> | 2022-03-24 18:34:56 +0300 |
commit | 791520d41cd98b11d55cc6f55b7592f5f722b484 (patch) | |
tree | 69a88ea1e8b4be6de95faf8c0a03f8d6f7047883 /src | |
parent | 564737d05673f3632f80714203b6460bf76303e9 (diff) |
Implement frontend for outbox
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
Diffstat (limited to 'src')
-rw-r--r-- | src/components/Composer.vue | 28 | ||||
-rw-r--r-- | src/components/Navigation.vue | 47 | ||||
-rw-r--r-- | src/components/NavigationOutbox.vue | 66 | ||||
-rw-r--r-- | src/components/NewMessageModal.vue | 37 | ||||
-rw-r--r-- | src/components/Outbox.vue | 115 | ||||
-rw-r--r-- | src/components/OutboxComposer.vue | 180 | ||||
-rw-r--r-- | src/components/OutboxMessageList.vue | 64 | ||||
-rw-r--r-- | src/components/OutboxMessageListItem.vue | 147 | ||||
-rw-r--r-- | src/mixins/OutboxAvatarMixin.js | 37 | ||||
-rw-r--r-- | src/router.js | 5 | ||||
-rw-r--r-- | src/service/OutboxService.js | 64 | ||||
-rw-r--r-- | src/store/index.js | 115 | ||||
-rw-r--r-- | src/store/outbox/actions.js | 67 | ||||
-rw-r--r-- | src/store/outbox/getters.js | 26 | ||||
-rw-r--r-- | src/store/outbox/index.js | 34 | ||||
-rw-r--r-- | src/store/outbox/mutations.js | 38 | ||||
-rw-r--r-- | src/store/outbox/state.js | 25 | ||||
-rw-r--r-- | src/views/Home.vue | 5 |
18 files changed, 1010 insertions, 90 deletions
diff --git a/src/components/Composer.vue b/src/components/Composer.vue index 54c4b026d..e4d7fd93a 100644 --- a/src/components/Composer.vue +++ b/src/components/Composer.vue @@ -407,7 +407,7 @@ export default { keyRing: undefined, keysMissing: [], }, - editorMode: 'html', + editorMode: (this.body?.format !== 'html') ? 'plaintext' : 'html', addShareLink: t('mail', 'Add share link from {productName} Files', { productName: OC?.theme?.name ?? 'Nextcloud' }), requestMdn: false, appendSignature: true, @@ -562,7 +562,8 @@ export default { } else { this.selectedAlias = this.aliases[0] } - if (previous === NO_ALIAS_SET) { + // only overwrite editormode if no body provided + if (previous === NO_ALIAS_SET && !this.body) { this.editorMode = this.selectedAlias.editorMode } }, @@ -598,28 +599,15 @@ export default { } this.bodyVal = html(body).value }, - recipientToRfc822(recipient) { - if (recipient.email === recipient.label) { - // From mailto or sender without proper label - return recipient.email - } else if (recipient.label === '') { - // Invalid label - return recipient.email - } else if (recipient.email.search(/^[a-zA-Z]+:/) === 0) { - // Group integration - return recipient.email - } else { - // Proper layout with label - return `"${recipient.label}" <${recipient.email}>` - } - }, getMessageData(id) { return { + // TODO: Rename account to accountId account: this.selectedAlias.id, + accountId: this.selectedAlias.id, aliasId: this.selectedAlias.aliasId, - to: this.selectTo.map(this.recipientToRfc822).join(', '), - cc: this.selectCc.map(this.recipientToRfc822).join(', '), - bcc: this.selectBcc.map(this.recipientToRfc822).join(', '), + to: this.selectTo, + cc: this.selectCc, + bcc: this.selectBcc, draftId: id, subject: this.subjectVal, body: this.encrypt ? plain(this.bodyVal) : html(this.bodyVal), diff --git a/src/components/Navigation.vue b/src/components/Navigation.vue index fcb11f200..e35bfe220 100644 --- a/src/components/Navigation.vue +++ b/src/components/Navigation.vue @@ -34,6 +34,16 @@ @click="refreshMailbox" /> <template #list> <ul id="accounts-list"> + <!-- Special mailboxes first --> + <NavigationMailbox + v-for="mailbox in unifiedMailboxes" + :key="'mailbox-' + mailbox.databaseId" + :account="unifiedAccount" + :mailbox="mailbox" /> + <NavigationOutbox /> + <AppNavigationSpacer /> + + <!-- All other mailboxes grouped by their account --> <template v-for="group in menu"> <NavigationAccount v-if="group.account" @@ -87,9 +97,11 @@ import logger from '../logger' import NavigationAccount from './NavigationAccount' import NavigationAccountExpandCollapse from './NavigationAccountExpandCollapse' import NavigationMailbox from './NavigationMailbox' +import NavigationOutbox from './NavigationOutbox' import AppSettingsMenu from '../components/AppSettingsMenu' import NewMessageModal from './NewMessageModal' +import { UNIFIED_ACCOUNT_ID } from '../store/constants' export default { name: 'Navigation', @@ -103,6 +115,7 @@ export default { NavigationAccountExpandCollapse, NavigationMailbox, NewMessageModal, + NavigationOutbox, }, data() { return { @@ -112,20 +125,22 @@ export default { }, computed: { menu() { - return this.$store.getters.accounts.map((account) => { - const mailboxes = this.$store.getters.getMailboxes(account.id) - const nonSpecialRoleMailboxes = mailboxes.filter( - (mailbox) => this.isCollapsed(account, mailbox) - ) - const isCollapsible = nonSpecialRoleMailboxes.length > 1 + return this.$store.getters.accounts + .filter(account => account.id !== UNIFIED_ACCOUNT_ID) + .map(account => { + const mailboxes = this.$store.getters.getMailboxes(account.id) + const nonSpecialRoleMailboxes = mailboxes.filter( + (mailbox) => this.isCollapsed(account, mailbox) + ) + const isCollapsible = nonSpecialRoleMailboxes.length > 1 - return { - id: account.id, - account, - mailboxes, - isCollapsible, - } - }) + return { + id: account.id, + account, + mailboxes, + isCollapsible, + } + }) }, currentMailbox() { if (this.$route.name === 'message' || this.$route.name === 'mailbox') { @@ -133,6 +148,12 @@ export default { } return undefined }, + unifiedAccount() { + return this.$store.getters.getAccount(UNIFIED_ACCOUNT_ID) + }, + unifiedMailboxes() { + return this.$store.getters.getMailboxes(UNIFIED_ACCOUNT_ID) + }, }, methods: { isCollapsed(account, mailbox) { diff --git a/src/components/NavigationOutbox.vue b/src/components/NavigationOutbox.vue new file mode 100644 index 000000000..0a8a40f49 --- /dev/null +++ b/src/components/NavigationOutbox.vue @@ -0,0 +1,66 @@ +<!-- + - @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + - + - @author Richard Steinmetz <richard@steinmetz.cloud> + - + - @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> + <AppNavigationItem + id="navigation-outbox" + key="navigation-outbox" + icon="icon-mail" + :title="t('mail', 'Outbox')" + :to="to"> + <template #counter> + <CounterBubble v-if="count"> + {{ count }} + </CounterBubble> + </template> + </AppNavigationItem> +</template> + +<script> +import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem' +import CounterBubble from '@nextcloud/vue/dist/Components/CounterBubble' + +export default { + name: 'NavigationOutbox', + components: { + AppNavigationItem, + CounterBubble, + }, + computed: { + to() { + return { + name: 'outbox', + } + }, + count() { + // TODO: fix outbox initial state that doesnt happen when the page loads. + return Object.keys(this.$store.getters['outbox/getAllMessages']).length + }, + }, +} +</script> + +<style lang="scss" scoped> +::v-deep .counter-bubble__counter { + margin-right: 43px; +} +</style> diff --git a/src/components/NewMessageModal.vue b/src/components/NewMessageModal.vue index c8abf4bdf..fe37d7584 100644 --- a/src/components/NewMessageModal.vue +++ b/src/components/NewMessageModal.vue @@ -25,6 +25,7 @@ import { showWarning } from '@nextcloud/dialogs' import Axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { translate as t } from '@nextcloud/l10n' + export default { name: 'NewMessageModal', components: { @@ -112,6 +113,9 @@ export default { } const dataForServer = { ...data, + to: data.to.map(this.recipientToRfc822).join(', '), + cc: data.cc.map(this.recipientToRfc822).join(', '), + bcc: data.bcc.map(this.recipientToRfc822).join(', '), body: data.isHtml ? data.body.value : toPlain(data.body).value, } const { id } = await saveDraft(data.account, dataForServer) @@ -127,11 +131,25 @@ export default { }, async sendMessage(data) { logger.debug('sending message', { data }) + const now = new Date().getTime() const dataForServer = { - ...data, + accountId: data.account, + sendAt: Math.floor(now / 1000), // JS timestamp is in milliseconds + subject: data.subject, body: data.isHtml ? data.body.value : toPlain(data.body).value, + isHtml: data.isHtml, + isMdn: false, + inReplyToMessageId: '', + to: data.to, + cc: data.cc, + bcc: data.bcc, + attachmentIds: [], } - await sendMessage(data.account, dataForServer) + const message = await this.$store.dispatch('outbox/enqueueMessage', { + message: dataForServer, + }) + + await this.$store.dispatch('outbox/sendMessage', { id: message.id }) // Remove old draft envelope this.$store.commit('removeEnvelope', { id: data.draftId }) @@ -177,6 +195,21 @@ export default { } this.fetchingTemplateMessage = false }, + recipientToRfc822(recipient) { + if (recipient.email === recipient.label) { + // From mailto or sender without proper label + return recipient.email + } else if (recipient.label === '') { + // Invalid label + return recipient.email + } else if (recipient.email.search(/^[a-zA-Z]+:/) === 0) { + // Group integration + return recipient.email + } else { + // Proper layout with label + return `"${recipient.label}" <${recipient.email}>` + } + }, }, } diff --git a/src/components/Outbox.vue b/src/components/Outbox.vue new file mode 100644 index 000000000..52f43ab88 --- /dev/null +++ b/src/components/Outbox.vue @@ -0,0 +1,115 @@ +<!-- + - @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + - + - @author Richard Steinmetz <richard@steinmetz.cloud> + - + - @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> + <AppContent + pane-config-key="mail" + :show-details="isMessageShown" + @update:showDetails="hideMessage"> + <!-- List --> + <template #list> + <AppContentList> + <Error + v-if="error" + :error="t('mail', 'Could not open outbox')" + message="" + role="alert" /> + <Loading + v-else-if="loading" + :hint="t('mail', 'Loading messages …')" /> + <EmptyMailbox v-else-if="messages.length === 0" /> + <OutboxMessageList + v-else + :messages="messages" /> + </AppContentList> + </template> + </AppContent> +</template> + +<script> +import AppContent from '@nextcloud/vue/dist/Components/AppContent' +import AppContentList from '@nextcloud/vue/dist/Components/AppContentList' +import Loading from './Loading' +import Error from './Error' +import EmptyMailbox from './EmptyMailbox' +import OutboxMessageList from './OutboxMessageList' +import logger from '../logger' + +export default { + name: 'Outbox', + components: { + AppContent, + AppContentList, + Error, + Loading, + EmptyMailbox, + OutboxMessageList, + }, + data() { + return { + error: false, + loading: false, + } + }, + computed: { + isMessageShown() { + return !!this.$route.params.messageId + }, + currentMessage() { + if (!this.isMessageShown) { + return null + } + + return this.$store.getters['outbox/getMessage'](this.$route.params.messageId) + }, + messages() { + return this.$store.getters['outbox/getAllMessages'] + }, + }, + async mounted() { + await this.fetchMessages() + }, + methods: { + hideMessage() { + this.$router.push({ + name: 'outbox', + }) + }, + async fetchMessages() { + this.loading = true + this.error = false + + try { + await this.$store.dispatch('outbox/fetchMessages') + } catch (error) { + this.error = true + logger.error('Failed to fetch outbox messages', { error }) + } + + this.loading = false + }, + }, +} +</script> + +<style lang="scss" scoped> +</style> diff --git a/src/components/OutboxComposer.vue b/src/components/OutboxComposer.vue new file mode 100644 index 000000000..d88f83007 --- /dev/null +++ b/src/components/OutboxComposer.vue @@ -0,0 +1,180 @@ +<template> + <Modal + size="normal" + :title="t('mail', 'Outbox draft')" + @close="$emit('close')"> + <Composer + :from-account="message.accountId" + :to="message.to" + :cc="message.cc" + :bcc="message.bcc" + :subject="message.subject" + :body="outboxBody" + :draft="saveDraft" + :send="sendMessage" + :forwarded-messages="forwardedMessages" /> + </Modal> +</template> +<script> +import Modal from '@nextcloud/vue/dist/Components/Modal' +import logger from '../logger' +import { html, plain, toPlain } from '../util/text' +import Composer from './Composer' +import Axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import { translate as t } from '@nextcloud/l10n' + +export default { + name: 'OutboxComposer', + components: { + Modal, + Composer, + }, + props: { + message: { + type: Object, + required: true, + }, + forwardedMessages: { + type: Array, + required: false, + default: () => [], + }, + templateMessageId: { + type: Number, + required: false, + default: undefined, + }, + }, + data() { + return { + original: undefined, + originalBody: undefined, + fetchingTemplateMessage: true, + } + }, + computed: { + outboxBody() { + if (this.message.html) { + return html(this.message.text) + } + return plain(this.message.text) + }, + }, + created() { + this.fetchOriginalMessage() + }, + methods: { + stringToRecipients(str) { + if (str === undefined) { + return [] + } + + return [ + { + label: str, + email: str, + }, + ] + }, + async saveDraft(data) { + if (data.draftId === undefined && this.draft) { + logger.debug('draft data does not have a draftId, adding one', { + draft: this.draft, + data, + id: this.draft.databaseId, + }) + data.draftId = this.draft.databaseId + } + const dataForServer = { + ...data, + body: data.isHtml ? data.body.value : toPlain(data.body).value, + } + await this.$store.dispatch('outbox/updateMessage', { message: dataForServer, id: this.message.id }) + }, + async sendMessage(data) { + logger.debug('sending message', { data }) + const now = new Date().getTime() + const dataForServer = { + accountId: data.account, + sendAt: Math.floor(now / 1000), // JS timestamp is in milliseconds + subject: data.subject, + body: data.isHtml ? data.body.value : toPlain(data.body).value, + isHtml: data.isHtml, + isMdn: false, + inReplyToMessageId: '', + to: data.to, + cc: data.cc, + bcc: data.bcc, + attachmentIds: [], + } + const message = await this.$store.dispatch('outbox/enqueueMessage', { + message: dataForServer, + }) + + await this.$store.dispatch('outbox/sendMessage', { id: message.id }) + + // Remove old draft envelope + this.$store.commit('removeEnvelope', { id: data.draftId }) + this.$store.commit('removeMessage', { id: data.draftId }) + }, + async fetchOriginalMessage() { + if (this.templateMessageId === undefined) { + this.fetchingTemplateMessage = false + return + } + this.loading = true + this.error = undefined + this.errorMessage = '' + + logger.debug(`fetching original message ${this.templateMessageId}`) + + try { + const message = await this.$store.dispatch('fetchMessage', this.templateMessageId) + logger.debug('original message fetched', { message }) + this.original = message + + let body = plain(message.body || '') + if (message.hasHtmlBody) { + logger.debug('original message has HTML body') + const resp = await Axios.get( + generateUrl('/apps/mail/api/messages/{id}/html?plain=true', { + Id: this.templateMessageId, + }) + ) + + body = html(resp.data) + } + this.originalBody = body + } catch (error) { + logger.error('could not load original message ' + this.templateMessageId, { error }) + if (error.isError) { + this.errorMessage = t('mail', 'Could not load original message') + this.error = error + this.loading = false + } + } finally { + this.loading = false + } + this.fetchingTemplateMessage = false + }, + }, +} + +</script> + +<style lang="scss" scoped> +@media only screen and (max-width: 600px) { + ::v-deep .modal-container { + max-width: 80%; + } +} +::v-deep .modal-container { + width: 80%; + min-height: 60%; +} +::v-deep .modal-wrapper .modal-container { + overflow-y: auto !important; + overflow-x: auto !important; +} +</style> diff --git a/src/components/OutboxMessageList.vue b/src/components/OutboxMessageList.vue new file mode 100644 index 000000000..02bb07db0 --- /dev/null +++ b/src/components/OutboxMessageList.vue @@ -0,0 +1,64 @@ +<!-- + - @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + - + - @author Richard Steinmetz <richard@steinmetz.cloud> + - + - @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> + <OutboxMessageListItem + v-for="message in messages" + :key="message.id" + :message="message" + @click="openModal" /> + <OutboxComposer v-if="showOutboxComposer" :message="messages" @close="showOutboxComposer = false;" /> + </div> +</template> + +<script> +import OutboxMessageListItem from './OutboxMessageListItem' +import OutboxComposer from './OutboxComposer' + +export default { + name: 'OutboxMessageList', + components: { + OutboxMessageListItem, + OutboxComposer, + }, + props: { + messages: { + type: Array, + required: true, + }, + }, + data() { + return { + showOutboxComposer: false, + } + }, + methods: { + openModal() { + this.showOutboxComposer = true + }, + }, +} +</script> + +<style lang="scss" scoped> +</style> diff --git a/src/components/OutboxMessageListItem.vue b/src/components/OutboxMessageListItem.vue new file mode 100644 index 000000000..b3fb86edd --- /dev/null +++ b/src/components/OutboxMessageListItem.vue @@ -0,0 +1,147 @@ +<!-- + - @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + - + - @author Richard Steinmetz <richard@steinmetz.cloud> + - + - @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> + <ListItem + class="outbox-message" + :class="{ selected }" + :title="title" + @click="openModal"> + <template #icon> + <div + class="account-color" + :style="{'background-color': accountColor}" /> + <Avatar :display-name="avatarDisplayName" :email="avatarEmail" /> + </template> + <template #subtitle> + {{ message.subject }} + </template> + <template slot="actions"> + <ActionButton + icon="icon-checkmark" + :close-after-click="true" + @click="sendMessage"> + {{ t('mail', 'Send message now') }} + </ActionButton> + <ActionButton + icon="icon-delete" + :close-after-click="true" + @click="deleteMessage"> + {{ t('mail', 'Delete message') }} + </ActionButton> + </template> + <template #extra> + <OutboxComposer v-if="showOutboxComposer" :message="message" @close="showOutboxComposer = false;" /> + </template> + </ListItem> +</template> + +<script> +import ListItem from '@nextcloud/vue/dist/Components/ListItem' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import Avatar from './Avatar' +import { calculateAccountColor } from '../util/AccountColor' +import OutboxAvatarMixin from '../mixins/OutboxAvatarMixin' +import logger from '../logger' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { matchError } from '../errors/match' +import { translate as t } from '@nextcloud/l10n' +import OutboxComposer from './OutboxComposer' + +export default { + name: 'OutboxMessageListItem', + components: { + ListItem, + Avatar, + ActionButton, + OutboxComposer, + }, + mixins: [ + OutboxAvatarMixin, + ], + props: { + message: { + type: Object, + required: true, + }, + }, + data() { + return { + showOutboxComposer: false, + } + }, + computed: { + selected() { + return this.$route.params.messageId === this.message.id + }, + accountColor() { + const account = this.$store.getters.getAccount(this.message.accountId) + return calculateAccountColor(account?.emailAddress ?? '') + }, + title() { + return 'Due in 30 seconds' + }, + }, + methods: { + async deleteMessage() { + try { + await this.$store.dispatch('outbox/deleteMessage', { + id: this.message.id, + }) + } catch (error) { + showError(await matchError(error, { + default(error) { + logger.error('could not delete message', error) + return t('mail', 'Could not delete message') + }, + })) + } + }, + async sendMessage(data) { + logger.debug('sending message', { data }) + await this.$store.dispatch('outbox/sendMessage', { id: this.message.id }) + showSuccess(t('mail', 'Message sent')) + }, + openModal() { + this.showOutboxComposer = true + }, + }, +} +</script> + +<style lang="scss" scoped> +.outbox-message { + list-style: none; + &.active { + background-color: var(--color-background-dark); + border-radius: 16px; + } + + .account-color { + position: absolute; + left: 0; + width: 2px; + height: 69px; + z-index: 1; + } +} +</style> diff --git a/src/mixins/OutboxAvatarMixin.js b/src/mixins/OutboxAvatarMixin.js new file mode 100644 index 000000000..23a456269 --- /dev/null +++ b/src/mixins/OutboxAvatarMixin.js @@ -0,0 +1,37 @@ +/** + * @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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/>. + * + */ + +export default { + computed: { + recipients() { + const to = this.message.to ?? [] + const cc = this.message.cc ?? [] + return [...to, ...cc] + }, + avatarDisplayName() { + return this.recipients[0]?.label ?? this.recipients[0]?.email ?? '?' + }, + avatarEmail() { + return this.recipients[0]?.email ?? '' + }, + }, +} diff --git a/src/router.js b/src/router.js index 313b83116..7d7fb3d90 100644 --- a/src/router.js +++ b/src/router.js @@ -33,6 +33,11 @@ export default new Router({ component: Home, }, { + path: '/outbox', + name: 'outbox', + component: Home, + }, + { path: '/setup', name: 'setup', component: Setup, diff --git a/src/service/OutboxService.js b/src/service/OutboxService.js new file mode 100644 index 000000000..0f89297cb --- /dev/null +++ b/src/service/OutboxService.js @@ -0,0 +1,64 @@ +/** + * @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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 { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' + +export async function fetchMessages() { + const url = generateUrl('/apps/mail/api/outbox') + + const { data } = await axios.get(url) + return data.data +} + +export async function deleteMessage(id) { + const url = generateUrl('/apps/mail/api/outbox/{id}', { + id, + }) + + const { data } = await axios.delete(url) + return data +} + +export async function enqueueMessage(message) { + const url = generateUrl('/apps/mail/api/outbox') + + const { data } = await axios.post(url, message) + return data.data +} +export async function updateMessage(message, id) { + const url = generateUrl('/apps/mail/api/outbox/{id}', { + id, + }) + + const { data } = await axios.put(url, message) + return data.data +} + +export async function sendMessage(id) { + const url = generateUrl('/apps/mail/api/outbox/{id}', { + id, + }) + + const { data } = await axios.post(url) + return data +} diff --git a/src/store/index.js b/src/store/index.js index 278b1435e..0c14b3fb6 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -30,66 +30,73 @@ import { import actions from './actions' import { getters } from './getters' import mutations from './mutations' +import outbox from './outbox' Vue.use(Vuex) export default new Vuex.Store({ strict: process.env.NODE_ENV !== 'production', - state: { - preferences: {}, - accounts: { - [UNIFIED_ACCOUNT_ID]: { - id: UNIFIED_ACCOUNT_ID, - accountId: UNIFIED_ACCOUNT_ID, - isUnified: true, - mailboxes: [PRIORITY_INBOX_ID, UNIFIED_INBOX_ID], - aliases: [], - collapsed: false, - emailAddress: '', - name: '', - showSubscribedOnly: false, - signatureAboveQuote: false, + modules: { + root: { + namespaced: false, + state: { + preferences: {}, + accounts: { + [UNIFIED_ACCOUNT_ID]: { + id: UNIFIED_ACCOUNT_ID, + accountId: UNIFIED_ACCOUNT_ID, + isUnified: true, + mailboxes: [PRIORITY_INBOX_ID, UNIFIED_INBOX_ID], + aliases: [], + collapsed: false, + emailAddress: '', + name: '', + showSubscribedOnly: false, + signatureAboveQuote: false, + }, + }, + accountList: [UNIFIED_ACCOUNT_ID], + allAccountSettings: [], + mailboxes: { + [UNIFIED_INBOX_ID]: { + id: UNIFIED_INBOX_ID, + databaseId: UNIFIED_INBOX_ID, + accountId: 0, + attributes: ['\\subscribed'], + isUnified: true, + path: '', + specialUse: ['inbox'], + specialRole: 'inbox', + unread: 0, + mailboxes: [], + envelopeLists: {}, + name: 'UNIFIED INBOX', + }, + [PRIORITY_INBOX_ID]: { + id: PRIORITY_INBOX_ID, + databaseId: PRIORITY_INBOX_ID, + accountId: 0, + attributes: ['\\subscribed'], + isPriorityInbox: true, + path: '', + specialUse: ['inbox'], + specialRole: 'inbox', + unread: 0, + mailboxes: [], + envelopeLists: {}, + name: 'PRIORITY INBOX', + }, + }, + envelopes: {}, + messages: {}, + autocompleteEntries: [], + tags: {}, + tagList: [], }, + getters, + mutations, + actions, }, - accountList: [UNIFIED_ACCOUNT_ID], - allAccountSettings: [], - mailboxes: { - [UNIFIED_INBOX_ID]: { - id: UNIFIED_INBOX_ID, - databaseId: UNIFIED_INBOX_ID, - accountId: 0, - attributes: ['\\subscribed'], - isUnified: true, - path: '', - specialUse: ['inbox'], - specialRole: 'inbox', - unread: 0, - mailboxes: [], - envelopeLists: {}, - name: 'UNIFIED INBOX', - }, - [PRIORITY_INBOX_ID]: { - id: PRIORITY_INBOX_ID, - databaseId: PRIORITY_INBOX_ID, - accountId: 0, - attributes: ['\\subscribed'], - isPriorityInbox: true, - path: '', - specialUse: ['inbox'], - specialRole: 'inbox', - unread: 0, - mailboxes: [], - envelopeLists: {}, - name: 'PRIORITY INBOX', - }, - }, - envelopes: {}, - messages: {}, - autocompleteEntries: [], - tags: {}, - tagList: [], + outbox, }, - getters, - mutations, - actions, }) diff --git a/src/store/outbox/actions.js b/src/store/outbox/actions.js new file mode 100644 index 000000000..a7661b756 --- /dev/null +++ b/src/store/outbox/actions.js @@ -0,0 +1,67 @@ +/** + * @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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 * as OutboxService from '../../service/OutboxService' +import logger from '../../logger' + +export default { + async fetchMessages({ commit }) { + const { messages } = await OutboxService.fetchMessages() + for (const message of messages) { + commit('addMessage', { message }) + } + return messages + }, + + async deleteMessage({ commit }, { id }) { + await OutboxService.deleteMessage(id) + commit('deleteMessage', { id }) + }, + + async enqueueMessage({ commit }, { message }) { + message = await OutboxService.enqueueMessage(message) + commit('addMessage', { message }) + return message + }, + async updateMessage({ commit }, { message, id }) { + const updatedMessage = await OutboxService.updateMessage(message, id) + commit('updateMessage', { message: updatedMessage }) + return updatedMessage + }, + + async sendMessage({ commit, getters }, { id }) { + // Skip if the message has been deleted/undone in the meantime + if (!getters.getMessage(id)) { + logger.debug('Skipped sending message that was undone') + return + } + + try { + await OutboxService.sendMessage(id) + } catch (error) { + logger.error(`Failed to send message ${id} from outbox`) + return + } + + commit('deleteMessage', id) + }, +} diff --git a/src/store/outbox/getters.js b/src/store/outbox/getters.js new file mode 100644 index 000000000..aaf8ec2a5 --- /dev/null +++ b/src/store/outbox/getters.js @@ -0,0 +1,26 @@ +/** + * @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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/>. + * + */ + +export default { + getAllMessages: state => Object.values(state.messages), + getMessage: state => id => state.messages[id], +} diff --git a/src/store/outbox/index.js b/src/store/outbox/index.js new file mode 100644 index 000000000..0b2edea0f --- /dev/null +++ b/src/store/outbox/index.js @@ -0,0 +1,34 @@ +/** + * @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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 actions from './actions.js' +import getters from './getters.js' +import mutations from './mutations.js' +import state from './state.js' + +export default { + namespaced: true, + actions, + getters, + mutations, + state, +} diff --git a/src/store/outbox/mutations.js b/src/store/outbox/mutations.js new file mode 100644 index 000000000..7fe3bc4d3 --- /dev/null +++ b/src/store/outbox/mutations.js @@ -0,0 +1,38 @@ +/** + * @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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' + +export default { + addMessage(state, { message }) { + const existing = state.messages[message.id] ?? {} + Vue.set(state.messages, message.id, Object.assign({}, existing, message)) + }, + + deleteMessage(state, { id }) { + Vue.delete(state.messages, id) + }, + updateMessage(state, { message }) { + const existing = state.messages[message.id] + Vue.set(state.messages, message.id, Object.assign({}, existing, message)) + }, +} diff --git a/src/store/outbox/state.js b/src/store/outbox/state.js new file mode 100644 index 000000000..77c82c33d --- /dev/null +++ b/src/store/outbox/state.js @@ -0,0 +1,25 @@ +/** + * @copyright Copyright (c) 2022 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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/>. + * + */ + +export default { + messages: {}, +} diff --git a/src/views/Home.vue b/src/views/Home.vue index f7f9b1628..033790701 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -3,7 +3,8 @@ app-name="mail" @shortkey.native="onNewMessage"> <Navigation /> - <MailboxThread v-if="activeAccount" + <Outbox v-if="$route.name === 'outbox'" /> + <MailboxThread v-else-if="activeAccount" :account="activeAccount" :mailbox="activeMailbox" /> </Content> @@ -16,6 +17,7 @@ import isMobile from '@nextcloud/vue/dist/Mixins/isMobile' import logger from '../logger' import MailboxThread from '../components/MailboxThread' import Navigation from '../components/Navigation' +import Outbox from '../components/Outbox' export default { name: 'Home', @@ -23,6 +25,7 @@ export default { Content, MailboxThread, Navigation, + Outbox, }, mixins: [isMobile], computed: { |