From 6b375eca9f3642319cd9411df71379b5e625d68d Mon Sep 17 00:00:00 2001 From: Vladimir Y Date: Fri, 10 Apr 2020 18:20:02 +0300 Subject: [PATCH] enable "move all to" button, closes #276 --- package.json | 2 +- .../__test__/database/index.spec.ts | 1 + src/electron-main/database/constants.ts | 2 +- src/electron-main/database/entity/folder.ts | 5 +- src/electron-main/database/util.ts | 1 + src/electron-main/storage-upgrade.ts | 17 ++ .../webview/lib/database-entity/folder.ts | 1 + .../lib/rest-model/response-entity/folder.ts | 2 +- .../webview/primary/api/index.ts | 14 +- .../webview/primary/provider-api.ts | 3 +- src/shared/api/webview/primary.ts | 9 +- src/shared/model/database/index.ts | 1 + .../account-view-primary.component.ts | 19 +- .../app/_accounts/accounts.effects.ts | 168 +++++++++--------- .../_db-view/db-view-folder.component.html | 2 +- .../_db-view/db-view-folder.component.scss | 11 +- .../app/_db-view/db-view-mails.component.html | 31 +++- .../app/_db-view/db-view-mails.component.scss | 4 + .../app/_db-view/db-view-mails.component.ts | 106 ++++++++++- .../app/_db-view/db-view.effects.ts | 64 +++++-- src/web/browser-window/app/model.ts | 6 +- .../app/store/actions/accounts.ts | 14 +- .../app/store/actions/db-view.ts | 4 - .../app/store/reducers/accounts.ts | 28 ++- .../app/store/reducers/db-view.ts | 31 ---- 25 files changed, 384 insertions(+), 162 deletions(-) diff --git a/package.json b/package.json index e5219681a..e9a06e2f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "electron-mail", "description": "Unofficial ProtonMail Desktop App", - "version": "4.4.3", + "version": "4.5.0", "author": "Vladimir Yakovlev ", "license": "MIT", "homepage": "https://github.com/vladimiry/ElectronMail", diff --git a/src/electron-main/__test__/database/index.spec.ts b/src/electron-main/__test__/database/index.spec.ts index 5e25086bf..5bd02401e 100644 --- a/src/electron-main/__test__/database/index.spec.ts +++ b/src/electron-main/__test__/database/index.spec.ts @@ -50,6 +50,7 @@ function buildFolder(): Folder { name: randomstring.generate(), folderType: MAIL_FOLDER_TYPE.SENT, mailFolderId: "123", + exclusive: 234, }; } diff --git a/src/electron-main/database/constants.ts b/src/electron-main/database/constants.ts index 6c290afd8..5f05bfdd9 100644 --- a/src/electron-main/database/constants.ts +++ b/src/electron-main/database/constants.ts @@ -1,3 +1,3 @@ -export const DATABASE_VERSION = "5"; +export const DATABASE_VERSION = "6"; export const DB_INSTANCE_PROP_NAME = "dbInstance"; diff --git a/src/electron-main/database/entity/folder.ts b/src/electron-main/database/entity/folder.ts index b9ecf8de9..1846e89cd 100644 --- a/src/electron-main/database/entity/folder.ts +++ b/src/electron-main/database/entity/folder.ts @@ -1,4 +1,4 @@ -import {IsIn, IsNotEmpty, IsString} from "class-validator"; +import {IsIn, IsInt, IsNotEmpty, IsString} from "class-validator"; import * as Model from "src/shared/model/database"; import {Entity} from "./base"; @@ -13,4 +13,7 @@ export class Folder extends Entity implements Model.Folder { @IsNotEmpty() @IsString() mailFolderId!: Model.Folder["mailFolderId"]; + + @IsInt() + exclusive!: Model.Folder["exclusive"]; } diff --git a/src/electron-main/database/util.ts b/src/electron-main/database/util.ts index 55406f9b0..84901cedf 100644 --- a/src/electron-main/database/util.ts +++ b/src/electron-main/database/util.ts @@ -31,6 +31,7 @@ export const resolveAccountFolders: ( id as any, // eslint-disable-line @typescript-eslint/no-explicit-any ), mailFolderId: id, + exclusive: 1, })); const result: typeof resolveAccountFolders = (account) => [ diff --git a/src/electron-main/storage-upgrade.ts b/src/electron-main/storage-upgrade.ts index 9500c2663..0862613ee 100644 --- a/src/electron-main/storage-upgrade.ts +++ b/src/electron-main/storage-upgrade.ts @@ -518,6 +518,23 @@ export async function upgradeDatabase(db: Database, accounts: Settings["accounts needToSave = true; } + if (Number(db.getVersion()) < 6) { + for (const {account} of db) { + for (const [/* folderPk */, folder] of Object.entries(account.folders)) { + if (typeof folder.exclusive === "number") { + continue; + } + type RawFolder = Pick; + const rawRestResponseFolder: Readonly = JSON.parse(folder.raw); + (folder as Mutable>).exclusive = rawRestResponseFolder.Exclusive; + if (typeof folder.exclusive !== "number") { + throw new Error(`Failed to resolve "rawFolder.Exclusive" numeric property`); + } + needToSave = true; + } + } + } + // removing non existent accounts await (async (): Promise => { const removePks: DbAccountPk[] = []; diff --git a/src/electron-preload/webview/lib/database-entity/folder.ts b/src/electron-preload/webview/lib/database-entity/folder.ts index 405107381..54cf4e098 100644 --- a/src/electron-preload/webview/lib/database-entity/folder.ts +++ b/src/electron-preload/webview/lib/database-entity/folder.ts @@ -31,5 +31,6 @@ export function buildFolder(input: RestModel.Label): Model.Folder { folderType, name, mailFolderId: input.ID, + exclusive: input.Exclusive, }; } diff --git a/src/electron-preload/webview/lib/rest-model/response-entity/folder.ts b/src/electron-preload/webview/lib/rest-model/response-entity/folder.ts index 6c09c4fb5..4fadadbf7 100644 --- a/src/electron-preload/webview/lib/rest-model/response-entity/folder.ts +++ b/src/electron-preload/webview/lib/rest-model/response-entity/folder.ts @@ -5,7 +5,7 @@ import {NumberBoolean} from "src/electron-preload/webview/lib/rest-model/common" export interface Label extends Entity { Color: string; Display: NumberBoolean; - Exclusive: NumberBoolean; + Exclusive: number; Name: string; Notify: NumberBoolean; Order: number; diff --git a/src/electron-preload/webview/primary/api/index.ts b/src/electron-preload/webview/primary/api/index.ts index 253101dcb..bef423942 100644 --- a/src/electron-preload/webview/primary/api/index.ts +++ b/src/electron-preload/webview/primary/api/index.ts @@ -116,8 +116,8 @@ const endpoints: ProtonApi = { } }, - async makeRead(input) { - _logger.info("makeRead()", input.zoneName); + async makeMailRead(input) { + _logger.info("makeMailRead()", input.zoneName); const {message} = await resolveProviderApi(); @@ -126,6 +126,16 @@ const endpoints: ProtonApi = { // TODO consider triggering the "refresh" action (clicking the "refresh" button action) }, + async setMailFolder(input) { + _logger.info("setMailFolder()", input.zoneName); + + const {message} = await resolveProviderApi(); + + await message.label({LabelID: input.folderId, IDs: input.messageIds}); + + // TODO consider triggering the "refresh" action (clicking the "refresh" button action) + }, + async fillLogin({login, zoneName}) { const logger = curryFunctionMembers(_logger, "fillLogin()", zoneName); diff --git a/src/electron-preload/webview/primary/provider-api.ts b/src/electron-preload/webview/primary/provider-api.ts index 4eca5cb70..a8faf9f51 100644 --- a/src/electron-preload/webview/primary/provider-api.ts +++ b/src/electron-preload/webview/primary/provider-api.ts @@ -43,6 +43,7 @@ export interface ProviderApi { params?: RestModel.QueryParams & { LabelID?: Unpacked }, ) => Promise; read: (params: { IDs: ReadonlyArray }) => Promise; + label: (params: { LabelID: RestModel.Label["ID"]; IDs: ReadonlyArray }) => Promise; }; contact: { get: (id: RestModel.Contact["ID"]) => Promise; @@ -190,7 +191,7 @@ export async function resolveProviderApi(): Promise { }), message: resolveService(injector, "messageApi", { ...rateLimiting, - rateLimitedMethodNames: ["get", "query"/*, "read"*/], + rateLimitedMethodNames: ["get", "query"/*, "read"*/ /*, "label"*/], }), contact: resolveService(injector, "Contact", { ...rateLimiting, diff --git a/src/shared/api/webview/primary.ts b/src/shared/api/webview/primary.ts index 8d04e1fab..294720d2e 100644 --- a/src/shared/api/webview/primary.ts +++ b/src/shared/api/webview/primary.ts @@ -1,6 +1,6 @@ import {ActionType, ScanService, createWebViewApiService} from "electron-rpc-api"; -import {DbAccountPk, FsDbAccount, Mail} from "src/shared/model/database"; +import {DbAccountPk, Folder, FsDbAccount, Mail} from "src/shared/model/database"; import {LoginFieldContainer, MailPasswordFieldContainer, PasswordFieldContainer} from "src/shared/model/container"; import {Notifications} from "src/shared/model/account"; import {PACKAGE_NAME} from "src/shared/constants"; @@ -10,6 +10,7 @@ import {buildLoggerBundle} from "src/electron-preload/lib/util"; const {Promise, Observable} = ActionType; +// TODO drop "ZoneApiParameter" use export const PROTONMAIL_IPC_WEBVIEW_API_DEFINITION = { ping: Promise>(), @@ -27,8 +28,10 @@ export const PROTONMAIL_IPC_WEBVIEW_API_DEFINITION = { } & ZoneApiParameter>>(), fetchSingleMail: Promise>(), - makeRead: - Promise>(), + makeMailRead: + Promise } & ZoneApiParameter>>(), + setMailFolder: + Promise } & ZoneApiParameter>>(), notification: Observable, ProtonNotificationOutput>(), unlock: diff --git a/src/shared/model/database/index.ts b/src/shared/model/database/index.ts index 9a8c05ed4..f6d986900 100644 --- a/src/shared/model/database/index.ts +++ b/src/shared/model/database/index.ts @@ -23,6 +23,7 @@ export interface Folder extends Entity { readonly folderType: Unpacked; readonly name: string; readonly mailFolderId: string; + readonly exclusive: number; } export interface ConversationEntry extends Entity { diff --git a/src/web/browser-window/app/_accounts/account-view-primary.component.ts b/src/web/browser-window/app/_accounts/account-view-primary.component.ts index 654c20a38..b8d54a7a2 100644 --- a/src/web/browser-window/app/_accounts/account-view-primary.component.ts +++ b/src/web/browser-window/app/_accounts/account-view-primary.component.ts @@ -149,10 +149,25 @@ export class AccountViewPrimaryComponent extends AccountViewAbstractComponent im if (!account.makeReadMailParams) { return; } - const {messageIds, mailsBundleKey} = account.makeReadMailParams; + const {messageIds} = account.makeReadMailParams; this.event.emit({ type: "action", - payload: ACCOUNTS_ACTIONS.MakeMailRead({account, webView, messageIds, mailsBundleKey}), + payload: ACCOUNTS_ACTIONS.MakeMailRead({account, webView, messageIds}), + }); + }), + ); + + this.addSubscription( + this.account$.pipe( + distinctUntilChanged(({setMailFolderParams: prev}, {setMailFolderParams: curr}) => curr === prev), + ).subscribe((account) => { + if (!account.setMailFolderParams) { + return; + } + const {folderId, messageIds} = account.setMailFolderParams; + this.event.emit({ + type: "action", + payload: ACCOUNTS_ACTIONS.SetMailFolder({account, webView, folderId, messageIds}), }); }), ); diff --git a/src/web/browser-window/app/_accounts/accounts.effects.ts b/src/web/browser-window/app/_accounts/accounts.effects.ts index 14b9f5015..c37500897 100644 --- a/src/web/browser-window/app/_accounts/accounts.effects.ts +++ b/src/web/browser-window/app/_accounts/accounts.effects.ts @@ -167,94 +167,102 @@ export class AccountsEffects { ), ), this.api.webViewClient(webView, {finishPromise}).pipe( - mergeMap((webViewClient) => merge( - timer(0, ONE_MINUTE_MS * 5).pipe( - tap(() => logger.verbose(`triggered by: timer`)), - ), - FIRE_SYNCING_ITERATION$.pipe( - filter((value) => value.login === login), - tap(() => logger.verbose(`triggered by: FIRE_SYNCING_ITERATION$`)), - // user might be moving emails from here to there while syncing/"buildDbPatch" cycle is in progress - // debounce call reduces 404 fetch errors as we don't trigger fetching until user got settled down - debounceTime(ONE_SECOND_MS * 3), - ), - fromEvent(window, "online").pipe( - tap(() => logger.verbose(`triggered by: "window.online" event`)), - delay(ONE_SECOND_MS * 3), - ), - ).pipe( - debounceTime(ONE_SECOND_MS), - debounce(() => pingOnlineStatusEverySecond$), - debounce(() => notSyncingPing$), - concatMap(() => { - return from( - ipcMainClient("dbGetAccountMetadata")({login}), - ); - }), - withLatestFrom(this.store.pipe(select(OptionsSelectors.CONFIG.timeouts))), - concatMap(([metadata, timeouts]) => { - const bootstrapping = !isDatabaseBootstrapped(metadata); + mergeMap((webViewClient) => { + const syncingIterationTrigger$: Observable = merge( + timer(0, ONE_MINUTE_MS * 5).pipe( + tap(() => logger.verbose(`triggered by: timer`)), + map(() => null), + ), + fromEvent(window, "online").pipe( + tap(() => logger.verbose(`triggered by: "window.online" event`)), + delay(ONE_SECOND_MS * 3), + map(() => null), + ), + FIRE_SYNCING_ITERATION$.pipe( + filter((value) => value.login === login), + tap(() => logger.verbose(`triggered by: FIRE_SYNCING_ITERATION$`)), + // user might be moving emails from here to there while syncing/"buildDbPatch" cycle is in progress + // debounce call reduces 404 fetch errors as we don't trigger fetching until user got settled down + debounceTime(ONE_SECOND_MS * 3), + ), + ).pipe( + map(() => null), + ); - if (bootstrapping && bootstrappingTriggeredOnce) { - return throwError( - new Error(`Database bootstrap fetch has already been called once for the account, ${zoneName}`), + return syncingIterationTrigger$.pipe( + debounceTime(ONE_SECOND_MS), + debounce(() => pingOnlineStatusEverySecond$), + debounce(() => notSyncingPing$), + concatMap(() => { + return from( + ipcMainClient("dbGetAccountMetadata")({login}), ); - } - - const timeoutMs = bootstrapping - ? timeouts.dbBootstrapping - : timeouts.dbSyncing; + }), + withLatestFrom(this.store.pipe(select(OptionsSelectors.CONFIG.timeouts))), + concatMap(([metadata, timeouts]) => { + const bootstrapping = !isDatabaseBootstrapped(metadata); - logger.verbose( - `calling "buildDbPatch" api`, - JSON.stringify({ - timeoutMs, - bootstrapping, - bootstrappingTriggeredOnce, - }), - ); + if (bootstrapping && bootstrappingTriggeredOnce) { + return throwError( + new Error(`Database bootstrap fetch has already been called once for the account, ${zoneName}`), + ); + } + + const timeoutMs = bootstrapping + ? timeouts.dbBootstrapping + : timeouts.dbSyncing; + + logger.verbose( + `calling "buildDbPatch" api`, + JSON.stringify({ + timeoutMs, + bootstrapping, + bootstrappingTriggeredOnce, + }), + ); - this.store.dispatch(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {syncing: true}})); + this.store.dispatch(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {syncing: true}})); - const result$ = from( - webViewClient("buildDbPatch", {timeoutMs})({ - login, - zoneName, - metadata, - }), - ).pipe( - concatMap(() => EMPTY), - takeUntil( - fromEvent(window, "offline").pipe( - tap(() => { - logger.verbose(`offline event`); - - // tslint:disable-next-line:early-exit - if (bootstrapping && bootstrappingTriggeredOnce) { - bootstrappingTriggeredOnce = false; - logger.verbose( - [ - `reset "bootstrappingTriggeredOnce" state as previous iteration got aborted`, - `by the "offline" event`, - ].join(" "), - ); - } - }), + const result$ = from( + webViewClient("buildDbPatch", {timeoutMs})({ + login, + zoneName, + metadata, + }), + ).pipe( + concatMap(() => of(ACCOUNTS_ACTIONS.Synced({pk: {login}}))), + takeUntil( + fromEvent(window, "offline").pipe( + tap(() => { + logger.verbose(`offline event`); + + // tslint:disable-next-line:early-exit + if (bootstrapping && bootstrappingTriggeredOnce) { + bootstrappingTriggeredOnce = false; + logger.verbose( + [ + `reset "bootstrappingTriggeredOnce" state as previous iteration`, + `got aborted by the "offline" event`, + ].join(" "), + ); + } + }), + ), ), - ), - finalize(() => { - return this.store.dispatch(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {syncing: false}})); - }), - ); + finalize(() => { + this.store.dispatch(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {syncing: false}})); + }), + ); - if (bootstrapping) { - bootstrappingTriggeredOnce = true; - logger.verbose("bootstrappingTriggeredOnce = true"); - } + if (bootstrapping) { + bootstrappingTriggeredOnce = true; + logger.verbose("bootstrappingTriggeredOnce = true"); + } - return result$; - }), - )), + return result$; + }), + ); + }), ), ).pipe( takeUntil(dispose$), diff --git a/src/web/browser-window/app/_db-view/db-view-folder.component.html b/src/web/browser-window/app/_db-view/db-view-folder.component.html index 15ea720f8..53974e49f 100644 --- a/src/web/browser-window/app/_db-view/db-view-folder.component.html +++ b/src/web/browser-window/app/_db-view/db-view-folder.component.html @@ -1,4 +1,4 @@ -
+
{{ folder.name }} ({{ folder.size }}) diff --git a/src/web/browser-window/app/_db-view/db-view-folder.component.scss b/src/web/browser-window/app/_db-view/db-view-folder.component.scss index f7cd610e8..96e8583a3 100644 --- a/src/web/browser-window/app/_db-view/db-view-folder.component.scss +++ b/src/web/browser-window/app/_db-view/db-view-folder.component.scss @@ -12,10 +12,15 @@ } .type { - &-0 { + &-0 { // custom .fa::before { - content: $fa-var-user-o; - font-size: 80%; + content: $fa-var-tag; + } + + &.folder-true { + .fa::before { + content: $fa-var-folder; + } } } diff --git a/src/web/browser-window/app/_db-view/db-view-mails.component.html b/src/web/browser-window/app/_db-view/db-view-mails.component.html index 86c7f5860..1aa30918e 100644 --- a/src/web/browser-window/app/_db-view/db-view-mails.component.html +++ b/src/web/browser-window/app/_db-view/db-view-mails.component.html @@ -10,9 +10,38 @@ style="white-space: nowrap" > Make all read - ({{ unreadCount }}) + ({{ count }}) +
+ +
{{ title$ | async }}: {{ (paging$ | async)?.end }} of {{ items.length }} diff --git a/src/web/browser-window/app/_db-view/db-view-mails.component.scss b/src/web/browser-window/app/_db-view/db-view-mails.component.scss index 59dfc9710..e1c702a29 100644 --- a/src/web/browser-window/app/_db-view/db-view-mails.component.scss +++ b/src/web/browser-window/app/_db-view/db-view-mails.component.scss @@ -7,6 +7,10 @@ flex-direction: column; .controls { + .dropdown-menu { + @include font-size($input-font-size-sm); + } + .title { @include app-db-view-badge-padding-radius; display: flex; diff --git a/src/web/browser-window/app/_db-view/db-view-mails.component.ts b/src/web/browser-window/app/_db-view/db-view-mails.component.ts index 1eaf84991..e3d1d3579 100644 --- a/src/web/browser-window/app/_db-view/db-view-mails.component.ts +++ b/src/web/browser-window/app/_db-view/db-view-mails.component.ts @@ -2,12 +2,14 @@ import {ChangeDetectionStrategy, Component, ElementRef, Input, OnDestroy, OnInit import {Observable, Subscription, combineLatest, fromEvent} from "rxjs"; import {Store} from "@ngrx/store"; import {distinctUntilChanged, map, mergeMap, take, tap, withLatestFrom} from "rxjs/operators"; +import {sortBy} from "remeda"; import {ACCOUNTS_ACTIONS, DB_VIEW_ACTIONS} from "src/web/browser-window/app/store/actions"; import {DB_VIDE_MAIL_SELECTED_CLASS_NAME} from "src/web/browser-window/app/_db-view/const"; import {DbViewAbstractComponent} from "src/web/browser-window/app/_db-view/db-view-abstract.component"; -import {Mail} from "src/shared/model/database"; +import {Folder, Mail} from "src/shared/model/database/view"; import {MailsBundleKey, State} from "src/web/browser-window/app/store/reducers/db-view"; +import {PROTONMAIL_MAILBOX_IDENTIFIERS} from "src/shared/model/database"; // TODO read "electron-mail-db-view-mail" from the DbViewMailComponent.selector property const mailComponentTagName = "electron-mail-db-view-mail"; @@ -88,12 +90,91 @@ export class DbViewMailsComponent extends DbViewAbstractComponent implements OnI this.makeAllReadInProgress$, this.onlineAndSignedIn$, ]).pipe( - map(([unreadCount, makeAllReadInProgress, onlineAndSignedIn]) => { - return unreadCount < 1 || makeAllReadInProgress || !onlineAndSignedIn; + map(([unreadCount, inProgress, onlineAndSignedIn]) => { + return unreadCount < 1 || inProgress || !onlineAndSignedIn; }), distinctUntilChanged(), ); + plainMailsBundle$ = this.mailsBundleKey$.pipe( + mergeMap((mailsBundleKey) => this.instance$.pipe( + map((instance) => { + const key = mailsBundleKey === "folderConversationsBundle" + ? "folderMailsBundle" + : mailsBundleKey; + return instance[key]; + }), + distinctUntilChanged(), + )), + ); + + plainItems$ = this.plainMailsBundle$.pipe( + map(({items}) => items), + distinctUntilChanged(), + tap(this.markDirty.bind(this)), + ); + + plainItemsCount$: Observable = this.plainItems$.pipe( + map(({length}) => length), + distinctUntilChanged(), + ); + + setFolderInProgress$: Observable = this.account$.pipe( + map((account) => { + return account + ? Boolean(account.setMailFolderParams) + : false; + }), + ); + + setFolderButtonLocked$: Observable = combineLatest([ + this.plainItemsCount$, + this.setFolderInProgress$, + this.onlineAndSignedIn$, + ]).pipe( + map(([count, inProgress, onlineAndSignedIn]) => { + return count < 1 || inProgress || !onlineAndSignedIn; + }), + distinctUntilChanged(), + ); + + moveToFolders$: Observable = (() => { + const excludePks: ReadonlySet = new Set([ + PROTONMAIL_MAILBOX_IDENTIFIERS["All Drafts"], + PROTONMAIL_MAILBOX_IDENTIFIERS["All Sent"], + PROTONMAIL_MAILBOX_IDENTIFIERS["All Mail"], + PROTONMAIL_MAILBOX_IDENTIFIERS.Search, + PROTONMAIL_MAILBOX_IDENTIFIERS.Label, + ]) + const staticFilter = (item: Folder): boolean => { + return ( + item.exclusive > 0 + && + !excludePks.has(item.pk) + ); + }; + return combineLatest([ + this.instance$.pipe( + map((value) => value.folders), + distinctUntilChanged(), + map(({custom, system}) => ([...custom, ...system])), + map((items) => items.filter(staticFilter)), + map((items) => sortBy([...items], ({name}) => name)), + ), + this.instance$.pipe( + map((value) => value.selectedFolderData), + distinctUntilChanged(), + ), + ]).pipe( + map(([items, selectedFolderData]) => { + const excludeFolderPk = this.mailsBundleKey === "searchMailsBundle" + ? null // no excluding for the full-text search result lit + : selectedFolderData?.pk; + return items.filter(({pk}) => pk !== excludeFolderPk); + }), + ); + })(); + private subscription = new Subscription(); private _uid?: string; @@ -198,7 +279,24 @@ export class DbViewMailsComponent extends DbViewAbstractComponent implements OnI .filter((item) => item.mail.unread) .map((item) => item.mail.id); this.store.dispatch( - ACCOUNTS_ACTIONS.MakeMailReadSetParams({pk, mailsBundleKey: this.mailsBundleKey, messageIds}), + ACCOUNTS_ACTIONS.MakeMailReadSetParams({pk, messageIds}), + ); + }); + } + + setFolder(folderId: Folder["id"]): void { + this.plainItems$ + .pipe( + withLatestFrom(this.dbAccountPk$), + take(1), + ) + .subscribe(([items, pk]) => { + const messageIds = items.map((item) => item.mail.id); + if (!messageIds.length) { + return; + } + this.store.dispatch( + ACCOUNTS_ACTIONS.SetMailFolderParams({pk, folderId, messageIds}), ); }); } diff --git a/src/web/browser-window/app/_db-view/db-view.effects.ts b/src/web/browser-window/app/_db-view/db-view.effects.ts index 00eb85137..88b00437c 100644 --- a/src/web/browser-window/app/_db-view/db-view.effects.ts +++ b/src/web/browser-window/app/_db-view/db-view.effects.ts @@ -1,10 +1,12 @@ import {Actions, createEffect} from "@ngrx/effects"; -import {EMPTY, forkJoin, from, merge, of} from "rxjs"; +import {EMPTY, concat, forkJoin, from, merge, of} from "rxjs"; import {Injectable, NgZone} from "@angular/core"; +import {Observable} from "rxjs/internal/Observable"; import {Store, select} from "@ngrx/store"; -import {concatMap, filter, finalize, map, mergeMap, switchMap, takeUntil, tap} from "rxjs/operators"; +import {concatMap, filter, finalize, map, mergeMap, switchMap, take, takeUntil, tap} from "rxjs/operators"; import {ACCOUNTS_ACTIONS, DB_VIEW_ACTIONS, OPTIONS_ACTIONS, unionizeActionFilter,} from "src/web/browser-window/app/store/actions"; +import {AccountConfig} from "src/shared/model/account"; import {ElectronService} from "src/web/browser-window/app/_core/electron.service"; import {FIRE_SYNCING_ITERATION$} from "src/web/browser-window/app/app.constants"; import {IPC_MAIN_API_NOTIFICATION_ACTIONS} from "src/shared/api/main"; @@ -165,24 +167,39 @@ export class DbViewEffects { () => this.actions$.pipe( unionizeActionFilter(ACCOUNTS_ACTIONS.is.MakeMailRead), map(logActionTypeAndBoundLoggerWithActionType({_logger})), - mergeMap(({payload: {account, webView, messageIds, mailsBundleKey}, logger}) => { + mergeMap(({payload: {account, webView, messageIds}, logger}) => { const {login} = account.accountConfig; const pk = {login}; return this.api.webViewClient(webView).pipe( mergeMap((webViewClient) => { return from( - webViewClient("makeRead")({...pk, messageIds, zoneName: logger.zoneName()}), + webViewClient("makeMailRead")({...pk, messageIds, zoneName: logger.zoneName()}), ).pipe( - mergeMap(() => { - FIRE_SYNCING_ITERATION$.next({login}); - return of(DB_VIEW_ACTIONS.MakeMailsReadInStore({dbAccountPk: pk, mailsBundleKey})); - }), - finalize(() => { - this.store.dispatch( - ACCOUNTS_ACTIONS.MakeMailReadSetParams({pk, messageIds: undefined, mailsBundleKey}), - ); - }), + mergeMap(() => this.fireSyncingIteration({login})), + finalize(() => this.store.dispatch(ACCOUNTS_ACTIONS.MakeMailReadSetParams({pk}))), + ); + }), + ); + }), + ), + ); + + setMailFolder$ = createEffect( + () => this.actions$.pipe( + unionizeActionFilter(ACCOUNTS_ACTIONS.is.SetMailFolder), + map(logActionTypeAndBoundLoggerWithActionType({_logger})), + mergeMap(({payload: {account, webView, folderId, messageIds}, logger}) => { + const {login} = account.accountConfig; + const pk = {login}; + + return this.api.webViewClient(webView).pipe( + mergeMap((webViewClient) => { + return from( + webViewClient("setMailFolder")({...pk, folderId, messageIds, zoneName: logger.zoneName()}), + ).pipe( + mergeMap(() => this.fireSyncingIteration({login})), + finalize(() => this.store.dispatch(ACCOUNTS_ACTIONS.SetMailFolderParams({pk}))), ); }), ); @@ -220,4 +237,25 @@ export class DbViewEffects { payload: any; // eslint-disable-line @typescript-eslint/no-explicit-any }>, ) {} + + private fireSyncingIteration({login}: Pick): Observable { + setTimeout(() => FIRE_SYNCING_ITERATION$.next({login})); + + return concat( + // first should start new syncing iteration + this.actions$.pipe( + unionizeActionFilter(ACCOUNTS_ACTIONS.is.PatchProgress), + filter(({payload}) => payload.login === login && Boolean(payload.patch.syncing)), + take(1), + ), + // then should successfully complete the syncing iteration + this.actions$.pipe( + unionizeActionFilter(ACCOUNTS_ACTIONS.is.Synced), + filter(({payload}) => payload.pk.login === login), + take(1), + ), + ).pipe( + mergeMap(() => EMPTY), + ); + } } diff --git a/src/web/browser-window/app/model.ts b/src/web/browser-window/app/model.ts index a7d95ff79..387d5fa2d 100644 --- a/src/web/browser-window/app/model.ts +++ b/src/web/browser-window/app/model.ts @@ -1,6 +1,5 @@ import {AccountConfig, Notifications} from "src/shared/model/account"; -import {Mail} from "src/shared/model/database"; -import {MailsBundleKey} from "src/web/browser-window/app/store/reducers/db-view"; +import {Folder, Mail} from "src/shared/model/database"; export interface WebAccount { accountConfig: AccountConfig; @@ -19,7 +18,8 @@ export interface WebAccount { loginDelayedUntilSelected?: boolean; // TODO consider combining "fetchSingleMailParams" and "makeReadMailParams" to the object with "optional" props fetchSingleMailParams: { mailPk: Mail["pk"] } | null; - makeReadMailParams: { messageIds: string[]; mailsBundleKey: MailsBundleKey } | null; + makeReadMailParams: { messageIds: Array } | null; + setMailFolderParams: { folderId: Folder["id"]; messageIds: Array } | null; } export type WebAccountProgress = WebAccount["progress"]; diff --git a/src/web/browser-window/app/store/actions/accounts.ts b/src/web/browser-window/app/store/actions/accounts.ts index 9f1197fd3..cd9f43993 100644 --- a/src/web/browser-window/app/store/actions/accounts.ts +++ b/src/web/browser-window/app/store/actions/accounts.ts @@ -2,7 +2,6 @@ import {ofType, unionize} from "@vladimiry/unionize"; import {AccountConfig} from "src/shared/model/account"; import {DbAccountPk, Mail} from "src/shared/model/database"; -import {MailsBundleKey} from "src/web/browser-window/app/store/reducers/db-view"; import {State} from "src/web/browser-window/app/store/reducers/accounts"; import {WebAccount, WebAccountProgress} from "src/web/browser-window/app/model"; @@ -24,6 +23,7 @@ export const ACCOUNTS_ACTIONS = unionize({ }>(), ToggleDatabaseView: ofType<{ login: string; forced?: Pick }>(), ToggleSyncing: ofType<{ pk: DbAccountPk; webView: Electron.WebviewTag; finishPromise: Promise }>(), + Synced: ofType<{ pk: DbAccountPk }>(), SetupNotificationChannel: ofType<{ account: WebAccount; webView: Electron.WebviewTag; finishPromise: Promise }>(), TryToLogin: ofType<{ account: WebAccount; webView: Electron.WebviewTag }>(), WireUpConfigs: ofType<{ accountConfigs: AccountConfig[] }>(), @@ -33,10 +33,14 @@ export const ACCOUNTS_ACTIONS = unionize({ & Partial, "mailPk">>>(), FetchSingleMail: ofType<{ account: WebAccount; webView: Electron.WebviewTag } & Pick, "mailPk">>(), - MakeMailReadSetParams: ofType<{ pk: DbAccountPk; mailsBundleKey: MailsBundleKey } - & Partial, "messageIds">>>(), - MakeMailRead: ofType<{ account: WebAccount; webView: Electron.WebviewTag; mailsBundleKey: MailsBundleKey } - & Pick, "messageIds">>(), + MakeMailReadSetParams: ofType<{ pk: DbAccountPk } + & (Exclude | {})>(), + MakeMailRead: ofType<{ account: WebAccount; webView: Electron.WebviewTag } + & Exclude>(), + SetMailFolderParams: ofType<{ pk: DbAccountPk } + & (Exclude | {})>(), + SetMailFolder: ofType<{ account: WebAccount; webView: Electron.WebviewTag } + & Exclude>(), }, { tag: "type", diff --git a/src/web/browser-window/app/store/actions/db-view.ts b/src/web/browser-window/app/store/actions/db-view.ts index 847715e1d..10e79d203 100644 --- a/src/web/browser-window/app/store/actions/db-view.ts +++ b/src/web/browser-window/app/store/actions/db-view.ts @@ -60,10 +60,6 @@ export const DB_VIEW_ACTIONS = unionize({ ResetSearchMailsBundleItems: ofType<{ dbAccountPk: DbAccountPk; }>(), - MakeMailsReadInStore: ofType<{ - dbAccountPk: DbAccountPk; - mailsBundleKey: MailsBundleKey; - }>(), }, { tag: "type", diff --git a/src/web/browser-window/app/store/reducers/accounts.ts b/src/web/browser-window/app/store/reducers/accounts.ts index 087f4bfe5..e48e17d98 100644 --- a/src/web/browser-window/app/store/reducers/accounts.ts +++ b/src/web/browser-window/app/store/reducers/accounts.ts @@ -75,13 +75,14 @@ export function reducer(state = initialState, action: UnionOf { + MakeMailReadSetParams: ({pk, ...rest}) => { + const key = "makeReadMailParams"; const {account} = pickAccountBundle(draftState.accounts, {login: pk.login}); - account.makeReadMailParams = messageIds - ? {messageIds, mailsBundleKey} - : null; + if ("messageIds" in rest) { + const {messageIds} = rest; + account[key] = {messageIds}; + return; + } + + account[key] = null; + }, + SetMailFolderParams: ({pk, ...rest}) => { + const key = "setMailFolderParams"; + const {account} = pickAccountBundle(draftState.accounts, {login: pk.login}); + + if ("messageIds" in rest) { + const {folderId, messageIds} = rest; + account[key] = {folderId, messageIds}; + return; + } + + account[key] = null; }, default: () => draftState, })); diff --git a/src/web/browser-window/app/store/reducers/db-view.ts b/src/web/browser-window/app/store/reducers/db-view.ts index fba7c40e9..8dfd1a5d8 100644 --- a/src/web/browser-window/app/store/reducers/db-view.ts +++ b/src/web/browser-window/app/store/reducers/db-view.ts @@ -410,37 +410,6 @@ function innerReducer(state = initialState, action: UnionOf { - const instanceKey = resolveInstanceKey(dbAccountPk); - const instance: Instance = { - ...(state.instances[instanceKey] || initInstance()), - }; - - return { - ...state, - instances: { - ...state.instances, - [instanceKey]: { - ...instance, - [mailsBundleKey]: { - ...instance[mailsBundleKey], - items: instance[mailsBundleKey].items.map((item) => { - if (!item.mail.unread) { - return item; - } - return { - ...item, - mail: { - ...item.mail, - unread: false, - }, - }; - }), - }, - }, - }, - }; - }, UnmountInstance: ({dbAccountPk}) => { const instanceKey = resolveInstanceKey(dbAccountPk); const instances = {...state.instances};