From 625255f49feec5925b318244005730e87a6dbfcd Mon Sep 17 00:00:00 2001 From: kei Date: Thu, 11 Jun 2026 04:25:41 +0200 Subject: [PATCH] feat(compose): add empty drafts action --- src/app/compose/compose.module.ts | 2 + src/app/compose/draftdesk.component.html | 8 ++++ src/app/compose/draftdesk.component.scss | 9 ++-- src/app/compose/draftdesk.component.ts | 44 +++++++++++++++++++- src/app/compose/draftdesk.service.spec.ts | 50 ++++++++++++++++++++++- src/app/compose/draftdesk.service.ts | 26 ++++++++++-- 6 files changed, 130 insertions(+), 9 deletions(-) diff --git a/src/app/compose/compose.module.ts b/src/app/compose/compose.module.ts index 4ec5d5595..c7f0ab155 100644 --- a/src/app/compose/compose.module.ts +++ b/src/app/compose/compose.module.ts @@ -37,6 +37,7 @@ import { DraftDeskComponent } from './draftdesk.component'; import { ComposeComponent } from './compose.component'; export { ComposeComponent } from './compose.component'; import { MenuModule } from '../menu/menu.module'; +import { DialogModule } from '../dialog/dialog.module'; import { MailRecipientInputComponent} from './mailrecipientinput.component'; export { MailRecipientInputComponent} from './mailrecipientinput.component'; @@ -56,6 +57,7 @@ export { MailRecipientInputComponent} from './mailrecipientinput.component'; FormsModule, ReactiveFormsModule, MenuModule, + DialogModule, MatTooltipModule ], declarations: [DraftDeskComponent, ComposeComponent, MailRecipientInputComponent], diff --git a/src/app/compose/draftdesk.component.html b/src/app/compose/draftdesk.component.html index d33548f20..70c2f4ba0 100644 --- a/src/app/compose/draftdesk.component.html +++ b/src/app/compose/draftdesk.component.html @@ -1,6 +1,14 @@

Draft Desk

