diff options
-rwxr-xr-x | css/mail.scss | 13 | ||||
-rw-r--r-- | css/mobile.scss | 2 | ||||
-rw-r--r-- | src/components/AddressList.vue | 3 | ||||
-rw-r--r-- | src/components/MailboxThread.vue | 25 | ||||
-rw-r--r-- | src/components/Message.vue | 99 | ||||
-rw-r--r-- | src/components/Thread.vue | 351 | ||||
-rw-r--r-- | src/components/ThreadEnvelope.vue | 325 | ||||
-rw-r--r-- | src/components/ThreadMessage.vue | 90 | ||||
-rw-r--r-- | src/store/actions.js | 38 | ||||
-rw-r--r-- | src/store/getters.js | 6 | ||||
-rw-r--r-- | src/store/mutations.js | 14 | ||||
-rw-r--r-- | src/tests/unit/store/getters.spec.js | 61 | ||||
-rw-r--r-- | src/tests/unit/store/mutations.spec.js | 74 |
13 files changed, 674 insertions, 427 deletions
diff --git a/css/mail.scss b/css/mail.scss index 529039edb..70a3d659e 100755 --- a/css/mail.scss +++ b/css/mail.scss @@ -280,19 +280,6 @@ vertical-align: text-top; } -#mail-message-header { - height: 90px; - z-index: 100; - position: fixed; // ie fallback - position: -webkit-sticky; // ios/safari fallback - position: sticky; - top: $header-height; - background: -webkit-linear-gradient(var(--color-main-background), var(--color-main-background) 80%, rgba(255,255,255,0)); - background: -o-linear-gradient(var(--color-main-background), var(--color-main-background) 80%, rgba(255,255,255,0)); - background: -moz-linear-gradient(var(--color-main-background), var(--color-main-background) 80%, rgba(255,255,255,0)); - background: linear-gradient(var(--color-main-background), var(--color-main-background) 80%, rgba(255,255,255,0)); -} - .transparency { -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=60)"; opacity: .6; diff --git a/css/mobile.scss b/css/mobile.scss index 5699a9916..8866e76da 100644 --- a/css/mobile.scss +++ b/css/mobile.scss @@ -50,7 +50,7 @@ } /* reply-forward actions align to the far right */ - #mail-message-header #mail-message-actions { + #mail-thread-header .mail-message-actions { margin-right: 5px; } } diff --git a/src/components/AddressList.vue b/src/components/AddressList.vue index d0d08fe84..963dfe2b9 100644 --- a/src/components/AddressList.vue +++ b/src/components/AddressList.vue @@ -2,7 +2,8 @@ <span> <template v-for="(entry, idx) in entries"> <Address :key="entry.email" :email="entry.email" :label="entry.label" /><!-- - --><span v-if="idx + 1 < entries.length" :key="entry.email">, </span> + --><span v-if="idx === entries.length - 2" :key="entry.email">{{ t('mail', 'and') }}</span><!-- + --><span v-else-if="idx + 1 < entries.length" :key="entry.email">, </span> </template> </span> </template> diff --git a/src/components/MailboxThread.vue b/src/components/MailboxThread.vue index ac74a34f5..aed78fb96 100644 --- a/src/components/MailboxThread.vue +++ b/src/components/MailboxThread.vue @@ -1,12 +1,12 @@ <template> <AppContent> - <AppDetailsToggle v-if="showMessage" @close="hideMessage" /> + <AppDetailsToggle v-if="showThread" @close="hideMessage" /> <div id="app-content-wrapper"> <AppContentList v-infinite-scroll="onScroll" v-shortkey.once="shortkeys" infinite-scroll-immediate-check="false" - :show-details="showMessage" + :show-details="showThread" :infinite-scroll-disabled="false" :infinite-scroll-distance="10" @shortkey.native="onShortcut"> @@ -56,8 +56,8 @@ </template> </AppContentList> <NewMessageDetail v-if="newMessage" /> - <Thread v-else-if="showMessage" @delete="deleteMessage" /> - <NoMessageSelected v-else-if="hasMessages && !isMobile" /> + <Thread v-else-if="showThread" @delete="deleteMessage" /> + <NoMessageSelected v-else-if="hasEnvelopes && !isMobile" /> </div> </AppContent> </template> @@ -76,7 +76,6 @@ import logger from '../logger' import Mailbox from './Mailbox' import NewMessageDetail from './NewMessageDetail' import NoMessageSelected from './NoMessageSelected' -import { normalizedEnvelopeListId } from '../store/normalization' import Thread from './Thread' import { UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID } from '../store/constants' @@ -131,19 +130,11 @@ export default { unifiedInbox() { return this.$store.getters.getMailbox(UNIFIED_INBOX_ID) }, - hasMessages() { - // it actually should be `return this.$store.getters.getEnvelopes(this.account.id, this.mailbox.databaseId).length > 0` - // but for some reason Vue doesn't track the dependencies on reactive data then and messages in submailboxes can't - // be opened then - const list = this.mailbox.envelopeLists[normalizedEnvelopeListId(this.searchQuery)] - - if (list === undefined) { - return false - } - return list.length > 0 + hasEnvelopes() { + return this.$store.getters.getEnvelopes(this.mailbox.databaseId, this.searchQuery).length > 0 }, - showMessage() { - return (this.mailbox.isPriorityInbox === true || this.hasMessages) && this.$route.name === 'message' + showThread() { + return (this.mailbox.isPriorityInbox === true || this.hasEnvelopes) && this.$route.name === 'message' }, query() { if (this.$route.params.filter === 'starred') { diff --git a/src/components/Message.vue b/src/components/Message.vue new file mode 100644 index 000000000..4feea802d --- /dev/null +++ b/src/components/Message.vue @@ -0,0 +1,99 @@ +<!-- + - @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/>. + --> + +<template> + <div :class="[message.hasHtmlBody ? 'mail-message-body mail-message-body-html' : 'mail-message-body']"> + <div v-if="message.itineraries.length > 0" class="message-itinerary"> + <Itinerary :entries="message.itineraries" :message-id="message.messageId" /> + </div> + <MessageHTMLBody v-if="message.hasHtmlBody" :url="htmlUrl" /> + <MessageEncryptedBody v-else-if="isEncrypted" :body="message.body" :from="from" /> + <MessagePlainTextBody v-else :body="message.body" :signature="message.signature" /> + <Popover v-if="message.attachments[0]" class="attachment-popover"> + <Actions slot="trigger"> + <ActionButton icon="icon-public icon-attachment"> + Attachments + </ActionButton> + </Actions> + <MessageAttachments :attachments="message.attachments" /> + </Popover> + <div id="reply-composer" /> + </div> +</template> + +<script> +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import { generateUrl } from '@nextcloud/router' +import Popover from '@nextcloud/vue/dist/Components/Popover' + +import { html, plain } from '../util/text' +import { isPgpgMessage } from '../crypto/pgp' +import Itinerary from './Itinerary' +import MessageAttachments from './MessageAttachments' +import MessageEncryptedBody from './MessageEncryptedBody' +import MessageHTMLBody from './MessageHTMLBody' +import MessagePlainTextBody from './MessagePlainTextBody' + +export default { + name: 'Message', + components: { + Actions, + ActionButton, + Itinerary, + MessageAttachments, + MessageEncryptedBody, + MessageHTMLBody, + MessagePlainTextBody, + Popover, + }, + props: { + envelope: { + required: true, + type: Object, + }, + message: { + required: true, + type: Object, + }, + }, + computed: { + from() { + return this.message.from.length === 0 ? '?' : this.message.from[0].label || this.message.from[0].email + }, + htmlUrl() { + return generateUrl('/apps/mail/api/messages/{id}/html', { + id: this.envelope.databaseId, + }) + }, + isEncrypted() { + return isPgpgMessage(this.message.hasHtmlBody ? html(this.message.body) : plain(this.message.body)) + }, + }, +} +</script> + +<style lang="scss" scoped> +.v-popover > .trigger > .action-item { + border-radius: 22px; + background-color: var(--color-background-darker); +} +</style> diff --git a/src/components/Thread.vue b/src/components/Thread.vue index 2679cdace..c905b0c39 100644 --- a/src/components/Thread.vue +++ b/src/components/Thread.vue @@ -1,188 +1,71 @@ <template> <AppContentDetails id="mail-message"> <Loading v-if="loading" /> - <Error - v-else-if="!message" - :error="error && error.message ? error.message : t('mail', 'Not found')" - :message="errorMessage" - :data="error" /> <template v-else> - <div id="mail-message-header"> - <div id="mail-message-header-fields"> - <h2 :title="message.subject"> - {{ message.subject }} + <div id="mail-thread-header"> + <div id="mail-thread-header-fields"> + <h2 :title="threadSubject"> + {{ threadSubject }} </h2> <p class="transparency"> - <AddressList :entries="message.from" /> - {{ t('mail', 'to') }} - <AddressList :entries="message.to" /> - <template v-if="message.cc.length"> - ({{ t('mail', 'cc') }} <AddressList :entries="message.cc" />) - </template> + <AddressList :entries="threadParticipants" /> </p> </div> - <div id="mail-message-actions"> - <div - :class=" - hasMultipleRecipients - ? 'icon-reply-all-white button primary' - : 'icon-reply-white button primary' - " - @click="hasMultipleRecipients ? replyAll() : replyMessage()"> - <span class="action-label">{{ t('mail', 'Reply') }}</span> - </div> - <Actions id="mail-message-actions-menu" class="app-content-list-item-menu" menu-align="right"> - <ActionButton v-if="hasMultipleRecipients" icon="icon-reply" @click="replyMessage"> - {{ t('mail', 'Reply to sender only') }} - </ActionButton> - <ActionButton icon="icon-forward" @click="forwardMessage"> - {{ t('mail', 'Forward') }} - </ActionButton> - <ActionButton icon="icon-important" @click.prevent="onToggleImportant"> - {{ - envelope.flags.important ? t('mail', 'Mark unimportant') : t('mail', 'Mark important') - }} - </ActionButton> - <ActionButton icon="icon-starred" @click.prevent="onToggleFlagged"> - {{ - envelope.flags.flagged ? t('mail', 'Mark unfavorite') : t('mail', 'Mark favorite') - }} - </ActionButton> - <ActionButton icon="icon-mail" @click="onToggleSeen"> - {{ envelope.flags.seen ? t('mail', 'Mark unread') : t('mail', 'Mark read') }} - </ActionButton> - - <ActionButton icon="icon-junk" @click="onToggleJunk"> - {{ envelope.flags.junk ? t('mail', 'Mark not spam') : t('mail', 'Mark as spam') }} - </ActionButton> - <ActionButton - :icon="sourceLoading ? 'icon-loading-small' : 'icon-details'" - :disabled="sourceLoading" - @click="onShowSource"> - {{ t('mail', 'View source') }} - </ActionButton> - <ActionButton icon="icon-delete" @click.prevent="onDelete"> - {{ t('mail', 'Delete') }} - </ActionButton> - </Actions> - <Modal v-if="showSource" @close="onCloseSource"> - <div class="section"> - <h2>{{ t('mail', 'Message source') }}</h2> - <pre class="message-source">{{ rawMessage }}</pre> - </div> - </Modal> - </div> - </div> - <ThreadMessage v-for="threadMessage in previousThread" - :key="threadMessage.databaseId" - :message="threadMessage" /> - <div :class="[message.hasHtmlBody ? 'mail-message-body mail-message-body-html' : 'mail-message-body']"> - <div v-if="message.itineraries.length > 0" class="message-itinerary"> - <Itinerary :entries="message.itineraries" :message-id="message.messageId" /> - </div> - <MessageHTMLBody v-if="message.hasHtmlBody" :url="htmlUrl" /> - <MessageEncryptedBody v-else-if="isEncrypted" :body="message.body" :from="from" /> - <MessagePlainTextBody v-else :body="message.body" :signature="message.signature" /> - <Popover v-if="message.attachments[0]" class="attachment-popover"> - <Actions slot="trigger"> - <ActionButton icon="icon-public icon-attachment"> - Attachments - </ActionButton> - </Actions> - <MessageAttachments :attachments="message.attachments" /> - </Popover> - <div id="reply-composer" /> </div> - <ThreadMessage v-for="threadMessage in successiveThread" - :key="threadMessage.databaseId" - :message="threadMessage" /> + <ThreadEnvelope v-for="env in thread" + :key="env.databaseId" + :envelope="env" + :mailbox-id="$route.params.mailboxId" + :expanded="expandedThreads.includes(env.databaseId)" + @toggleExpand="toggleExpand(env.databaseId)" /> </template> </AppContentDetails> </template> <script> -import Actions from '@nextcloud/vue/dist/Components/Actions' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import Popover from '@nextcloud/vue/dist/Components/Popover' import AppContentDetails from '@nextcloud/vue/dist/Components/AppContentDetails' -import axios from '@nextcloud/axios' -import Modal from '@nextcloud/vue/dist/Components/Modal' -import { generateUrl } from '@nextcloud/router' +import { prop, uniqBy } from 'ramda' import AddressList from './AddressList' -import { - buildRecipients as buildReplyRecipients, - buildReplySubject, -} from '../ReplyBuilder' -import Error from './Error' import { getRandomMessageErrorMessage } from '../util/ErrorMessageFactory' -import { html, plain } from '../util/text' -import { isPgpgMessage } from '../crypto/pgp' -import Itinerary from './Itinerary' -import MessageEncryptedBody from './MessageEncryptedBody' -import MessageHTMLBody from './MessageHTMLBody' -import MessagePlainTextBody from './MessagePlainTextBody' import Loading from './Loading' import logger from '../logger' -import MessageAttachments from './MessageAttachments' -import ThreadMessage from './ThreadMessage' +import ThreadEnvelope from './ThreadEnvelope' export default { name: 'Thread', components: { - ActionButton, - Actions, AddressList, AppContentDetails, - Error, - Itinerary, Loading, - MessageAttachments, - MessageEncryptedBody, - MessageHTMLBody, - MessagePlainTextBody, - Modal, - Popover, - ThreadMessage, + ThreadEnvelope, }, data() { return { loading: true, message: undefined, - envelope: undefined, errorMessage: '', error: undefined, - replyRecipient: {}, - replySubject: '', - rawMessage: '', - sourceLoading: false, - showSource: false, + expandedThreads: [], } }, computed: { - from() { - return this.message.from.length === 0 ? '?' : this.message.from[0].label || this.message.from[0].email - }, - isEncrypted() { - return isPgpgMessage(this.message.hasHtmlBody ? html(this.message.body) : plain(this.message.body)) + thread() { + return this.$store.getters.getEnvelopeThread(this.$route.params.threadId) }, - htmlUrl() { - return generateUrl('/apps/mail/api/messages/{id}/html', { - id: this.envelope.databaseId, + threadParticipants() { + const recipients = this.thread.flatMap(envelope => { + return envelope.from.concat(envelope.to).concat(envelope.cc) }) + return uniqBy(prop('email'), recipients) }, - hasMultipleRecipients() { - return this.replyRecipient.to.concat(this.replyRecipient.cc).length > 1 - }, - previousThread() { - // Only show younger messages, not the current one - return this.$store.getters.getMessageThread(this.message.databaseId) - .filter(m => m.dateInt < this.message.dateInt) - }, - successiveThread() { - // Only show older messages, not the current one - return this.$store.getters.getMessageThread(this.message.databaseId) - .filter(m => m.dateInt > this.message.dateInt) + threadSubject() { + const thread = this.thread + if (thread.length === 0) { + console.warn('thread is empty') + return '' + } + return thread[0].subject }, }, watch: { @@ -193,156 +76,68 @@ export default { && from.params.threadId === to.params.threadId && from.params.filter === to.params.filter ) { - logger.debug('navigated but the message is still the same') + logger.debug('navigated but the thread is still the same') return } - logger.debug('navigated to another message', { to, from }) - this.fetchMessage() + logger.debug('navigated to another thread', { to, from }) + this.fetchThread() }, }, created() { - this.fetchMessage() + this.fetchThread() }, methods: { - async fetchMessage() { + toggleExpand(threadId) { + if (!this.expandedThreads.includes(threadId)) { + console.debug(`expand thread ${threadId}`) + this.expandedThreads.push(threadId) + } else { + console.debug(`collapse thread ${threadId}`) + this.expandedThreads = this.expandedThreads.filter(t => t !== threadId) + } + }, + async fetchThread() { this.loading = true - this.message = undefined this.errorMessage = '' this.error = undefined - this.replyRecipient = {} - this.replySubject = '' - - const threadId = this.$route.params.threadId + const threadId = parseInt(this.$route.params.threadId, 10) + this.expandedThreads = [threadId] try { - const [envelope, message] = await Promise.all([ - this.$store.dispatch('fetchEnvelope', threadId), - this.$store.dispatch('fetchMessage', threadId), - ]) - logger.debug('envelope and message fetched', { envelope, message }) - // TODO: add timeout so that message isn't flagged when only viewed + const thread = await this.$store.dispatch('fetchThread', threadId) + logger.debug(`thread for envelope ${threadId} fetched`, { thread }) + // TODO: add timeout so that envelope isn't flagged when only viewed // for a few seconds - if (envelope && envelope.databaseId !== parseInt(this.$route.params.threadId, 10)) { - logger.debug("User navigated away, loaded message won't be shown nor flagged as seen", { - messageId: envelope.databaseId, - threadId: this.$route.params.threadId, + if (threadId !== parseInt(this.$route.params.threadId, 10)) { + logger.debug("User navigated away, loaded envelope won't be shown nor flagged as seen", { + oldId: threadId, + newId: this.$route.params.threadId, }) return } - this.envelope = envelope - this.message = message - - if (envelope === undefined || message === undefined) { - logger.info('message could not be found', { threadId, envelope, message }) + if (thread.length === 0) { + logger.info('thread could not be found and is empty', { threadId }) this.errorMessage = getRandomMessageErrorMessage() this.loading = false return } - const account = this.$store.getters.getAccount(envelope.accountId) - this.replyRecipient = buildReplyRecipients(message, { - label: account.name, - email: account.emailAddress, - }) - - this.replySubject = buildReplySubject(message.subject) - this.loading = false - - if (!envelope.flags.seen) { - return this.$store.dispatch('toggleEnvelopeSeen', envelope) - } } catch (error) { - logger.error('could not load message ', { threadId, error }) + logger.error('could not load envelope thread', { threadId, error }) if (error.isError) { - this.errorMessage = t('mail', 'Could not load your message') + this.errorMessage = t('mail', 'Could not load your message thread') this.error = error this.loading = false } } }, - replyMessage() { - this.$router.push({ - name: 'message', - params: { - mailboxId: this.$route.params.mailboxId, - threadId: 'reply', - filter: this.$route.params.filter ? this.$route.params.filter : undefined, - }, - query: { - messageId: this.$route.params.threadId, - }, - }) - }, - replyAll() { - this.$router.push({ - name: 'message', - params: { - mailboxId: this.$route.params.mailboxId, - threadId: 'replyAll', - filter: this.$route.params.filter ? this.$route.params.filter : undefined, - }, - query: { - messageId: this.$route.params.threadId, - }, - }) - }, - forwardMessage() { - this.$router.push({ - name: 'message', - params: { - mailboxId: this.$route.params.mailboxId, - threadId: 'new', - filter: this.$route.params.filter ? this.$route.params.filter : undefined, - }, - query: { - messageId: this.$route.params.threadId, - }, - }) - }, - onToggleSeen() { - this.$store.dispatch('toggleEnvelopeSeen', this.envelope) - }, - onToggleJunk() { - this.$store.dispatch('toggleEnvelopeJunk', this.envelope) - }, - onDelete() { - this.$emit('delete', this.envelope.databaseId) - this.$store.dispatch('deleteMessage', { - id: this.envelope.databaseId, - }) - }, - async onShowSource() { - this.sourceLoading = true - - try { - const resp = await axios.get( - generateUrl('/apps/mail/api/messages/{id}/source', { - id: this.envelope.databaseId, - }) - ) - - this.rawMessage = resp.data.source - this.showSource = true - } finally { - this.sourceLoading = false - } - }, - onCloseSource() { - this.showSource = false - }, - onToggleImportant() { - this.$store.dispatch('toggleEnvelopeImportant', this.envelope) - }, - onToggleFlagged() { - this.$store.dispatch('toggleEnvelopeFlagged', this.envelope) - }, }, } </script> -<style lang="scss" scoped> +<style lang="scss"> #mail-message { flex-grow: 1; } @@ -353,7 +148,7 @@ export default { position: relative; } -#mail-message-header { +#mail-thread-header { display: flex; flex-direction: row; justify-content: space-between; @@ -365,9 +160,19 @@ export default { box-sizing: content-box !important; height: 44px; width: 100%; + + z-index: 100; + position: fixed; // ie fallback + position: -webkit-sticky; // ios/safari fallback + position: sticky; + top: var(--header-height); + background: -webkit-linear-gradient(var(--color-main-background), var(--color-main-background) 80%, rgba(255,255,255,0)); + background: -o-linear-gradient(var(--color-main-background), var(--color-main-background) 80%, rgba(255,255,255,0)); + background: -moz-linear-gradient(var(--color-main-background), var(--color-main-background) 80%, rgba(255,255,255,0)); + background: linear-gradient(var(--color-main-background), var(--color-main-background) 80%, rgba(255,255,255,0)); } -#mail-message-header-fields { +#mail-thread-header-fields { // initial width width: 0; padding-left: 38px; @@ -390,11 +195,6 @@ export default { } } -.v-popover > .trigger > .action-item { - border-radius: 22px; - background-color: var(--color-background-darker); -} - .attachment-popover { position: sticky; bottom: 12px; @@ -429,15 +229,6 @@ export default { word-wrap: break-word; } -#mail-message-actions { - display: flex; - flex-direction: row; - justify-content: flex-end; - margin-left: 10px; - margin-right: 22px; - height: 44px; -} - .icon-reply-white, .icon-reply-all-white { height: 44px; @@ -460,16 +251,12 @@ export default { } } -#mail-message-actions-menu { - margin-left: 4px; -} - ::v-deep .modal-container { overflow-y: scroll !important; } @media print { - #mail-message-header-fields { + #mail-thread-header-fields { position: relative; } diff --git a/src/components/ThreadEnvelope.vue b/src/components/ThreadEnvelope.vue new file mode 100644 index 000000000..884d2cfa2 --- /dev/null +++ b/src/components/ThreadEnvelope.vue @@ -0,0 +1,325 @@ +<!-- + - @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/>. + --> + +<template> + <div> + <div class="icon-mail"> + <router-link + :to="route" + event="" + class="left" + @click.native.prevent="$emit('toggleExpand', $event)"> + <span class="sender">{{ envelope.from[0].label }}</span> + <span class="preview"> + <!-- TODO: instead of subject it should be shown the first line of the message #2666 --> + {{ envelope.subject }} + </span> + </router-link> + <div class="right"> + <Moment :timestamp="envelope.dateInt" /> + <div + class="button" + :class="{ + 'icon-reply-all-white': hasMultipleRecipients, + 'icon-reply-white': !hasMultipleRecipients, + primary: expanded, + }" + @click="hasMultipleRecipients ? replyAll() : replyMessage()"> + <span class="action-label">{{ t('mail', 'Reply') }}</span> + </div> + <Actions class="app-content-list-item-menu" menu-align="right"> + <ActionButton v-if="hasMultipleRecipients" icon="icon-reply" @click="replyMessage"> + {{ t('mail', 'Reply to sender only') }} + </ActionButton> + <ActionButton icon="icon-forward" @click="forwardMessage"> + {{ t('mail', 'Forward') }} + </ActionButton> + <ActionButton icon="icon-important" @click.prevent="onToggleImportant"> + {{ + envelope.flags.important ? t('mail', 'Mark unimportant') : t('mail', 'Mark important') + }} + </ActionButton> + <ActionButton icon="icon-starred" @click.prevent="onToggleFlagged"> + {{ + envelope.flags.flagged ? t('mail', 'Mark unfavorite') : t('mail', 'Mark favorite') + }} + </ActionButton> + <ActionButton icon="icon-mail" @click="onToggleSeen"> + {{ envelope.flags.seen ? t('mail', 'Mark unread') : t('mail', 'Mark read') }} + </ActionButton> + + <ActionButton icon="icon-junk" @click="onToggleJunk"> + {{ envelope.flags.junk ? t('mail', 'Mark not spam') : t('mail', 'Mark as spam') }} + </ActionButton> + <ActionButton + :icon="sourceLoading ? 'icon-loading-small' : 'icon-details'" + :disabled="sourceLoading" + @click="onShowSource"> + {{ t('mail', 'View source') }} + </ActionButton> + <ActionButton icon="icon-delete" @click.prevent="onDelete"> + {{ t('mail', 'Delete') }} + </ActionButton> + </Actions> + <Modal v-if="showSource" @close="onCloseSource"> + <div class="section"> + <h2>{{ t('mail', 'Message source') }}</h2> + <pre class="message-source">{{ rawMessage }}</pre> + </div> + </Modal> + </div> + </div> + <Loading v-if="loading" /> + <Message v-else-if="message" :envelope="envelope" :message="message" /> + <Error v-else-if="error" + :error="error && error.message ? error.message : t('mail', 'Not found')" + :message="errorMessage" + :data="error" /> + </div> +</template> + +<script> +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import axios from '@nextcloud/axios' +import Error from './Error' +import Loading from './Loading' +import logger from '../logger' +import Message from './Message' +import Moment from './Moment' +import { buildRecipients as buildReplyRecipients } from '../ReplyBuilder' +import { generateUrl } from '@nextcloud/router' + +export default { + name: 'ThreadEnvelope', + components: { + Actions, + ActionButton, + Error, + Loading, + Moment, + Message, + }, + props: { + envelope: { + required: true, + type: Object, + }, + mailboxId: { + required: false, + type: [ + String, + Number, + ], + default: undefined, + }, + expanded: { + required: false, + type: Boolean, + default: false, + }, + }, + data() { + return { + loading: false, + error: undefined, + message: undefined, + rawMessage: '', + sourceLoading: false, + showSource: false, + } + }, + computed: { + route() { + return { + name: 'message', + params: { + mailboxId: this.mailboxId || this.envelope.mailboxId, + threadId: this.envelope.databaseId, + }, + } + }, + hasMultipleRecipients() { + const account = this.$store.getters.getAccount(this.envelope.accountId) + if (!account) { + console.error('account is undefined', { + accountId: this.envelope.accountId, + }) + } + const recipients = buildReplyRecipients(this.envelope, { + label: account.name, + email: account.emailAddress, + }) + return recipients.to.concat(recipients.cc).length > 1 + }, + }, + watch: { + expanded(expanded) { + if (expanded) { + this.fetchMessage() + } else { + this.message = undefined + } + }, + }, + mounted() { + if (this.expanded) { + this.fetchMessage() + } + }, + methods: { + async fetchMessage() { + this.loading = true + + logger.debug(`fetching thread message ${this.envelope.databaseId}`) + + try { + const message = this.message = await this.$store.dispatch('fetchMessage', this.envelope.databaseId) + logger.debug(`message ${this.envelope.databaseId} fetched`, { message }) + + if (!this.envelope.flags.seen) { + return this.$store.dispatch('toggleEnvelopeSeen', this.envelope) + } + + this.loading = false + } catch (error) { + logger.error('Could not fetch message', { error }) + } + }, + replyMessage() { + this.$router.push({ + name: 'message', + params: { + mailboxId: this.$route.params.mailboxId, + threadId: 'reply', + filter: this.$route.params.filter ? this.$route.params.filter : undefined, + }, + query: { + messageId: this.$route.params.threadId, + }, + }) + }, + replyAll() { + this.$router.push({ + name: 'message', + params: { + mailboxId: this.$route.params.mailboxId, + threadId: 'replyAll', + filter: this.$route.params.filter ? this.$route.params.filter : undefined, + }, + query: { + messageId: this.$route.params.threadId, + }, + }) + }, + forwardMessage() { + this.$router.push({ + name: 'message', + params: { + mailboxId: this.$route.params.mailboxId, + threadId: 'new', + filter: this.$route.params.filter ? this.$route.params.filter : undefined, + }, + query: { + messageId: this.$route.params.threadId, + }, + }) + }, + onToggleSeen() { + this.$store.dispatch('toggleEnvelopeSeen', this.envelope) + }, + onToggleJunk() { + this.$store.dispatch('toggleEnvelopeJunk', this.envelope) + }, + onDelete() { + this.$emit('delete', this.envelope.databaseId) + this.$store.dispatch('deleteMessage', { + id: this.envelope.databaseId, + }) + }, + async onShowSource() { + this.sourceLoading = true + + try { + const resp = await axios.get( + generateUrl('/apps/mail/api/messages/{id}/source', { + id: this.envelope.databaseId, + }) + ) + + this.rawMessage = resp.data.source + this.showSource = true + } finally { + this.sourceLoading = false + } + }, + onCloseSource() { + this.showSource = false + }, + onToggleImportant() { + this.$store.dispatch('toggleEnvelopeImportant', this.envelope) + }, + onToggleFlagged() { + this.$store.dispatch('toggleEnvelopeFlagged', this.envelope) + }, + }, +} +</script> + +<style lang="scss" scoped> +.icon-mail { + background-image: var(--icon-mail-000); + background-position: 0 center; + + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + border-bottom: 1px solid var(--color-primary-light); + padding-left: 30px; + margin-bottom: 15px; + horiz-align: center; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + .sender { + font-weight: bold; + } + + .right { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + margin-left: 10px; + margin-right: 22px; + height: 44px; + + .app-content-list-item-menu { + margin-left: 4px; + } + } +} +</style> diff --git a/src/components/ThreadMessage.vue b/src/components/ThreadMessage.vue deleted file mode 100644 index 66966dd62..000000000 --- a/src/components/ThreadMessage.vue +++ /dev/null @@ -1,90 +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/>. - --> - -<template> - <router-link :to="route" - class="icon-mail"> - <div class="left"> - <div class="sender"> - {{ message.from[0].label }} - </div> - <div class="preview"> - <!-- TODO: instead of subject it should be shown the first line of the message #2666 --> - {{ message.subject }} - </div> - </div> - <div class="right"> - <div><Moment :timestamp="message.dateInt" /></div> - </div> - </router-link> -</template> - -<script> -import Moment from './Moment' - -export default { - name: 'ThreadMessage', - components: { Moment }, - props: { - message: { - required: true, - type: Object, - }, - }, - computed: { - route() { - return { - name: 'message', - params: { - mailboxId: this.message.mailboxId, - threadId: this.message.databaseId, - }, - } - }, - }, -} -</script> - -<style lang="scss" scoped> -.icon-mail { - background-image: var(--icon-mail-000); - background-position: 0 center; - - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - - border-bottom: 1px solid var(--color-primary-light); - padding-left: 30px; - margin-bottom: 15px; - horiz-align: center; - opacity: 0.7; - - &:hover { - opacity: 1; - } - - .sender { - font-weight: bold; - } -} -</style> diff --git a/src/store/actions.js b/src/store/actions.js index 76da406e6..4a5c441a4 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -227,24 +227,23 @@ export default { updated, }) }, - fetchEnvelope({ commit, getters }, id) { + async fetchEnvelope({ commit, getters }, id) { const cached = getters.getEnvelope(id) if (cached) { logger.debug(`using cached value for envelope ${id}`) return cached } - return fetchEnvelope(id).then((envelope) => { - // Only commit if not undefined (not found) - if (envelope) { - commit('addEnvelope', { - envelope, - }) - } + const envelope = await fetchEnvelope(id) + // Only commit if not undefined (not found) + if (envelope) { + commit('addEnvelope', { + envelope, + }) + } - // Always use the object from the store - return getters.getEnvelope(id) - }) + // Always use the object from the store + return getters.getEnvelope(id) }, fetchEnvelopes({ state, commit, getters, dispatch }, { mailboxId, query }) { const mailbox = getters.getMailbox(mailboxId) @@ -392,7 +391,6 @@ export default { }) }, syncEnvelopes({ commit, getters, dispatch }, { mailboxId, query, init = false }) { - // TODO: use mailboxId const mailbox = getters.getMailbox(mailboxId) if (mailbox.isUnified) { @@ -438,6 +436,8 @@ export default { const ids = getters.getEnvelopes(mailboxId, query).map((env) => env.databaseId) return syncEnvelopes(mailbox.accountId, mailboxId, ids, query, init) .then((syncData) => { + logger.info(`mailbox ${mailboxId} synchronized, ${syncData.newMessages.length} new, ${syncData.changedMessages.length} changed and ${syncData.vanishedMessages.length} vanished messages`) + const unifiedMailbox = getters.getUnifiedMailbox(mailbox.specialRole) syncData.newMessages.forEach((envelope) => { @@ -654,6 +654,14 @@ export default { }) }) }, + async fetchThread({ getters, commit }, id) { + const thread = await fetchThread(id) + commit('addEnvelopeThread', { + id, + thread, + }) + return thread + }, async fetchMessage({ getters, commit }, id) { const message = await fetchMessage(id) // Only commit if not undefined (not found) @@ -661,12 +669,6 @@ export default { commit('addMessage', { message, }) - - const thread = await fetchThread(message.databaseId) - commit('addMessageThread', { - message, - thread, - }) } return message }, diff --git a/src/store/getters.js b/src/store/getters.js index cad479d03..99bb27e3c 100644 --- a/src/store/getters.js +++ b/src/store/getters.js @@ -61,7 +61,9 @@ export const getters = { getMessage: (state) => (id) => { return state.messages[id] }, - getMessageThread: (state) => (id) => { - return sortBy(prop('dateInt'), state.messages[id]?.thread ?? []) + getEnvelopeThread: (state) => (id) => { + const thread = state.envelopes[id]?.thread ?? [] + const envelopes = thread.map(id => state.envelopes[id]) + return sortBy(prop('dateInt'), envelopes) }, } diff --git a/src/store/mutations.js b/src/store/mutations.js index 0e893d0f9..69d2e15ee 100644 --- a/src/store/mutations.js +++ b/src/store/mutations.js @@ -32,7 +32,7 @@ import { UNIFIED_ACCOUNT_ID } from './constants' const addMailboxToState = curry((state, account, mailbox) => { mailbox.accountId = account.id mailbox.mailboxes = [] - mailbox.envelopeLists = {} + Vue.set(mailbox, 'envelopeLists', {}) // Add all mailboxes (including submailboxes to state, but only toplevel to account const nameWithoutPrefix = account.personalNamespace @@ -216,8 +216,16 @@ export default { addMessage(state, { message }) { Vue.set(state.messages, message.databaseId, message) }, - addMessageThread(state, { message, thread }) { - Vue.set(message, 'thread', thread) + addEnvelopeThread(state, { id, thread }) { + // Store the envelopes, merge into any existing object if one exists + thread.map(e => { + const mailbox = state.mailboxes[e.mailboxId] + Vue.set(e, 'accountId', mailbox.accountId) + Vue.set(state.envelopes, e.databaseId, Object.assign({}, state.envelopes[e.databaseId] || {}, e)) + }) + + // Store the references + Vue.set(state.envelopes[id], 'thread', thread.map(e => e.databaseId)) }, removeMessage(state, { id }) { Vue.delete(state.messages, id) diff --git a/src/tests/unit/store/getters.spec.js b/src/tests/unit/store/getters.spec.js index b6e438f81..420ee686e 100644 --- a/src/tests/unit/store/getters.spec.js +++ b/src/tests/unit/store/getters.spec.js @@ -68,4 +68,65 @@ describe('Vuex store getters', () => { accountId: 13, }) }) + it('returns an envelope\'s empty thread', () => { + state.envelopes[1] = { + databaseId: 1, + uid: 101, + mailboxId: 13, + } + const getters = bindGetters() + + const thread = getters.getEnvelopeThread(1) + + expect(thread).to.be.empty + }) + it('returns an envelope\'s empty thread', () => { + state.envelopes[1] = { + databaseId: 1, + uid: 101, + mailboxId: 13, + thread: [ + 1, + 2, + 3, + ] + } + state.envelopes[2] = { + databaseId: 1, + uid: 101, + mailboxId: 13, + } + state.envelopes[3] = { + databaseId: 1, + uid: 101, + mailboxId: 13, + } + const getters = bindGetters() + + const thread = getters.getEnvelopeThread(1) + + expect(thread).to.not.be.empty + expect(thread).to.deep.equal([ + { + databaseId: 1, + uid: 101, + mailboxId: 13, + thread: [ + 1, + 2, + 3, + ] + }, + { + databaseId: 1, + uid: 101, + mailboxId: 13, + }, + { + databaseId: 1, + uid: 101, + mailboxId: 13, + }, + ]) + }) }) diff --git a/src/tests/unit/store/mutations.spec.js b/src/tests/unit/store/mutations.spec.js index 7d7a6eb49..73471f03e 100644 --- a/src/tests/unit/store/mutations.spec.js +++ b/src/tests/unit/store/mutations.spec.js @@ -824,4 +824,78 @@ describe('Vuex store mutations', () => { }, }) }) + + it('adds a thread', () => { + const envelope = { + databaseId: 123, + mailboxId: 27, + uid: 12345, + } + const state = { + mailboxes: { + 27: { + databaseId: 27, + accountId: 1, + }, + }, + envelopes: { + [envelope.databaseId]: envelope, + }, + } + + mutations.addEnvelopeThread(state, { + id: 123, + thread: [ + { + databaseId: 122, + mailboxId: 27, + uid: 12344, + }, + { + databaseId: 123, + mailboxId: 27, + uid: 12345, + }, + { + databaseId: 124, + mailboxId: 27, + uid: 12346, + } + ], + }) + + expect(state).to.deep.equal({ + mailboxes: { + 27: { + databaseId: 27, + accountId: 1, + }, + }, + envelopes: { + 122: { + databaseId: 122, + mailboxId: 27, + accountId: 1, + uid: 12344, + }, + 123: { + databaseId: 123, + mailboxId: 27, + accountId: 1, + uid: 12345, + thread: [ + 122, + 123, + 124, + ] + }, + 124: { + databaseId: 124, + mailboxId: 27, + accountId: 1, + uid: 12346, + }, + }, + }) + }) }) |