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 <christoph@winzerhof-wurst.at>2020-02-20 18:12:11 +0300
committerChristoph Wurst <christoph@winzerhof-wurst.at>2020-02-24 09:12:16 +0300
commit46916ee4f396e71075fd228a87ef294e38825dea (patch)
treefbe43ebdd9862f3992ea60da7af40ea1c627a05e
parent5be88a693bf7e516b1159f3c0500302c0adbe514 (diff)
Make envelope lists more versatile
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
-rw-r--r--appinfo/routes.php5
-rw-r--r--lib/Contracts/IMailSearch.php13
-rwxr-xr-xlib/Controller/MessagesController.php40
-rw-r--r--lib/Service/Search/MailSearch.php21
-rw-r--r--package-lock.json11
-rw-r--r--package.json2
-rw-r--r--src/components/EmptyMailbox.vue (renamed from src/components/EmptyFolder.vue)2
-rw-r--r--src/components/EnvelopeList.vue221
-rw-r--r--src/components/FolderContent.vue226
-rw-r--r--src/components/Mailbox.vue359
-rw-r--r--src/components/MailboxMessage.vue145
-rw-r--r--src/components/Message.vue68
-rw-r--r--src/service/MessageService.js54
-rw-r--r--src/store/actions.js362
-rw-r--r--src/store/getters.js68
-rw-r--r--src/store/index.js56
-rw-r--r--src/store/mutations.js64
-rw-r--r--src/store/normalization.js (renamed from src/util/undefined.js)26
-rw-r--r--src/tests/setup.js6
-rw-r--r--src/tests/unit/store/actions.spec.js313
-rw-r--r--src/tests/unit/store/getters.spec.js72
-rw-r--r--src/tests/unit/store/mutations.spec.js130
-rw-r--r--src/tests/unit/store/normalization.spec.js (renamed from src/tests/unit/util/undefined.spec.js)27
-rw-r--r--src/views/Home.vue6
24 files changed, 1492 insertions, 805 deletions
diff --git a/appinfo/routes.php b/appinfo/routes.php
index 8ce7275c8..c7ee9d33e 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -79,6 +79,11 @@ return [
'verb' => 'POST'
],
[
+ 'name' => 'messages#getBody',
+ 'url' => '/api/accounts/{accountId}/folders/{folderId}/messages/{messageId}/body',
+ 'verb' => 'GET'
+ ],
+ [
'name' => 'messages#getHtmlBody',
'url' => '/api/accounts/{accountId}/folders/{folderId}/messages/{messageId}/html',
'verb' => 'GET'
diff --git a/lib/Contracts/IMailSearch.php b/lib/Contracts/IMailSearch.php
index e3bc996c6..85213dc30 100644
--- a/lib/Contracts/IMailSearch.php
+++ b/lib/Contracts/IMailSearch.php
@@ -27,12 +27,25 @@ use OCA\Mail\Account;
use OCA\Mail\Db\Message;
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ServiceException;
+use OCP\AppFramework\Db\DoesNotExistException;
interface IMailSearch {
/**
* @param Account $account
* @param string $mailboxName
+ * @param int $uid
+ *
+ * @return Message
+ * @throws DoesNotExistException
+ * @throws ClientException
+ * @throws ServiceException
+ */
+ public function findMessage(Account $account, string $mailboxName, int $uid): Message;
+
+ /**
+ * @param Account $account
+ * @param string $mailboxName
* @param string|null $filter
* @param string|null $cursor
*
diff --git a/lib/Controller/MessagesController.php b/lib/Controller/MessagesController.php
index 9a6fc82de..8726c5be7 100755
--- a/lib/Controller/MessagesController.php
+++ b/lib/Controller/MessagesController.php
@@ -54,6 +54,7 @@ use OCP\IL10N;
use OCP\ILogger;
use OCP\IRequest;
use OCP\IURLGenerator;
+use function array_map;
use function base64_decode;
class MessagesController extends Controller {
@@ -176,23 +177,52 @@ class MessagesController extends Controller {
return new JSONResponse(null, Http::STATUS_FORBIDDEN);
}
+ $this->logger->debug("loading message of folder <$folderId>");
+
+ return new JSONResponse(
+ $this->mailSearch->findMessage(
+ $account,
+ base64_decode($folderId),
+ $id
+ )
+ );
+ }
+
+ /**
+ * @NoAdminRequired
+ * @TrapError
+ *
+ * @param int $accountId
+ * @param string $folderId
+ * @param int $messageId
+ *
+ * @return JSONResponse
+ * @throws ServiceException
+ */
+ public function getBody(int $accountId, string $folderId, int $messageId): JSONResponse {
+ try {
+ $account = $this->accountService->find($this->currentUserId, $accountId);
+ } catch (DoesNotExistException $e) {
+ return new JSONResponse(null, Http::STATUS_FORBIDDEN);
+ }
+
$json = $this->mailManager->getMessage(
$account,
base64_decode($folderId),
- $id,
+ $messageId,
true
)->getFullMessage(
$accountId,
base64_decode($folderId),
- $id
+ $messageId
);
$json['itineraries'] = $this->itineraryService->extract(
$account,
base64_decode($folderId),
- $id
+ $messageId
);
- $json['attachments'] = array_map(function ($a) use ($accountId, $folderId, $id) {
- return $this->enrichDownloadUrl($accountId, $folderId, $id, $a);
+ $json['attachments'] = array_map(function ($a) use ($accountId, $folderId, $messageId) {
+ return $this->enrichDownloadUrl($accountId, $folderId, $messageId, $a);
}, $json['attachments']);
return new JSONResponse($json);
diff --git a/lib/Service/Search/MailSearch.php b/lib/Service/Search/MailSearch.php
index 773f7152c..555188826 100644
--- a/lib/Service/Search/MailSearch.php
+++ b/lib/Service/Search/MailSearch.php
@@ -73,6 +73,27 @@ class MailSearch implements IMailSearch {
$this->logger = $logger;
}
+ public function findMessage(Account $account, string $mailboxName, int $uid): Message {
+ try {
+ $mailbox = $this->mailboxMapper->find($account, $mailboxName);
+ } catch (DoesNotExistException $e) {
+ throw new ServiceException('Mailbox does not exist', 0, $e);
+ }
+
+ $messages = $this->previewEnhancer->process(
+ $account,
+ $mailbox,
+ $this->messageMapper->findByUids(
+ $mailbox,
+ [$uid]
+ )
+ );
+ if (empty($messages)) {
+ throw new DoesNotExistException("Message does not exist");
+ }
+ return $messages[0];
+ }
+
/**
* @param Account $account
* @param string $mailboxName
diff --git a/package-lock.json b/package-lock.json
index d66bb9f9e..45d5d2f7c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11802,6 +11802,11 @@
"integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=",
"dev": true
},
+ "ramda": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.0.tgz",
+ "integrity": "sha512-pVzZdDpWwWqEVVLshWUHjNwuVP7SfcmPraYuqocJp1yo2U1R7P+5QAfDhdItkuoGqIBnBYrtPp7rEPqDn9HlZA=="
+ },
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -12693,6 +12698,12 @@
}
}
},
+ "sinon-chai": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.4.0.tgz",
+ "integrity": "sha512-BpVxsjEkGi6XPbDXrgWUe7Cb1ZzIfxKUbu/MmH5RoUnS7AXpKo3aIYIyQUg0FMvlUL05aPt7VZuAdaeQhEnWxg==",
+ "dev": true
+ },
"slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
diff --git a/package.json b/package.json
index bba9b2511..f7d75b27d 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"nextcloud_issuetemplate_builder": "^0.1.0",
"postcss-loader": "^3.0.0",
"printscout": "2.0.3",
+ "ramda": "^0.27.0",
"raw-loader": "^4.0.0",
"v-tooltip": "^2.0.3",
"vue": "^2.6.11",
@@ -95,6 +96,7 @@
"prettier": "1.19.1",
"sass-loader": "^8.0.2",
"sinon": "^9.0.0",
+ "sinon-chai": "^3.4.0",
"url-loader": "^3.0.0",
"vue-loader": "^15.9.0",
"vue-server-renderer": "^2.6.11",
diff --git a/src/components/EmptyFolder.vue b/src/components/EmptyMailbox.vue
index cf8d7f868..49622a49a 100644
--- a/src/components/EmptyFolder.vue
+++ b/src/components/EmptyMailbox.vue
@@ -7,6 +7,6 @@
<script>
export default {
- name: 'EmptyFolder',
+ name: 'EmptyMailbox',
}
</script>
diff --git a/src/components/EnvelopeList.vue b/src/components/EnvelopeList.vue
index aa665b6a3..6f2652856 100644
--- a/src/components/EnvelopeList.vue
+++ b/src/components/EnvelopeList.vue
@@ -1,52 +1,26 @@
<template>
- <AppContentList :show-details="!show">
- <transition-group
- v-infinite-scroll="loadMore"
- v-scroll="onScroll"
- v-shortkey.once="shortkeys"
- name="list"
- infinite-scroll-disabled="loading"
- infinite-scroll-distance="30"
- @shortkey.native="handleShortcut"
- >
+ <div>
+ <transition-group name="list">
<div id="list-refreshing" key="loading" class="icon-loading-small" :class="{refreshing: refreshing}" />
- <EmptyFolder v-if="envelopes.length === 0" key="empty" />
<Envelope
v-for="env in envelopes"
- v-else
:key="env.uid"
:data="env"
:folder="folder"
- @delete="onEnvelopeDeleted"
+ @delete="$emit('delete', env)"
/>
<div id="load-more-mail-messages" key="loadingMore" :class="{'icon-loading-small': loadingMore}" />
</transition-group>
- </AppContentList>
+ </div>
</template>
<script>
-import AppContentList from '@nextcloud/vue/dist/Components/AppContentList'
-import infiniteScroll from 'vue-infinite-scroll'
-import vuescroll from 'vue-scroll'
-import Vue from 'vue'
-
-import EmptyFolder from './EmptyFolder'
import Envelope from './Envelope'
-import logger from '../logger'
-import {matchError} from '../errors/match'
-import MailboxLockedError from '../errors/MailboxLockedError'
-
-Vue.use(vuescroll, {throttle: 600})
export default {
name: 'EnvelopeList',
components: {
- AppContentList,
Envelope,
- EmptyFolder,
- },
- directives: {
- infiniteScroll,
},
props: {
account: {
@@ -61,190 +35,13 @@ export default {
type: Array,
required: true,
},
- searchQuery: {
- type: String,
- required: false,
- default: undefined,
- },
- show: {
+ refreshing: {
type: Boolean,
- default: true,
- },
- },
- data() {
- return {
- loadingMore: false,
- refreshing: false,
- shortkeys: {
- del: ['del'],
- flag: ['s'],
- next: ['arrowright'],
- prev: ['arrowleft'],
- refresh: ['r'],
- unseen: ['u'],
- },
- }
- },
- computed: {
- isSearch() {
- return this.searchQuery !== undefined
- },
- },
- methods: {
- loadMore() {
- this.loadingMore = true
-
- this.$store
- .dispatch('fetchNextEnvelopePage', {
- accountId: this.$route.params.accountId,
- folderId: this.$route.params.folderId,
- query: this.searchQuery,
- })
- .catch(error => logger.error('could not fetch next envelope page', {error}))
- .then(() => {
- this.loadingMore = false
- })
- },
- async sync() {
- this.refreshing = true
-
- try {
- await this.$store.dispatch('syncEnvelopes', {
- accountId: this.$route.params.accountId,
- folderId: this.$route.params.folderId,
- })
- } catch (error) {
- matchError(error, {
- [MailboxLockedError.name](error) {
- logger.info('Background sync failed because the mailbox is locked', {error})
- },
- default(error) {
- logger.error('Could not sync envelopes: ' + error.message, {error})
- },
- })
- } finally {
- this.refreshing = false
- }
- },
- onEnvelopeDeleted(envelope) {
- const envelopes = this.envelopes
- const idx = this.envelopes.indexOf(envelope)
- if (idx === -1) {
- logger.debug('envelope to delete does not exist in envelope list')
- return
- }
- if (envelope.uid !== this.$route.params.messageUid) {
- logger.debug('other message open, not jumping to the next/previous message')
- return
- }
-
- let next
- if (idx === 0) {
- next = envelopes[idx + 1]
- } else {
- next = envelopes[idx - 1]
- }
-
- if (!next) {
- logger.debug('no next/previous envelope, not navigating')
- return
- }
-
- // Keep the selected account-folder combination, but navigate to a different message
- // (it's not a bug that we don't use next.accountId and next.folderId here)
- this.$router.push({
- name: 'message',
- params: {
- accountId: this.$route.params.accountId,
- folderId: this.$route.params.folderId,
- messageUid: next.uid,
- },
- })
- },
- onScroll(e, p) {
- if (p.scrollTop === 0 && !this.refreshing) {
- return this.sync()
- }
+ required: true,
},
- handleShortcut(e) {
- const envelopes = this.envelopes
- const currentUid = this.$route.params.messageUid
-
- if (!currentUid) {
- logger.debug('ignoring shortcut: no envelope selected')
- return
- }
-
- const current = envelopes.filter(e => e.uid == currentUid)
- if (current.length === 0) {
- logger.debug('ignoring shortcut: currently displayed messages is not in current envelope list')
- return
- }
-
- const env = current[0]
- const idx = envelopes.indexOf(env)
-
- switch (e.srcKey) {
- case 'next':
- case 'prev':
- let next
- if (e.srcKey === 'next') {
- next = envelopes[idx + 1]
- } else {
- next = envelopes[idx - 1]
- }
-
- if (!next) {
- logger.debug('ignoring shortcut: head or tail of envelope list reached', {
- envelopes,
- idx,
- srcKey: e.srcKey,
- })
- return
- }
-
- // Keep the selected account-folder combination, but navigate to a different message
- // (it's not a bug that we don't use next.accountId and next.folderId here)
- this.$router.push({
- name: 'message',
- params: {
- accountId: this.$route.params.accountId,
- folderId: this.$route.params.folderId,
- messageUid: next.uid,
- },
- })
- break
- case 'del':
- logger.debug('deleting', {env})
- this.$store
- .dispatch('deleteMessage', env)
- .catch(error => logger.error('could not delete envelope', {env, error}))
-
- break
- case 'flag':
- logger.debug('flagging envelope via shortkey', {env})
- this.$store
- .dispatch('toggleEnvelopeFlagged', env)
- .catch(error => logger.error('could not flag envelope via shortkey', {env, error}))
- break
- case 'refresh':
- logger.debug('syncing envelopes via shortkey')
- if (!this.refreshing) {
- this.sync()
- }
-
- break
- case 'unseen':
- logger.debug('marking message as seen/unseen via shortkey', {env})
- this.$store
- .dispatch('toggleEnvelopeSeen', env)
- .catch(error =>
- logger.error('could not mark envelope as seen/unseen via shortkey', {env, error})
- )
- break
- default:
- logger.warn('shortcut ' + e.srcKey + ' is unknown. ignoring.')
- }
+ loadingMore: {
+ type: Boolean,
+ required: true,
},
},
}
diff --git a/src/components/FolderContent.vue b/src/components/FolderContent.vue
deleted file mode 100644
index 39f237a95..000000000
--- a/src/components/FolderContent.vue
+++ /dev/null
@@ -1,226 +0,0 @@
-<template>
- <AppContent>
- <AppDetailsToggle v-if="showMessage" @close="hideMessage" />
- <div id="app-content-wrapper">
- <Error v-if="error" :error="t('mail', 'Could not open mailbox')" message="" />
- <Loading v-else-if="loadingEnvelopes" :hint="t('mail', 'Loading messages')" />
- <Loading
- v-else-if="loadingCacheInitialization"
- :hint="t('mail', 'Loading messages')"
- :slow-hint="t('mail', 'Indexing your messages. This can take a bit longer for larger mailboxes.')"
- />
- <template v-else>
- <EnvelopeList
- :account="account"
- :folder="folder"
- :envelopes="envelopes"
- :search-query="searchQuery"
- :show="!showMessage"
- />
- <NewMessageDetail v-if="newMessage" />
- <Message v-else-if="showMessage" />
- <NoMessageSelected v-else-if="hasMessages && !isMobile" />
- </template>
- </div>
- </AppContent>
-</template>
-
-<script>
-import AppContent from '@nextcloud/vue/dist/Components/AppContent'
-import isMobile from '@nextcloud/vue/dist/Mixins/isMobile'
-
-import AppDetailsToggle from './AppDetailsToggle'
-import EnvelopeList from './EnvelopeList'
-import Error from './Error'
-import Loading from './Loading'
-import logger from '../logger'
-import MailboxNotCachedError from '../errors/MailboxNotCachedError'
-import Message from './Message'
-import NewMessageDetail from './NewMessageDetail'
-import NoMessageSelected from './NoMessageSelected'
-import {matchError} from '../errors/match'
-import MailboxLockedError from '../errors/MailboxLockedError'
-import {wait} from '../util/wait'
-
-export default {
- name: 'FolderContent',
- components: {
- AppContent,
- AppDetailsToggle,
- EnvelopeList,
- Error,
- Loading,
- Message,
- NewMessageDetail,
- NoMessageSelected,
- },
- mixins: [isMobile],
- props: {
- account: {
- type: Object,
- required: true,
- },
- folder: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- error: false,
- loadingEnvelopes: true,
- loadingCacheInitialization: false,
- searchQuery: undefined,
- alive: false,
- }
- },
- computed: {
- hasMessages() {
- // it actually should be `return this.$store.getters.getEnvelopes(this.account.id, this.folder.id).length > 0`
- // but for some reason Vue doesn't track the dependencies on reactive data then and messages in subfolders can't
- // be opened then
-
- return this.folder.envelopes.map(msgId => this.$store.state.envelopes[msgId])
- },
- showMessage() {
- return this.hasMessages && this.$route.name === 'message'
- },
- newMessage() {
- return (
- this.$route.params.messageUid === 'new' ||
- this.$route.params.messageUid === 'reply' ||
- this.$route.params.messageUid === 'replyAll'
- )
- },
- envelopes() {
- if (this.searchQuery === undefined) {
- return this.$store.getters.getEnvelopes(this.account.id, this.folder.id)
- } else {
- return this.$store.getters.getSearchEnvelopes(this.account.id, this.folder.id)
- }
- },
- },
- watch: {
- $route(to, from) {
- if (to.name === 'folder') {
- // Navigate (back) to the folder view -> (re)fetch data
- this.loadEnvelopes()
- }
- },
- },
- created() {
- this.alive = true
-
- new OCA.Search(this.searchProxy, this.clearSearchProxy)
-
- this.loadEnvelopes()
- },
- beforeDestroy() {
- this.alive = false
- },
- methods: {
- initializeCache() {
- this.loadingCacheInitialization = true
- this.error = false
-
- this.$store
- .dispatch('syncEnvelopes', {
- accountId: this.account.id,
- folderId: this.folder.id,
- init: true,
- })
- .then(() => {
- this.loadingCacheInitialization = false
-
- return this.loadEnvelopes()
- })
- },
- async loadEnvelopes() {
- this.loadingEnvelopes = true
- this.error = false
-
- try {
- await this.$store.dispatch('fetchEnvelopes', {
- accountId: this.account.id,
- folderId: this.folder.id,
- query: this.searchQuery,
- })
-
- const envelopes = this.envelopes
- logger.debug('envelopes fetched', envelopes)
-
- this.loadingEnvelopes = false
-
- if (!this.isMobile && this.$route.name !== 'message' && envelopes.length > 0) {
- // Show first message
- let first = envelopes[0]
-
- // Keep the selected account-folder combination, but navigate to the message
- // (it's not a bug that we don't use first.accountId and first.folderId here)
- this.$router.replace({
- name: 'message',
- params: {
- accountId: this.account.id,
- folderId: this.folder.id,
- messageUid: first.uid,
- },
- })
- }
- } catch (error) {
- await matchError(error, {
- [MailboxLockedError.getName()]: async error => {
- logger.info('Mailbox is locked', {error})
-
- await wait(15 * 1000)
- // Keep trying
- await this.loadEnvelopes()
- },
- [MailboxNotCachedError.getName()]: async error => {
- logger.info('Mailbox not cached. Triggering initialization', {error})
- this.loadingEnvelopes = false
-
- try {
- await this.initializeCache()
- } catch (error) {
- logger.error('Could not initialize cache', {error})
- this.error = error
- }
- },
- default: error => {
- logger.error('Could not fetch envelopes', {error})
- this.loadingEnvelopes = false
- this.error = error
- },
- })
- }
- },
- hideMessage() {
- this.$router.replace({
- name: 'folder',
- params: {
- accountId: this.account.id,
- folderId: this.folder.id,
- },
- })
- },
- searchProxy(query) {
- if (this.alive) {
- this.search(query)
- }
- },
- clearSearchProxy() {
- if (this.alive) {
- this.clearSearch()
- }
- },
- search(query) {
- this.searchQuery = query
-
- this.loadEnvelopes()
- },
- clearSearch() {
- this.searchQuery = undefined
- },
- },
-}
-</script>
diff --git a/src/components/Mailbox.vue b/src/components/Mailbox.vue
new file mode 100644
index 000000000..75b8883ea
--- /dev/null
+++ b/src/components/Mailbox.vue
@@ -0,0 +1,359 @@
+<!--
+ - @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>
+ <Error v-if="error" :error="t('mail', 'Could not open mailbox')" message="" />
+ <Loading v-else-if="loadingEnvelopes" :hint="t('mail', 'Loading messages')" />
+ <Loading
+ v-else-if="loadingCacheInitialization"
+ :hint="t('mail', 'Loading messages')"
+ :slow-hint="t('mail', 'Indexing your messages. This can take a bit longer for larger mailboxes.')"
+ />
+ <EmptyMailbox v-else-if="envelopes.length === 0" key="empty" />
+ <EnvelopeList
+ v-else
+ :account="account"
+ :folder="folder"
+ :envelopes="envelopes"
+ :refreshing="refreshing"
+ :loading-more="loadingMore"
+ @delete="onDelete"
+ />
+</template>
+
+<script>
+import EmptyMailbox from './EmptyMailbox'
+import EnvelopeList from './EnvelopeList'
+import Error from './Error'
+import {findIndex, propEq} from 'ramda'
+import isMobile from '@nextcloud/vue/dist/Mixins/isMobile'
+import Loading from './Loading'
+import logger from '../logger'
+import MailboxLockedError from '../errors/MailboxLockedError'
+import MailboxNotCachedError from '../errors/MailboxNotCachedError'
+import {matchError} from '../errors/match'
+import {wait} from '../util/wait'
+
+export default {
+ name: 'Mailbox',
+ components: {
+ EmptyMailbox,
+ EnvelopeList,
+ Error,
+ Loading,
+ },
+ mixins: [isMobile],
+ props: {
+ account: {
+ type: Object,
+ required: true,
+ },
+ folder: {
+ type: Object,
+ required: true,
+ },
+ bus: {
+ type: Object,
+ required: true,
+ },
+ searchQuery: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ data() {
+ return {
+ error: false,
+ refreshing: false,
+ loadingMore: false,
+ loadingEnvelopes: true,
+ loadingCacheInitialization: false,
+ }
+ },
+ computed: {
+ envelopes() {
+ return this.$store.getters.getEnvelopes(this.account.id, this.folder.id, this.searchQuery)
+ },
+ },
+ watch: {
+ account() {
+ this.loadEnvelopes()
+ },
+ folder() {
+ this.loadEnvelopes()
+ },
+ searchQuery() {
+ this.loadEnvelopes()
+ },
+ },
+ created() {
+ this.bus.$on('loadMore', this.loadMore)
+ this.bus.$on('delete', this.onDelete)
+ this.bus.$on('shortcut', this.handleShortcut)
+ },
+ async mounted() {
+ return await this.loadEnvelopes()
+ },
+ destroyed() {
+ this.bus.$off('loadMore', this.loadMore)
+ this.bus.$off('delete', this.onDelete)
+ this.bus.$off('shortcut', this.handleShortcut)
+ },
+ methods: {
+ initializeCache() {
+ this.loadingCacheInitialization = true
+ this.error = false
+
+ this.$store
+ .dispatch('syncEnvelopes', {
+ accountId: this.account.id,
+ folderId: this.folder.id,
+ query: this.searchQuery,
+ init: true,
+ })
+ .then(() => {
+ this.loadingCacheInitialization = false
+
+ return this.loadEnvelopes()
+ })
+ },
+ async loadEnvelopes() {
+ logger.debug('fetching envelopes')
+ this.loadingEnvelopes = true
+ this.loadingCacheInitialization = false
+ this.error = false
+
+ try {
+ const envelopes = await this.$store.dispatch('fetchEnvelopes', {
+ accountId: this.account.id,
+ folderId: this.folder.id,
+ query: this.searchQuery,
+ })
+
+ logger.debug('envelopes fetched', {envelopes})
+
+ this.loadingEnvelopes = false
+
+ if (!this.isMobile && this.$route.name !== 'message' && envelopes.length > 0) {
+ // Show first message
+ let first = envelopes[0]
+
+ // Keep the selected account-folder combination, but navigate to the message
+ // (it's not a bug that we don't use first.accountId and first.folderId here)
+ this.$router.replace({
+ name: 'message',
+ params: {
+ accountId: this.account.id,
+ folderId: this.folder.id,
+ messageUid: first.uid,
+ },
+ })
+ }
+ } catch (error) {
+ await matchError(error, {
+ [MailboxLockedError.getName()]: async error => {
+ logger.info('Mailbox is locked', {error})
+
+ await wait(15 * 1000)
+ // Keep trying
+ await this.loadEnvelopes()
+ },
+ [MailboxNotCachedError.getName()]: async error => {
+ logger.info('Mailbox not cached. Triggering initialization', {error})
+ this.loadingEnvelopes = false
+
+ try {
+ await this.initializeCache()
+ } catch (error) {
+ logger.error('Could not initialize cache', {error})
+ this.error = error
+ }
+ },
+ default: error => {
+ logger.error('Could not fetch envelopes', {error})
+ this.loadingEnvelopes = false
+ this.error = error
+ },
+ })
+ }
+ },
+ async loadMore() {
+ logger.debug('fetching next envelope page')
+ this.loadingMore = true
+
+ try {
+ await this.$store.dispatch('fetchNextEnvelopePage', {
+ accountId: this.$route.params.accountId,
+ folderId: this.$route.params.folderId,
+ envelopes: this.envelopes,
+ query: this.searchQuery,
+ })
+ } catch (error) {
+ logger.error('could not fetch next envelope page', {error})
+ } finally {
+ this.loadingMore = false
+ }
+ },
+ handleShortcut(e) {
+ const envelopes = this.envelopes
+ const currentUid = this.$route.params.messageUid
+
+ if (!currentUid) {
+ logger.debug('ignoring shortcut: no envelope selected')
+ return
+ }
+
+ const current = envelopes.filter(e => e.uid === currentUid)
+ if (current.length === 0) {
+ logger.debug('ignoring shortcut: currently displayed messages is not in current envelope list')
+ return
+ }
+
+ const env = current[0]
+ const idx = envelopes.indexOf(env)
+
+ switch (e.srcKey) {
+ case 'next':
+ case 'prev':
+ let next
+ if (e.srcKey === 'next') {
+ next = envelopes[idx + 1]
+ } else {
+ next = envelopes[idx - 1]
+ }
+
+ if (!next) {
+ logger.debug('ignoring shortcut: head or tail of envelope list reached', {
+ envelopes,
+ idx,
+ srcKey: e.srcKey,
+ })
+ return
+ }
+
+ // Keep the selected account-folder combination, but navigate to a different message
+ // (it's not a bug that we don't use next.accountId and next.folderId here)
+ this.$router.push({
+ name: 'message',
+ params: {
+ accountId: this.$route.params.accountId,
+ folderId: this.$route.params.folderId,
+ messageUid: next.uid,
+ },
+ })
+ break
+ case 'del':
+ logger.debug('deleting', {env})
+ this.onDelete({envelope: env})
+ this.$store
+ .dispatch('deleteMessage', env)
+ .catch(error => logger.error('could not delete envelope', {env, error}))
+
+ break
+ case 'flag':
+ logger.debug('flagging envelope via shortkey', {env})
+ this.$store
+ .dispatch('toggleEnvelopeFlagged', env)
+ .catch(error => logger.error('could not flag envelope via shortkey', {env, error}))
+ break
+ case 'refresh':
+ logger.debug('syncing envelopes via shortkey')
+ if (!this.refreshing) {
+ this.sync()
+ }
+
+ break
+ case 'unseen':
+ logger.debug('marking message as seen/unseen via shortkey', {env})
+ this.$store
+ .dispatch('toggleEnvelopeSeen', env)
+ .catch(error =>
+ logger.error('could not mark envelope as seen/unseen via shortkey', {env, error})
+ )
+ break
+ default:
+ logger.warn('shortcut ' + e.srcKey + ' is unknown. ignoring.')
+ }
+ },
+ async sync() {
+ this.refreshing = true
+
+ try {
+ await this.$store.dispatch('syncEnvelopes', {
+ accountId: this.$route.params.accountId,
+ folderId: this.$route.params.folderId,
+ query: this.searchQuery,
+ })
+ } catch (error) {
+ matchError(error, {
+ [MailboxLockedError.getName()](error) {
+ logger.info('Background sync failed because the mailbox is locked', {error})
+ },
+ default(error) {
+ logger.error('Could not sync envelopes: ' + error.message, {error})
+ },
+ })
+ } finally {
+ this.refreshing = false
+ }
+ },
+ onDelete({envelope}) {
+ const envelopes = this.envelopes
+ const idx = findIndex(propEq('uid', envelope.uid), this.envelopes)
+ if (idx === -1) {
+ logger.debug('envelope to delete does not exist in envelope list')
+ return
+ }
+ this.envelopes.splice(idx, 1)
+ if (envelope.uid !== this.$route.params.messageUid) {
+ logger.debug('other message open, not jumping to the next/previous message')
+ return
+ }
+
+ let next
+ if (idx === 0) {
+ next = envelopes[idx + 1]
+ } else {
+ next = envelopes[idx - 1]
+ }
+
+ if (!next) {
+ logger.debug('no next/previous envelope, not navigating')
+ return
+ }
+
+ // Keep the selected account-folder combination, but navigate to a different message
+ // (it's not a bug that we don't use next.accountId and next.folderId here)
+ this.$router.push({
+ name: 'message',
+ params: {
+ accountId: this.$route.params.accountId,
+ folderId: this.$route.params.folderId,
+ messageUid: next.uid,
+ },
+ })
+ },
+ },
+}
+</script>
+
+<style scoped></style>
diff --git a/src/components/MailboxMessage.vue b/src/components/MailboxMessage.vue
new file mode 100644
index 000000000..0c8ac90b1
--- /dev/null
+++ b/src/components/MailboxMessage.vue
@@ -0,0 +1,145 @@
+<template>
+ <AppContent>
+ <AppDetailsToggle v-if="showMessage" @close="hideMessage" />
+ <div id="app-content-wrapper">
+ <AppContentList
+ v-infinite-scroll="onScroll"
+ v-shortkey.once="shortkeys"
+ :show-details="showMessage"
+ :infinite-scroll-disabled="false"
+ :infinite-scroll-distance="10"
+ @shortkey.native="onShortcut"
+ >
+ <Mailbox :account="account" :folder="folder" :search-query="searchQuery" :bus="bus" />
+ </AppContentList>
+ <NewMessageDetail v-if="newMessage" />
+ <Message v-else-if="showMessage" @delete="deleteMessage" />
+ <NoMessageSelected v-else-if="hasMessages && !isMobile" />
+ </div>
+ </AppContent>
+</template>
+
+<script>
+import AppContent from '@nextcloud/vue/dist/Components/AppContent'
+import AppContentList from '@nextcloud/vue/dist/Components/AppContentList'
+import infiniteScroll from 'vue-infinite-scroll'
+import isMobile from '@nextcloud/vue/dist/Mixins/isMobile'
+import Vue from 'vue'
+
+import AppDetailsToggle from './AppDetailsToggle'
+import Mailbox from './Mailbox'
+import Message from './Message'
+import NewMessageDetail from './NewMessageDetail'
+import NoMessageSelected from './NoMessageSelected'
+import {normalizeEnvelopeListId} from '../store/normalization'
+
+export default {
+ name: 'MailboxMessage',
+ directives: {
+ infiniteScroll,
+ },
+ components: {
+ AppContent,
+ AppContentList,
+ AppDetailsToggle,
+ Mailbox,
+ Message,
+ NewMessageDetail,
+ NoMessageSelected,
+ },
+ mixins: [isMobile],
+ props: {
+ account: {
+ type: Object,
+ required: true,
+ },
+ folder: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ alive: false,
+ bus: new Vue(),
+ searchQuery: undefined,
+ shortkeys: {
+ del: ['del'],
+ flag: ['s'],
+ next: ['arrowright'],
+ prev: ['arrowleft'],
+ refresh: ['r'],
+ unseen: ['u'],
+ },
+ }
+ },
+ computed: {
+ hasMessages() {
+ // it actually should be `return this.$store.getters.getEnvelopes(this.account.id, this.folder.id).length > 0`
+ // but for some reason Vue doesn't track the dependencies on reactive data then and messages in subfolders can't
+ // be opened then
+ const list = this.folder.envelopeLists[normalizeEnvelopeListId(this.searchQuery)]
+
+ if (list === undefined) {
+ return false
+ }
+ return list.length > 0
+ },
+ showMessage() {
+ return this.hasMessages && this.$route.name === 'message'
+ },
+ newMessage() {
+ return (
+ this.$route.params.messageUid === 'new' ||
+ this.$route.params.messageUid === 'reply' ||
+ this.$route.params.messageUid === 'replyAll'
+ )
+ },
+ },
+ created() {
+ this.alive = true
+
+ new OCA.Search(this.searchProxy, this.clearSearchProxy)
+ },
+ beforeDestroy() {
+ this.alive = false
+ },
+ methods: {
+ hideMessage() {
+ this.$router.replace({
+ name: 'folder',
+ params: {
+ accountId: this.account.id,
+ folderId: this.folder.id,
+ },
+ })
+ },
+ deleteMessage({envelope, message}) {
+ console.info({envelope, message})
+ this.bus.$emit('delete', {envelope, message})
+ },
+ onScroll() {
+ this.bus.$emit('loadMore')
+ },
+ onShortcut(e) {
+ this.bus.$emit('shortcut', e)
+ },
+ searchProxy(query) {
+ if (this.alive) {
+ this.search(query)
+ }
+ },
+ clearSearchProxy() {
+ if (this.alive) {
+ this.clearSearch()
+ }
+ },
+ search(query) {
+ this.searchQuery = query
+ },
+ clearSearch() {
+ this.searchQuery = undefined
+ },
+ },
+}
+</script>
diff --git a/src/components/Message.vue b/src/components/Message.vue
index 73a53e314..96daa412b 100644
--- a/src/components/Message.vue
+++ b/src/components/Message.vue
@@ -46,7 +46,7 @@
<ActionButton icon="icon-mail" @click="onToggleSeen">
{{ envelope.flags.unseen ? t('mail', 'Mark read') : t('mail', 'Mark unread') }}
</ActionButton>
- <ActionButton icon="icon-delete" @click="onDelete">
+ <ActionButton icon="icon-delete" @click.prevent="onDelete">
{{ t('mail', 'Delete') }}
</ActionButton>
</Actions>
@@ -79,7 +79,7 @@ import Itinerary from './Itinerary'
import MessageHTMLBody from './MessageHTMLBody'
import MessagePlainTextBody from './MessagePlainTextBody'
import Loading from './Loading'
-import Logger from '../logger'
+import logger from '../logger'
import MessageAttachments from './MessageAttachments'
export default {
@@ -100,11 +100,11 @@ export default {
return {
loading: true,
message: undefined,
+ envelope: undefined,
errorMessage: '',
error: undefined,
replyRecipient: {},
replySubject: '',
- envelope: '',
}
},
computed: {
@@ -146,18 +146,23 @@ export default {
const messageUid = this.$route.params.messageUid
try {
- const message = await this.$store.dispatch('fetchMessage', messageUid)
+ const [envelope, message] = await Promise.all([
+ this.$store.dispatch('fetchEnvelope', messageUid),
+ this.$store.dispatch('fetchMessage', messageUid),
+ ])
+ logger.debug('envelope and message fetched', {envelope, message})
// TODO: add timeout so that message isn't flagged when only viewed
// for a few seconds
if (message && message.uid !== this.$route.params.messageUid) {
- Logger.debug("User navigated away, loaded message won't be shown nor flagged as seen")
+ logger.debug("User navigated away, loaded message won't be shown nor flagged as seen")
return
}
+ this.envelope = envelope
this.message = message
- if (this.message === undefined) {
- Logger.info('message could not be found', {messageUid})
+ if (envelope === undefined || message === undefined) {
+ logger.info('message could not be found', {messageUid})
this.errorMessage = getRandomMessageErrorMessage()
this.loading = false
return
@@ -173,15 +178,11 @@ export default {
this.loading = false
- this.envelope = this.$store.getters.getEnvelope(message.accountId, message.folderId, message.id)
- if (!this.envelope.flags.unseen) {
- // Already seen -> no change necessary
- return
+ if (envelope.flags.unseen) {
+ return this.$store.dispatch('toggleEnvelopeSeen', envelope)
}
-
- return this.$store.dispatch('toggleEnvelopeSeen', this.envelope)
} catch (error) {
- Logger.error('could not load message ', {messageUid, error})
+ logger.error('could not load message ', {messageUid, error})
if (error.isError) {
this.errorMessage = t('mail', 'Could not load your message')
this.error = error
@@ -231,41 +232,12 @@ export default {
onToggleSeen() {
this.$store.dispatch('toggleEnvelopeSeen', this.envelope)
},
- onDelete(e) {
- // Don't try to navigate to the deleted message
- e.preventDefault()
-
- let envelopes = this.$store.getters.getEnvelopes(this.$route.params.accountId, this.$route.params.folderId)
- const idx = envelopes.indexOf(this.envelope)
-
- let next
- if (idx === -1) {
- Logger.debug('envelope to delete does not exist in envelope list')
- return
- } else if (idx === 0) {
- next = envelopes[idx + 1]
- } else {
- next = envelopes[idx - 1]
- }
-
- this.$emit('delete', this.envelope)
- this.$store.dispatch('deleteMessage', this.envelope)
-
- if (!next) {
- Logger.debug('no next/previous envelope, not navigating')
- return
- }
-
- // Keep the selected account-folder combination, but navigate to a different message
- // (it's not a bug that we don't use next.accountId and next.folderId here)
- this.$router.push({
- name: 'message',
- params: {
- accountId: this.$route.params.accountId,
- folderId: this.$route.params.folderId,
- messageUid: next.uid,
- },
+ onDelete() {
+ this.$emit('delete', {
+ envelope: this.envelope,
+ message: this.message,
})
+ this.$store.dispatch('deleteMessage', this.message)
},
},
}
diff --git a/src/service/MessageService.js b/src/service/MessageService.js
index 1753487e7..44dad554f 100644
--- a/src/service/MessageService.js
+++ b/src/service/MessageService.js
@@ -1,11 +1,35 @@
import {generateUrl} from '@nextcloud/router'
import axios from '@nextcloud/axios'
+import {curry, map} from 'ramda'
-import logger from '../logger'
-import MailboxNotCachedError from '../errors/MailboxNotCachedError'
import {parseErrorResponse} from '../http/ErrorResponseParser'
import {convertAxiosError} from '../errors/convert'
+const amendEnvelopeWithIds = curry((accountId, folderId, envelope) => ({
+ accountId,
+ folderId,
+ uid: `${accountId}-${folderId}-${envelope.id}`,
+ ...envelope,
+}))
+
+export function fetchEnvelope(accountId, folderId, id) {
+ const url = generateUrl('/apps/mail/api/accounts/{accountId}/folders/{folderId}/messages/{id}', {
+ accountId,
+ folderId,
+ id,
+ })
+
+ return axios
+ .get(url)
+ .then(resp => resp.data)
+ .catch(error => {
+ if (error.response && error.response.status === 404) {
+ return undefined
+ }
+ return Promise.reject(parseErrorResponse(error.response))
+ })
+}
+
export function fetchEnvelopes(accountId, folderId, query, cursor) {
const url = generateUrl('/apps/mail/api/accounts/{accountId}/folders/{folderId}/messages', {
accountId,
@@ -22,9 +46,10 @@ export function fetchEnvelopes(accountId, folderId, query, cursor) {
return axios
.get(url, {
- params: params,
+ params,
})
.then(resp => resp.data)
+ .then(map(amendEnvelopeWithIds(accountId, folderId)))
.catch(error => {
throw convertAxiosError(error)
})
@@ -37,14 +62,19 @@ export async function syncEnvelopes(accountId, folderId, uids, init = false) {
})
try {
- return (
- await axios.get(url, {
- params: {
- uids,
- init,
- },
- })
- ).data
+ const response = await axios.get(url, {
+ params: {
+ uids,
+ init,
+ },
+ })
+
+ const amend = amendEnvelopeWithIds(accountId, folderId)
+ return {
+ newMessages: response.data.newMessages.map(amend),
+ changedMessages: response.data.changedMessages.map(amend),
+ vanishedMessages: response.data.vanishedMessages.map(amend),
+ }
} catch (e) {
throw convertAxiosError(e)
}
@@ -70,7 +100,7 @@ export function setEnvelopeFlag(accountId, folderId, id, flag, value) {
}
export function fetchMessage(accountId, folderId, id) {
- const url = generateUrl('/apps/mail/api/accounts/{accountId}/folders/{folderId}/messages/{id}', {
+ const url = generateUrl('/apps/mail/api/accounts/{accountId}/folders/{folderId}/messages/{id}/body', {
accountId,
folderId,
id,
diff --git a/src/store/actions.js b/src/store/actions.js
index 4c09f1343..cdf5a2601 100644
--- a/src/store/actions.js
+++ b/src/store/actions.js
@@ -20,14 +20,25 @@
*/
import flatMapDeep from 'lodash/fp/flatMapDeep'
-import flatten from 'lodash/fp/flatten'
-import flattenDepth from 'lodash/fp/flattenDepth'
-import identity from 'lodash/fp/identity'
-import isEmpty from 'lodash/fp/isEmpty'
-import last from 'lodash/fp/last'
import orderBy from 'lodash/fp/orderBy'
-import slice from 'lodash/fp/slice'
-import sortedUniq from 'lodash/fp/sortedUniq'
+import {
+ andThen,
+ complement,
+ curry,
+ identity,
+ filter,
+ flatten,
+ gt,
+ head,
+ last,
+ map,
+ pipe,
+ prop,
+ propEq,
+ slice,
+ tap,
+ where,
+} from 'ramda'
import {savePreference} from '../service/PreferenceService'
import {
@@ -40,11 +51,35 @@ import {
fetchAll as fetchAllAccounts,
} from '../service/AccountService'
import {fetchAll as fetchAllFolders, create as createFolder, markFolderRead} from '../service/FolderService'
-import {deleteMessage, fetchEnvelopes, fetchMessage, setEnvelopeFlag, syncEnvelopes} from '../service/MessageService'
+import {
+ deleteMessage,
+ fetchEnvelope,
+ fetchEnvelopes,
+ fetchMessage,
+ setEnvelopeFlag,
+ syncEnvelopes,
+} from '../service/MessageService'
import logger from '../logger'
+import {normalizeEnvelopeListId} from './normalization'
import {showNewMessagesNotification} from '../service/NotificationService'
import {parseUid} from '../util/EnvelopeUidParser'
+const PAGE_SIZE = 20
+
+const sliceToPage = slice(0, PAGE_SIZE)
+
+const findIndividualFolders = curry((getFolders, specialRole) =>
+ pipe(
+ filter(complement(prop('isUnified'))),
+ map(prop('accountId')),
+ map(getFolders),
+ flatten,
+ filter(propEq('specialRole', specialRole))
+ )
+)
+
+const combineEnvelopeLists = pipe(flatten, orderBy(prop('dateInt'), 'desc'))
+
export default {
savePreference({commit, getters}, {key, value}) {
return savePreference(key, value).then(({value}) => {
@@ -142,170 +177,158 @@ export default {
})
)
},
+ fetchEnvelope({commit, getters}, uid) {
+ const {accountId, folderId, id} = parseUid(uid)
+
+ const cached = getters.getEnvelope(accountId, folderId, id)
+ if (cached) {
+ return cached
+ }
+
+ return fetchEnvelope(accountId, folderId, id).then(envelope => {
+ // Only commit if not undefined (not found)
+ if (envelope) {
+ commit('addEnvelope', {
+ accountId,
+ folderId,
+ envelope,
+ })
+ }
+
+ // Always use the object from the store
+ return getters.getEnvelope(accountId, folderId, id)
+ })
+ },
fetchEnvelopes({state, commit, getters, dispatch}, {accountId, folderId, query}) {
const folder = getters.getFolder(accountId, folderId)
- const isSearch = query !== undefined
+
if (folder.isUnified) {
- // Fetch and combine envelopes of all individual folders
- //
- // The last envelope is excluded to efficiently build the next unified
- // pages (fetch only individual pages that do not have sufficient local
- // data)
- //
- // TODO: handle short/ending streams and show their last element as well
- return Promise.all(
- getters.accounts
- .filter(account => !account.isUnified)
- .map(account =>
- Promise.all(
- getters
- .getFolders(account.id)
- .filter(f => f.specialRole === folder.specialRole)
- .map(folder =>
- dispatch('fetchEnvelopes', {
- accountId: account.id,
- folderId: folder.id,
- query,
- })
- )
+ const fetchIndividualLists = pipe(
+ map(f =>
+ dispatch('fetchEnvelopes', {
+ accountId: f.accountId,
+ folderId: f.id,
+ query,
+ })
+ ),
+ Promise.all.bind(Promise),
+ andThen(map(sliceToPage))
+ )
+ const fetchUnifiedEnvelopes = pipe(
+ findIndividualFolders(getters.getFolders, folder.specialRole),
+ fetchIndividualLists,
+ andThen(combineEnvelopeLists),
+ andThen(sliceToPage),
+ andThen(
+ tap(
+ map(envelope =>
+ commit('addEnvelope', {
+ accountId,
+ folderId,
+ envelope,
+ query,
+ })
)
)
+ )
)
- .then(res => res.map(envs => envs.slice(0, 19)))
- .then(res => flattenDepth(2)(res))
- .then(envs => orderBy(env => env.dateInt)('desc')(envs))
- .then(envs => slice(0)(19)(envs)) // 19 to handle non-overlapping streams
- .then(envelopes => {
- if (!isSearch) {
- commit('addUnifiedEnvelopes', {
- folder,
- uids: envelopes.map(e => e.uid),
- })
- } else {
- commit('addUnifiedSearchEnvelopes', {
- folder,
- uids: envelopes.map(e => e.uid),
- })
- }
- return envelopes
- })
- }
- return fetchEnvelopes(accountId, folderId, query).then(envelopes => {
- let folder = getters.getFolder(accountId, folderId)
+ return fetchUnifiedEnvelopes(getters.accounts)
+ }
- if (!isSearch) {
- envelopes.forEach(envelope =>
- commit('addEnvelope', {
- accountId,
- folder,
- envelope,
- })
+ return pipe(
+ fetchEnvelopes,
+ andThen(
+ tap(
+ map(envelope =>
+ commit('addEnvelope', {
+ accountId,
+ folderId,
+ query,
+ envelope,
+ })
+ )
)
- } else {
- commit('addSearchEnvelopes', {
- accountId,
- folder,
- envelopes,
- clear: true,
- })
- }
- return envelopes
- })
+ )
+ )(accountId, folderId, query)
},
- fetchNextUnifiedEnvelopePage({state, commit, getters, dispatch}, {accountId, folderId, query}) {
+ fetchNextEnvelopePage({commit, getters, dispatch}, {accountId, folderId, query}) {
const folder = getters.getFolder(accountId, folderId)
- const isSearch = query !== undefined
- const list = isSearch ? 'searchEnvelopes' : 'envelopes'
- // We only care about folders of the same type/role
- const individualFolders = flatten(
- getters.accounts
- .filter(a => !a.isUnified)
- .map(a => getters.getFolders(a.id).filter(f => f.specialRole === folder.specialRole))
- )
- // Build a sorted list of all currently known envelopes (except last elem)
- const knownEnvelopes = orderBy(id => state.envelopes[id].dateInt)('desc')(
- flatten(individualFolders.map(f => f[list].slice(0, f[list].length - 1)))
- )
- // The index of the last element in the current unified mailbox determines
- // the new offset
- const tailId = last(folder[list])
- const tailIdx = knownEnvelopes.indexOf(tailId)
- if (tailIdx === -1) {
- return Promise.reject(
- new Error('current envelopes do not contain unified mailbox tail. this should not have happened')
+ if (folder.isUnified) {
+ const getIndivisualLists = curry((query, f) => getters.getEnvelopes(f.accountId, f.id, query))
+ const individualCursor = curry((query, f) =>
+ prop('dateInt', last(getters.getEnvelopes(f.accountId, f.id, query)))
)
- }
+ const cursor = individualCursor(query, folder)
- // Select the next page, based on offline data
- const nextCandidates = knownEnvelopes.slice(tailIdx + 1, tailIdx + 20)
-
- // Now, let's check if any of the "streams" have reached their ends.
- // In that case, we attempt to fetch more elements recursively
- //
- // In case of an empty next page we always fetch all streams (this might be redundant)
- //
- // Their end was reached if the last known (oldest) envelope is an element
- // of the offline page
- // TODO: what about streams that ended before? Is it safe to ignore those?
- const needFetch = individualFolders
- .filter(f => !isEmpty(f[list]))
- .filter(f => {
- const lastShown = f[list][f[list].length - 2]
- return nextCandidates.length <= 18 || nextCandidates.indexOf(lastShown) !== -1
+ if (cursor === undefined) {
+ throw new Error('Unified list has no tail')
+ }
+ const nextLocalUnifiedEnvelopePage = pipe(
+ findIndividualFolders(getters.getFolders, folder.specialRole),
+ map(getIndivisualLists(query)),
+ combineEnvelopeLists,
+ filter(
+ where({
+ dateInt: gt(cursor),
+ })
+ ),
+ sliceToPage
+ )
+ // We know the next page based on local data
+ // We have to fetch individual pages only if the page ends in the known
+ // next page. If it ended before, there is no data to fetch anyway. If
+ // it ends after, we have all the relevant data already
+ const needsFetch = curry((query, nextPage, f) => {
+ const c = individualCursor(query, f)
+ return nextPage.length < PAGE_SIZE || (c <= head(nextPage).dateInt && c >= last(nextPage).dateInt)
})
- if (isEmpty(needFetch)) {
- if (!isSearch) {
- commit('addUnifiedEnvelopes', {
- folder,
- uids: sortedUniq(
- orderBy(id => state.envelopes[id].dateInt)('desc')(folder[list].concat(nextCandidates))
- ),
- })
- } else {
- commit('addUnifiedSearchEnvelopes', {
- folder,
- uids: sortedUniq(
- orderBy(id => state.envelopes[id].dateInt)('desc')(folder[list].concat(nextCandidates))
+ const foldersToFetch = accounts =>
+ pipe(
+ findIndividualFolders(getters.getFolders, folder.specialRole),
+ filter(needsFetch(query, nextLocalUnifiedEnvelopePage(accounts)))
+ )(accounts)
+
+ const fs = foldersToFetch(getters.accounts)
+
+ if (fs.length) {
+ return pipe(
+ map(f =>
+ dispatch('fetchNextEnvelopePage', {
+ accountId: f.accountId,
+ folderId: f.id,
+ query,
+ })
),
- })
+ Promise.all.bind(Promise),
+ andThen(() =>
+ dispatch('fetchNextEnvelopePage', {
+ accountId,
+ folderId,
+ query,
+ })
+ )
+ )(fs)
}
- } else {
- return Promise.all(
- needFetch.map(f =>
- dispatch('fetchNextEnvelopePage', {
- accountId: f.accountId,
- folderId: f.id,
- query,
- })
- )
- ).then(() => {
- return dispatch('fetchNextUnifiedEnvelopePage', {
+
+ const page = nextLocalUnifiedEnvelopePage(getters.accounts)
+ page.map(envelope =>
+ commit('addEnvelope', {
accountId,
folderId,
query,
+ envelope,
})
- })
- }
- },
- fetchNextEnvelopePage({commit, getters, dispatch}, {accountId, folderId, query}) {
- const folder = getters.getFolder(accountId, folderId)
- const isSearch = query !== undefined
- const list = isSearch ? 'searchEnvelopes' : 'envelopes'
-
- if (folder.isUnified) {
- return dispatch('fetchNextUnifiedEnvelopePage', {
- accountId,
- folderId,
- query,
- })
+ )
+ return page
}
- const lastEnvelopeId = folder[list][folder.envelopes.length - 1]
+ const list = folder.envelopeLists[normalizeEnvelopeListId(query)]
+ const lastEnvelopeId = last(list)
if (typeof lastEnvelopeId === 'undefined') {
- console.error('folder is empty', folder[list])
+ console.error('folder is empty', list)
return Promise.reject(new Error('Local folder has no envelopes, cannot determine cursor'))
}
const lastEnvelope = getters.getEnvelopeById(lastEnvelopeId)
@@ -314,27 +337,18 @@ export default {
}
return fetchEnvelopes(accountId, folderId, query, lastEnvelope.dateInt).then(envelopes => {
- if (!isSearch) {
- envelopes.forEach(envelope =>
- commit('addEnvelope', {
- accountId,
- folder,
- envelope,
- })
- )
- } else {
- commit('addSearchEnvelopes', {
+ envelopes.forEach(envelope =>
+ commit('addEnvelope', {
accountId,
- folder,
- envelopes,
- clear: false,
+ folderId,
+ query,
+ envelope,
})
- }
-
+ )
return envelopes
})
},
- syncEnvelopes({commit, getters, dispatch}, {accountId, folderId, init = false}) {
+ syncEnvelopes({commit, getters, dispatch}, {accountId, folderId, query, init = false}) {
const folder = getters.getFolder(accountId, folderId)
if (folder.isUnified) {
@@ -350,6 +364,7 @@ export default {
dispatch('syncEnvelopes', {
accountId: account.id,
folderId: folder.id,
+ query,
init,
})
)
@@ -358,7 +373,7 @@ export default {
)
}
- const uids = getters.getEnvelopes(accountId, folderId).map(env => env.id)
+ const uids = getters.getEnvelopes(accountId, folderId, query).map(env => env.id)
return syncEnvelopes(accountId, folderId, uids, init).then(syncData => {
const unifiedFolder = getters.getUnifiedFolder(folder.specialRole)
@@ -366,28 +381,33 @@ export default {
syncData.newMessages.forEach(envelope => {
commit('addEnvelope', {
accountId,
- folder,
+ folderId,
envelope,
+ query,
})
if (unifiedFolder) {
- commit('addUnifiedEnvelope', {
- folder: unifiedFolder,
+ commit('addEnvelope', {
+ accountId: unifiedFolder.accountId,
+ folderId: unifiedFolder.id,
envelope,
+ query,
})
}
})
syncData.changedMessages.forEach(envelope => {
commit('addEnvelope', {
accountId,
- folder,
+ folderId,
envelope,
+ query,
})
})
syncData.vanishedMessages.forEach(id => {
commit('removeEnvelope', {
accountId,
- folder,
+ folderId,
id,
+ query,
})
// Already removed from unified inbox
})
@@ -486,7 +506,7 @@ export default {
const folder = getters.getFolder(envelope.accountId, envelope.folderId)
commit('removeEnvelope', {
accountId: envelope.accountId,
- folder,
+ folderId: envelope.folderId,
id: envelope.id,
})
@@ -503,7 +523,7 @@ export default {
console.error('could not delete message', err)
commit('addEnvelope', {
accountId: envelope.accountId,
- folder,
+ folderId: envelope.folderId,
envelope,
})
throw err
diff --git a/src/store/getters.js b/src/store/getters.js
new file mode 100644
index 000000000..2082acd09
--- /dev/null
+++ b/src/store/getters.js
@@ -0,0 +1,68 @@
+/*
+ * @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/>.
+ */
+
+import {defaultTo, head} from 'ramda'
+
+import {UNIFIED_ACCOUNT_ID} from './constants'
+import {normalizeEnvelopeListId} from './normalization'
+
+export const getters = {
+ getPreference: state => (key, def) => {
+ return defaultTo(def, state.preferences[key])
+ },
+ getAccount: state => id => {
+ return state.accounts[id]
+ },
+ accounts: state => {
+ return state.accountList.map(id => state.accounts[id])
+ },
+ getFolder: state => (accountId, folderId) => {
+ return state.folders[accountId + '-' + folderId]
+ },
+ getFolders: state => accountId => {
+ return state.accounts[accountId].folders.map(folderId => state.folders[folderId])
+ },
+ getSubfolders: (state, getters) => (accountId, folderId) => {
+ const folder = getters.getFolder(accountId, folderId)
+
+ return folder.folders.map(id => state.folders[id])
+ },
+ getUnifiedFolder: state => specialRole => {
+ return head(
+ state.accounts[UNIFIED_ACCOUNT_ID].folders
+ .map(folderId => state.folders[folderId])
+ .filter(folder => folder.specialRole === specialRole)
+ )
+ },
+ getEnvelope: state => (accountId, folderId, id) => {
+ return state.envelopes[accountId + '-' + folderId + '-' + id]
+ },
+ getEnvelopeById: state => id => {
+ return state.envelopes[id]
+ },
+ getEnvelopes: (state, getters) => (accountId, folderId, query) => {
+ const list = getters.getFolder(accountId, folderId).envelopeLists[normalizeEnvelopeListId(query)] || []
+ return list.map(msgId => state.envelopes[msgId])
+ },
+ getMessage: state => (accountId, folderId, id) => {
+ return state.messages[accountId + '-' + folderId + '-' + id]
+ },
+}
diff --git a/src/store/index.js b/src/store/index.js
index 468ba606f..512da958a 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -19,67 +19,16 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-import head from 'lodash/fp/head'
-import {translate as t} from '@nextcloud/l10n'
import Vue from 'vue'
import Vuex from 'vuex'
-import {value} from '../util/undefined'
-
import {UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID, UNIFIED_INBOX_UID} from './constants'
import actions from './actions'
+import {getters} from './getters'
import mutations from './mutations'
Vue.use(Vuex)
-export const getters = {
- getPreference: state => (key, def) => {
- return value(state.preferences[key]).or(def)
- },
- getAccount: state => id => {
- return state.accounts[id]
- },
- accounts: state => {
- return state.accountList.map(id => state.accounts[id])
- },
- getFolder: state => (accountId, folderId) => {
- return state.folders[accountId + '-' + folderId]
- },
- getFolders: state => accountId => {
- return state.accounts[accountId].folders.map(folderId => state.folders[folderId])
- },
- getSubfolders: (state, getters) => (accountId, folderId) => {
- const folder = getters.getFolder(accountId, folderId)
-
- return folder.folders.map(id => state.folders[id])
- },
- getUnifiedFolder: state => specialRole => {
- return head(
- state.accounts[UNIFIED_ACCOUNT_ID].folders
- .map(folderId => state.folders[folderId])
- .filter(folder => folder.specialRole === specialRole)
- )
- },
- getEnvelope: state => (accountId, folderId, id) => {
- return state.envelopes[accountId + '-' + folderId + '-' + id]
- },
- getEnvelopeById: state => id => {
- return state.envelopes[id]
- },
- getEnvelopes: (state, getters) => (accountId, folderId) => {
- return getters.getFolder(accountId, folderId).envelopes.map(msgId => state.envelopes[msgId])
- },
- getSearchEnvelopes: (state, getters) => (accountId, folderId) => {
- return getters.getFolder(accountId, folderId).searchEnvelopes.map(msgId => state.envelopes[msgId])
- },
- getMessage: state => (accountId, folderId, id) => {
- return state.messages[accountId + '-' + folderId + '-' + id]
- },
- getMessageByUid: state => uid => {
- return state.messages[uid]
- },
-}
-
export default new Vuex.Store({
strict: process.env.NODE_ENV !== 'production',
state: {
@@ -104,8 +53,7 @@ export default new Vuex.Store({
specialRole: 'inbox',
unread: 0,
folders: [],
- envelopes: [],
- searchEnvelopes: [],
+ envelopeLists: {},
},
},
envelopes: {},
diff --git a/src/store/mutations.js b/src/store/mutations.js
index ad2e7a738..b07e20247 100644
--- a/src/store/mutations.js
+++ b/src/store/mutations.js
@@ -25,14 +25,14 @@ import Vue from 'vue'
import {buildMailboxHierarchy} from '../imap/MailboxHierarchy'
import {havePrefix} from '../imap/MailboxPrefix'
+import {normalizedFolderId, normalizedMessageId, normalizeEnvelopeListId} from './normalization'
import {sortMailboxes} from '../imap/MailboxSorter'
import {UNIFIED_ACCOUNT_ID} from './constants'
const addFolderToState = (state, account) => folder => {
const id = account.id + '-' + folder.id
folder.accountId = account.id
- folder.envelopes = []
- folder.searchEnvelopes = []
+ folder.envelopeLists = {}
Vue.set(state.folders, id, folder)
return id
}
@@ -104,43 +104,15 @@ export default {
account.folders.push(id)
})
},
- addEnvelope(state, {accountId, folder, envelope}) {
- const uid = accountId + '-' + folder.id + '-' + envelope.id
- envelope.accountId = accountId
- envelope.folderId = folder.id
- envelope.uid = uid
- Vue.set(state.envelopes, uid, envelope)
+ addEnvelope(state, {accountId, folderId, query, envelope}) {
+ const folder = state.folders[normalizedFolderId(accountId, folderId)]
+ Vue.set(state.envelopes, envelope.uid, envelope)
+ const listId = normalizeEnvelopeListId(query)
+ const existing = folder.envelopeLists[listId] || []
Vue.set(
- folder,
- 'envelopes',
- sortedUniq(orderBy(id => state.envelopes[id].dateInt)('desc')(folder.envelopes.concat([uid])))
- )
- },
- addSearchEnvelopes(state, {accountId, folder, envelopes, clear}) {
- const uids = envelopes.map(envelope => {
- const uid = accountId + '-' + folder.id + '-' + envelope.id
- envelope.accountId = accountId
- envelope.folderId = folder.id
- envelope.uid = uid
- Vue.set(state.envelopes, uid, envelope)
- return uid
- })
-
- if (clear) {
- Vue.set(folder, 'searchEnvelopes', uids)
- } else {
- Vue.set(
- folder,
- 'searchEnvelopes',
- sortedUniq(orderBy(id => state.envelopes[id].dateInt)('desc')(folder.searchEnvelopes.concat(uids)))
- )
- }
- },
- addUnifiedEnvelope(state, {folder, envelope}) {
- Vue.set(
- folder,
- 'envelopes',
- sortedUniq(orderBy(id => state.envelopes[id].dateInt)('desc')(folder.envelopes.concat([envelope.uid])))
+ folder.envelopeLists,
+ listId,
+ sortedUniq(orderBy(id => state.envelopes[id].dateInt, 'desc', existing.concat([envelope.uid])))
)
},
addUnifiedEnvelopes(state, {folder, uids}) {
@@ -152,29 +124,31 @@ export default {
flagEnvelope(state, {envelope, flag, value}) {
envelope.flags[flag] = value
},
- removeEnvelope(state, {accountId, folder, id}) {
- const envelopeUid = accountId + '-' + folder.id + '-' + id
- const idx = folder.envelopes.indexOf(envelopeUid)
+ removeEnvelope(state, {accountId, folderId, id, query}) {
+ const folder = state.folders[normalizedFolderId(accountId, folderId)]
+ const list = folder.envelopeLists[normalizeEnvelopeListId(query)]
+ const idx = list.indexOf(normalizedMessageId(accountId, folderId, id))
if (idx < 0) {
console.warn('envelope does not exist', accountId, folder.id, id)
return
}
- folder.envelopes.splice(idx, 1)
+ list.splice(idx, 1)
const unifiedAccount = state.accounts[UNIFIED_ACCOUNT_ID]
unifiedAccount.folders
.map(fId => state.folders[fId])
.filter(f => f.specialRole === folder.specialRole)
.forEach(folder => {
- const idx = folder.envelopes.indexOf(envelopeUid)
+ const list = folder.envelopeLists[normalizeEnvelopeListId(query)]
+ const idx = list.indexOf(normalizedMessageId(accountId, folderId, id))
if (idx < 0) {
console.warn('envelope does not exist in unified mailbox', accountId, folder.id, id)
return
}
- folder.envelopes.splice(idx, 1)
+ list.splice(idx, 1)
})
- Vue.delete(folder.envelopes, envelopeUid)
+ Vue.delete(list, normalizedMessageId(accountId, folderId, id))
},
addMessage(state, {accountId, folderId, message}) {
const uid = accountId + '-' + folderId + '-' + message.id
diff --git a/src/util/undefined.js b/src/store/normalization.js
index 929686c97..5375a9755 100644
--- a/src/util/undefined.js
+++ b/src/store/normalization.js
@@ -1,7 +1,7 @@
/*
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
@@ -19,18 +19,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-class IndefinableValue {
- constructor(value) {
- this.value = value
- }
+import {curry, defaultTo} from 'ramda'
- or(other) {
- if (this.value === undefined) {
- return other
- } else {
- return this.value
- }
- }
-}
+export const normalizedFolderId = curry((accountId, folderId) => {
+ return `${accountId}-${folderId}`
+})
-export const value = value => new IndefinableValue(value)
+export const normalizedMessageId = curry((accountId, folderId, messageId) => {
+ return `${accountId}-${folderId}-${messageId}`
+})
+
+export const normalizeEnvelopeListId = defaultTo('')
diff --git a/src/tests/setup.js b/src/tests/setup.js
index e3db9cef8..9245898f7 100644
--- a/src/tests/setup.js
+++ b/src/tests/setup.js
@@ -20,7 +20,11 @@
*/
require('jsdom-global')()
-global.expect = require('chai').expect
+const chai = require('chai')
+const sinonChai = require('sinon-chai')
+
+chai.use(sinonChai)
+global.expect = chai.expect
// https://github.com/vuejs/vue-test-utils/issues/936
// better fix for "TypeError: Super expression must either be null or
// a function" than pinning an old version of prettier.
diff --git a/src/tests/unit/store/actions.spec.js b/src/tests/unit/store/actions.spec.js
new file mode 100644
index 000000000..893a9e536
--- /dev/null
+++ b/src/tests/unit/store/actions.spec.js
@@ -0,0 +1,313 @@
+/*
+ * @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/>.
+ */
+
+import sinon from 'sinon'
+import {curry, prop, range, reverse} from 'ramda'
+import orderBy from 'lodash/fp/orderBy'
+
+import actions from '../../../store/actions'
+import * as MessageService from '../../../service/MessageService'
+import {normalizedMessageId} from '../../../store/normalization'
+import {UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID} from '../../../store/constants'
+
+const mockEnvelope = curry((accountId, folderId, id) => ({
+ accountId,
+ folderId,
+ id,
+ uid: normalizedMessageId(accountId, folderId, id),
+ dateInt: id * 10000,
+}))
+
+describe('Vuex store actions', () => {
+ let context
+
+ beforeEach(() => {
+ context = {
+ commit: sinon.stub(),
+ dispatch: sinon.stub(),
+ getters: {
+ accounts: [],
+ getFolder: sinon.stub(),
+ getFolders: sinon.stub(),
+ getEnvelopeById: sinon.stub(),
+ getEnvelopes: sinon.stub(),
+ },
+ }
+ })
+
+ it('combines unified inbox even if no inboxes are present', () => {
+ context.getters.getFolder.returns({
+ isUnified: true,
+ })
+
+ const envelopes = actions.fetchEnvelopes(context, {
+ accountId: UNIFIED_ACCOUNT_ID,
+ folderId: UNIFIED_INBOX_ID,
+ })
+
+ expect(envelopes).to.be.empty
+ })
+
+ it('creates a unified page from one mailbox', async () => {
+ context.getters.accounts.push({
+ id: 13,
+ accountId: 13,
+ })
+ context.getters.getFolder.withArgs(UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID).returns({
+ isUnified: true,
+ specialRole: 'inbox',
+ })
+ context.getters.getFolders.withArgs(13).returns([
+ {
+ id: 'INBOX',
+ accountId: 13,
+ specialRole: 'inbox',
+ },
+ {
+ id: 'Drafts',
+ accountId: 13,
+ specialRole: 'draft',
+ },
+ ])
+ context.dispatch
+ .withArgs('fetchEnvelopes', {
+ accountId: 13,
+ folderId: 'INBOX',
+ query: undefined,
+ })
+ .returns([
+ {
+ accountId: 13,
+ folderId: 'INBOX',
+ uid: '13-INBOX-123',
+ subject: 'msg1',
+ },
+ ])
+
+ const envelopes = await actions.fetchEnvelopes(context, {
+ accountId: UNIFIED_ACCOUNT_ID,
+ folderId: UNIFIED_INBOX_ID,
+ })
+
+ expect(envelopes).to.deep.equal([
+ {
+ accountId: 13,
+ folderId: 'INBOX',
+ uid: '13-INBOX-123',
+ subject: 'msg1',
+ },
+ ])
+ expect(context.dispatch).to.have.been.calledOnce
+ expect(context.commit).to.have.been.calledWith('addEnvelope', {
+ accountId: UNIFIED_ACCOUNT_ID,
+ folderId: UNIFIED_INBOX_ID,
+ envelope: {
+ accountId: 13,
+ folderId: 'INBOX',
+ uid: '13-INBOX-123',
+ subject: 'msg1',
+ },
+ query: undefined,
+ })
+ })
+
+ it('fetches the next individual page', async () => {
+ context.getters.accounts.push({
+ id: 13,
+ accountId: 13,
+ })
+ context.getters.getFolder.withArgs(13, 'INBOX').returns({
+ id: 'INBOX',
+ accountId: 13,
+ specialRole: 'inbox',
+ envelopeLists: {
+ '': reverse(range(21, 40).map(normalizedMessageId(13, 'INBOX'))),
+ },
+ })
+ context.getters.getEnvelopeById
+ .withArgs(normalizedMessageId(13, 'INBOX', 21))
+ .returns(mockEnvelope(13, 'INBOX', 1))
+ sinon.stub(MessageService, 'fetchEnvelopes').returns(
+ Promise.resolve(
+ reverse(
+ range(1, 21).map(n => ({
+ id: n,
+ uid: normalizedMessageId(13, 'INBOX', n),
+ dateInt: n * 10000,
+ }))
+ )
+ )
+ )
+
+ const page = await actions.fetchNextEnvelopePage(context, {
+ accountId: 13,
+ folderId: 'INBOX',
+ })
+
+ expect(page).to.deep.equal(
+ reverse(
+ range(1, 21).map(n => ({
+ id: n,
+ uid: normalizedMessageId(13, 'INBOX', n),
+ dateInt: n * 10000,
+ }))
+ )
+ )
+ expect(context.commit).to.have.callCount(20)
+ })
+
+ it('builds the next unified page with local data', async () => {
+ const page1 = reverse(range(25, 30))
+ const page2 = reverse(range(30, 35))
+ const msgs1 = reverse(range(10, 30))
+ const msgs2 = reverse(range(5, 35))
+ context.getters.accounts.push({
+ id: 13,
+ accountId: 13,
+ })
+ context.getters.accounts.push({
+ id: 26,
+ accountId: 26,
+ })
+ context.getters.getFolder.withArgs(UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID).returns({
+ isUnified: true,
+ specialRole: 'inbox',
+ accountId: UNIFIED_ACCOUNT_ID,
+ id: UNIFIED_INBOX_ID,
+ })
+ context.getters.getFolders.withArgs(13).returns([
+ {
+ id: 'INBOX',
+ accountId: 13,
+ specialRole: 'inbox',
+ },
+ {
+ id: 'Drafts',
+ accountId: 13,
+ specialRole: 'draft',
+ },
+ ])
+ context.getters.getFolders.withArgs(26).returns([
+ {
+ id: 'INBOX',
+ accountId: 26,
+ specialRole: 'inbox',
+ },
+ {
+ id: 'Drafts',
+ accountId: 26,
+ specialRole: 'draft',
+ },
+ ])
+ context.getters.getEnvelopes
+ .withArgs(UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID, undefined)
+ .returns(
+ orderBy(
+ prop('dateInt'),
+ 'desc',
+ page1.map(mockEnvelope(13, 'INBOX')).concat(page2.map(mockEnvelope(26, 'INBOX')))
+ )
+ )
+ context.getters.getEnvelopes.withArgs(13, 'INBOX', undefined).returns(msgs1.map(mockEnvelope(13, 'INBOX')))
+ context.getters.getEnvelopes.withArgs(26, 'INBOX', undefined).returns(msgs2.map(mockEnvelope(26, 'INBOX')))
+
+ const page = await actions.fetchNextEnvelopePage(context, {
+ accountId: UNIFIED_ACCOUNT_ID,
+ folderId: UNIFIED_INBOX_ID,
+ })
+
+ expect(context.dispatch).not.have.been.called
+ expect(page.length).to.equal(20)
+ })
+
+ it('builds the next unified page with partial fetch', async () => {
+ const page1 = reverse(range(25, 30))
+ const page2 = reverse(range(30, 35))
+ const msgs1 = reverse(range(25, 30))
+ const msgs2 = reverse(range(5, 35))
+ context.getters.accounts.push({
+ id: 13,
+ accountId: 13,
+ })
+ context.getters.accounts.push({
+ id: 26,
+ accountId: 26,
+ })
+ context.getters.getFolder.withArgs(UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID).returns({
+ isUnified: true,
+ specialRole: 'inbox',
+ accountId: UNIFIED_ACCOUNT_ID,
+ id: UNIFIED_INBOX_ID,
+ })
+ context.getters.getFolders.withArgs(13).returns([
+ {
+ id: 'INBOX',
+ accountId: 13,
+ specialRole: 'inbox',
+ },
+ {
+ id: 'Drafts',
+ accountId: 13,
+ specialRole: 'draft',
+ },
+ ])
+ context.getters.getFolders.withArgs(26).returns([
+ {
+ id: 'INBOX',
+ accountId: 26,
+ specialRole: 'inbox',
+ },
+ {
+ id: 'Drafts',
+ accountId: 26,
+ specialRole: 'draft',
+ },
+ ])
+ context.getters.getEnvelopes
+ .withArgs(UNIFIED_ACCOUNT_ID, UNIFIED_INBOX_ID, undefined)
+ .returns(
+ orderBy(
+ prop('dateInt'),
+ 'desc',
+ page1.map(mockEnvelope(13, 'INBOX')).concat(page2.map(mockEnvelope(26, 'INBOX')))
+ )
+ )
+ context.getters.getEnvelopes.withArgs(13, 'INBOX', undefined).returns(msgs1.map(mockEnvelope(13, 'INBOX')))
+ context.getters.getEnvelopes.withArgs(26, 'INBOX', undefined).returns(msgs2.map(mockEnvelope(26, 'INBOX')))
+
+ await actions.fetchNextEnvelopePage(context, {
+ accountId: UNIFIED_ACCOUNT_ID,
+ folderId: UNIFIED_INBOX_ID,
+ })
+
+ expect(context.dispatch).have.been.calledTwice
+ expect(context.dispatch).have.been.calledWith('fetchNextEnvelopePage', {
+ accountId: 26,
+ folderId: 'INBOX',
+ query: undefined,
+ })
+ expect(context.dispatch).have.been.calledWith('fetchNextEnvelopePage', {
+ accountId: UNIFIED_ACCOUNT_ID,
+ folderId: UNIFIED_INBOX_ID,
+ query: undefined,
+ })
+ })
+})
diff --git a/src/tests/unit/store/getters.spec.js b/src/tests/unit/store/getters.spec.js
new file mode 100644
index 000000000..6f1581b71
--- /dev/null
+++ b/src/tests/unit/store/getters.spec.js
@@ -0,0 +1,72 @@
+/*
+ * @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/>.
+ */
+
+import {curry, mapObjIndexed} from 'ramda'
+
+import {getters} from '../../../store/getters'
+
+const bindGetterToState = curry((getters, state, num, key) => getters[key](state, getters))
+
+describe('Vuex store getters', () => {
+ let state
+ let bindGetters
+
+ beforeEach(() => {
+ state = {
+ accountList: [],
+ accounts: {},
+ folders: {},
+ envelopes: {},
+ messages: {},
+ }
+ bindGetters = () => mapObjIndexed(bindGetterToState(getters, state), getters)
+ })
+
+ it('gets all accounts', () => {
+ state.accountList.push('13')
+ state.accounts[13] = {
+ accountId: 13,
+ }
+ const getters = bindGetters()
+
+ const accounts = getters.accounts
+
+ expect(accounts).to.deep.equal([
+ {
+ accountId: 13,
+ },
+ ])
+ })
+ it('gets a specific account', () => {
+ state.accountList.push('13')
+ state.accounts[13] = {
+ accountId: 13,
+ }
+ const getters = bindGetters()
+
+ const accounts = getters.getAccount(13)
+
+ expect(accounts).to.deep.equal({
+ accountId: 13,
+ })
+ })
+ it('gets account folders', () => {})
+})
diff --git a/src/tests/unit/store/mutations.spec.js b/src/tests/unit/store/mutations.spec.js
new file mode 100644
index 000000000..23e79362f
--- /dev/null
+++ b/src/tests/unit/store/mutations.spec.js
@@ -0,0 +1,130 @@
+/*
+ * @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/>.
+ */
+
+import mutations from '../../../store/mutations'
+import {UNIFIED_ACCOUNT_ID} from '../../../store/constants'
+
+describe('Vuex store mutations', () => {
+ it('adds envelopes', () => {
+ const state = {
+ envelopes: {},
+ folders: {
+ '13-INBOX': {
+ id: 'INBOX',
+ envelopeLists: {},
+ },
+ },
+ }
+
+ mutations.addEnvelope(state, {
+ accountId: 13,
+ folderId: 'INBOX',
+ query: undefined,
+ envelope: {
+ accountId: 13,
+ folderId: 'INBOX',
+ id: 123,
+ subject: 'henlo',
+ uid: '13-INBOX-123',
+ },
+ })
+
+ expect(state).to.deep.equal({
+ envelopes: {
+ '13-INBOX-123': {
+ accountId: 13,
+ folderId: 'INBOX',
+ uid: '13-INBOX-123',
+ id: 123,
+ subject: 'henlo',
+ },
+ },
+ folders: {
+ '13-INBOX': {
+ id: 'INBOX',
+ envelopeLists: {
+ '': ['13-INBOX-123'],
+ },
+ },
+ },
+ })
+ })
+
+ it('removes an envelope', () => {
+ const state = {
+ accounts: {
+ [UNIFIED_ACCOUNT_ID]: {
+ accountId: UNIFIED_ACCOUNT_ID,
+ id: UNIFIED_ACCOUNT_ID,
+ folders: [],
+ },
+ },
+ envelopes: {
+ '13-INBOX-123': {
+ accountId: 13,
+ folderId: 'INBOX',
+ id: 123,
+ uid: '13-INBOX-123',
+ },
+ },
+ folders: {
+ '13-INBOX': {
+ id: 'INBOX',
+ envelopeLists: {
+ '': ['13-INBOX-123'],
+ },
+ },
+ },
+ }
+
+ mutations.removeEnvelope(state, {
+ accountId: 13,
+ folderId: 'INBOX',
+ id: 123,
+ })
+
+ expect(state).to.deep.equal({
+ accounts: {
+ [UNIFIED_ACCOUNT_ID]: {
+ accountId: UNIFIED_ACCOUNT_ID,
+ id: UNIFIED_ACCOUNT_ID,
+ folders: [],
+ },
+ },
+ envelopes: {
+ '13-INBOX-123': {
+ accountId: 13,
+ folderId: 'INBOX',
+ id: 123,
+ uid: '13-INBOX-123',
+ },
+ },
+ folders: {
+ '13-INBOX': {
+ id: 'INBOX',
+ envelopeLists: {
+ '': [],
+ },
+ },
+ },
+ })
+ })
+})
diff --git a/src/tests/unit/util/undefined.spec.js b/src/tests/unit/store/normalization.spec.js
index 6fe30071b..fc6eb66c9 100644
--- a/src/tests/unit/util/undefined.spec.js
+++ b/src/tests/unit/store/normalization.spec.js
@@ -1,7 +1,7 @@
/*
- * @copyright 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
+ * @copyright 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
- * @author 2018 Christoph Wurst <christoph@winzerhof-wurst.at>
+ * @author 2020 Christoph Wurst <christoph@winzerhof-wurst.at>
*
* @license GNU AGPL version 3 or any later version
*
@@ -19,22 +19,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-import {value} from '../../../util/undefined'
+import {normalizedFolderId, normalizedMessageId} from '../../../store/normalization'
-describe('undefined helper', () => {
- it('returns other value', () => {
- const wrapped = undefined
+describe('Vuex store normalization', () => {
+ it('creates a unique folder ID', () => {
+ const accountId = 13
+ const folderId = 'INBOX'
- const val = value(wrapped)
+ const id = normalizedFolderId(accountId, folderId)
- expect(val.or(3)).to.equal(3)
+ expect(id).to.equal('13-INBOX')
})
- it('returns actual value', () => {
- const wrapped = 4
+ it('creates a unique message ID', () => {
+ const accountId = 13
+ const folderId = 'INBOX'
+ const messageId = 123
- const val = value(wrapped)
+ const id = normalizedMessageId(accountId, folderId, 123)
- expect(val.or(3)).to.equal(4)
+ expect(id).to.equal('13-INBOX-123')
})
})
diff --git a/src/views/Home.vue b/src/views/Home.vue
index e19bd2037..e47e8caab 100644
--- a/src/views/Home.vue
+++ b/src/views/Home.vue
@@ -1,14 +1,14 @@
<template>
<Content v-shortkey.once="['c']" app-name="mail" @shortkey.native="onNewMessage">
<Navigation />
- <FolderContent v-if="activeAccount" :account="activeAccount" :folder="activeFolder" />
+ <MailboxMessage v-if="activeAccount" :account="activeAccount" :folder="activeFolder" />
</Content>
</template>
<script>
import Content from '@nextcloud/vue/dist/Components/Content'
-import FolderContent from '../components/FolderContent'
+import MailboxMessage from '../components/MailboxMessage'
import logger from '../logger'
import Navigation from '../components/Navigation'
@@ -16,7 +16,7 @@ export default {
name: 'Home',
components: {
Content,
- FolderContent,
+ MailboxMessage,
Navigation,
},
computed: {