+
diff --git a/src/app/compose/draftdesk.component.scss b/src/app/compose/draftdesk.component.scss index edf4397c8..db86a5856 100644 --- a/src/app/compose/draftdesk.component.scss +++ b/src/app/compose/draftdesk.component.scss @@ -2,9 +2,12 @@ padding: 15px; .appPageHeader { - position: absolute; - top: 8px; - left: 70px; + position: absolute; + top: 8px; + left: 70px; + display: flex; + align-items: center; + gap: 8px; } } diff --git a/src/app/compose/draftdesk.component.ts b/src/app/compose/draftdesk.component.ts index 442b35dfe..3458740cd 100644 --- a/src/app/compose/draftdesk.component.ts +++ b/src/app/compose/draftdesk.component.ts @@ -19,9 +19,11 @@ import { Component, OnInit } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; +import { MatLegacyDialog as MatDialog } from '@angular/material/legacy-dialog'; import { RunboxWebmailAPI } from '../rmmapi/rbwebmail'; import { DraftDeskService, DraftFormModel } from './draftdesk.service'; import { RecipientsService } from './recipients.service'; +import { ConfirmDialog } from '../dialog/dialog.module'; const MAX_DRAFTS_IN_VIEW = 10; @@ -34,12 +36,14 @@ export class DraftDeskComponent implements OnInit { public draftModelsInView: DraftFormModel[] = []; public hasMoreDrafts = false; public currentMaxDraftsInView: number = MAX_DRAFTS_IN_VIEW; + public isEmptyingDrafts = false; private hasInitialized = false; constructor( public rmmapi: RunboxWebmailAPI, public router: Router, private route: ActivatedRoute, + private dialog: MatDialog, public draftDeskservice: DraftDeskService) { this.draftDeskservice.draftModels.subscribe( @@ -109,6 +113,7 @@ export class DraftDeskComponent implements OnInit { } else { this.draftModelsInView = this.draftDeskservice.draftModels.value.slice(0, this.currentMaxDraftsInView); } + this.hasMoreDrafts = this.currentMaxDraftsInView < this.draftDeskservice.draftModels.value.length; } draftDeleted(messageId) { @@ -124,9 +129,46 @@ export class DraftDeskComponent implements OnInit { this.updateDraftsInView(); } + get savedDraftCount(): number { + return this.draftDeskservice.draftModels.value.filter((draft) => !draft.isUnsaved()).length; + } + + emptyDrafts() { + const savedDraftIds = this.draftDeskservice.draftModels.value + .filter((draft) => !draft.isUnsaved()) + .map((draft) => draft.mid); + + if (savedDraftIds.length === 0 || this.isEmptyingDrafts) { + return; + } + + const confirmDialog = this.dialog.open(ConfirmDialog); + confirmDialog.componentInstance.title = 'Empty Drafts?'; + confirmDialog.componentInstance.question = + `Delete ${savedDraftIds.length} saved draft${savedDraftIds.length === 1 ? '' : 's'}?`; + confirmDialog.componentInstance.noOptionTitle = 'cancel'; + confirmDialog.componentInstance.yesOptionTitle = 'delete'; + confirmDialog.afterClosed().subscribe((confirmed) => { + if (confirmed !== true) { + return; + } + + this.isEmptyingDrafts = true; + this.draftDeskservice.deleteDrafts(savedDraftIds).subscribe({ + next: () => { + this.isEmptyingDrafts = false; + this.updateDraftsInView(); + }, + error: (error) => { + this.isEmptyingDrafts = false; + console.error('Could not empty drafts', error); + } + }); + }); + } + showMore() { this.currentMaxDraftsInView += MAX_DRAFTS_IN_VIEW; this.updateDraftsInView(); - this.hasMoreDrafts = this.currentMaxDraftsInView < this.draftDeskservice.draftModels.value.length; } } diff --git a/src/app/compose/draftdesk.service.spec.ts b/src/app/compose/draftdesk.service.spec.ts index 0b4f6bbff..e019e5d60 100644 --- a/src/app/compose/draftdesk.service.spec.ts +++ b/src/app/compose/draftdesk.service.spec.ts @@ -448,7 +448,8 @@ describe('DraftDeskService', () => { headers: { subject: 'Test Subject' }, text: { text: 'Test body', html: '

Test body

' } })), - copyAttachmentToDraft: jasmine.createSpy('copyAttachmentToDraft').and.returnValue(of({ filename: 'attachment.txt' })) + copyAttachmentToDraft: jasmine.createSpy('copyAttachmentToDraft').and.returnValue(of({ filename: 'attachment.txt' })), + deleteMessages: jasmine.createSpy('deleteMessages').and.returnValue(of({ status: 'success' })) }; draftDeskService = new DraftDeskService( @@ -522,6 +523,53 @@ describe('DraftDeskService', () => { }); }); + describe('deleteDrafts', () => { + it('should batch-delete saved drafts and remove them locally', (done) => { + const firstDraft = DraftFormModel.create( + 12345, + mockProfileService.composeProfile, + 'to@runbox.com', + 'First Draft' + ); + const secondDraft = DraftFormModel.create( + 23456, + mockProfileService.composeProfile, + 'to@runbox.com', + 'Second Draft' + ); + const unsavedDraft = DraftFormModel.create( + -1, + mockProfileService.composeProfile, + 'to@runbox.com', + 'Unsaved Draft' + ); + draftDeskService.draftModels.next([firstDraft, secondDraft, unsavedDraft]); + + draftDeskService.deleteDrafts([firstDraft.mid, secondDraft.mid, unsavedDraft.mid]).subscribe(() => { + expect(mockRmmapi.deleteMessages).toHaveBeenCalledOnceWith([firstDraft.mid, secondDraft.mid]); + expect(draftDeskService.draftModels.value).toEqual([unsavedDraft]); + done(); + }); + }); + + it('should not call the backend when there are no saved drafts', (done) => { + const unsavedDraft = DraftFormModel.create( + -1, + mockProfileService.composeProfile, + 'to@runbox.com', + 'Unsaved Draft' + ); + draftDeskService.draftModels.next([unsavedDraft]); + mockRmmapi.deleteMessages.calls.reset(); + + draftDeskService.deleteDrafts([unsavedDraft.mid]).subscribe(() => { + expect(mockRmmapi.deleteMessages).not.toHaveBeenCalled(); + expect(draftDeskService.draftModels.value).toEqual([unsavedDraft]); + done(); + }); + }); + }); + describe('newBugReport with firstValueFrom', () => { it('should create bug report draft with template from HTTP', async () => { mockHttp.get.and.returnValue(of('Username: %%USERNAME%%\nUser Agent: %%USERAGENT%%')); diff --git a/src/app/compose/draftdesk.service.ts b/src/app/compose/draftdesk.service.ts index 6f1efa0be..cb8303346 100644 --- a/src/app/compose/draftdesk.service.ts +++ b/src/app/compose/draftdesk.service.ts @@ -26,7 +26,7 @@ import { MailAddressInfo } from '../common/mailaddressinfo'; import { MessageListService } from '../rmmapi/messagelist.service'; import { MessageTableRowTool} from '../messagetable/messagetablerow'; import { Identity, ProfileService } from '../profiles/profile.service'; -import { from, of, BehaviorSubject, firstValueFrom } from 'rxjs'; +import { from, of, BehaviorSubject, firstValueFrom, Observable } from 'rxjs'; import { map, mergeMap, bufferCount, take, distinctUntilChanged } from 'rxjs/operators'; import moment from 'moment'; @@ -307,9 +307,27 @@ export class DraftDeskService { } public deleteDraft(messageId: number) { - let models = this.draftModels.value; - models = models.filter(dm => dm.mid !== messageId); - this.draftModels.next(models); + this.removeDrafts([messageId]); + } + + public deleteDrafts(messageIds: number[]): Observable { + const savedDraftIds = messageIds.filter((messageId) => messageId > 0); + + if (savedDraftIds.length === 0) { + return of({ status: 'success' }); + } + + return this.rmmapi.deleteMessages(savedDraftIds).pipe( + map((reply) => { + this.removeDrafts(savedDraftIds); + return reply; + }) + ); + } + + private removeDrafts(messageIds: number[]) { + const messageIdSet = new Set(messageIds); + this.draftModels.next(this.draftModels.value.filter((dm) => !messageIdSet.has(dm.mid))); } public async newTemplateDraft(