Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/mail.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristoph Wurst <ChristophWurst@users.noreply.github.com>2020-09-08 19:56:20 +0300
committerGitHub <noreply@github.com>2020-09-08 19:56:20 +0300
commitc1efbd505314c6ee65c28a1adebce3c76b0becaf (patch)
treea95c451305ece153042b3aed7116f92aaffff942
parentf8a33d4f69371928a1b337e589e5c1c036944e41 (diff)
parentce8968edfcef32ff835604ee7f4cf369d651b21d (diff)
Merge pull request #3540 from nextcloud/enhancement/messages-in-thread
Make it possible to view messages of the same thread directly
-rwxr-xr-xcss/mail.scss13
-rw-r--r--css/mobile.scss2
-rw-r--r--src/components/AddressList.vue3
-rw-r--r--src/components/MailboxThread.vue25
-rw-r--r--src/components/Message.vue99
-rw-r--r--src/components/Thread.vue351
-rw-r--r--src/components/ThreadEnvelope.vue325
-rw-r--r--src/components/ThreadMessage.vue90
-rw-r--r--src/store/actions.js38
-rw-r--r--src/store/getters.js6
-rw-r--r--src/store/mutations.js14
-rw-r--r--src/tests/unit/store/getters.spec.js61
-rw-r--r--src/tests/unit/store/mutations.spec.js74
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,
+ },
+ },
+ })
+ })
})