diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2020-02-20 18:12:11 +0300 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2020-02-24 09:12:16 +0300 |
commit | 46916ee4f396e71075fd228a87ef294e38825dea (patch) | |
tree | fbe43ebdd9862f3992ea60da7af40ea1c627a05e | |
parent | 5be88a693bf7e516b1159f3c0500302c0adbe514 (diff) |
Make envelope lists more versatile
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
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: { |