Skip to content

Commit

Permalink
enable "custom actions" account context menu ("unload" action), #456
Browse files Browse the repository at this point in the history
* Done in a hacky way for now: via the "WebAccount.disabled" prop toggling.
  • Loading branch information
vladimiry committed Nov 10, 2021
1 parent 556d174 commit 27bea7f
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 41 deletions.
4 changes: 4 additions & 0 deletions src/electron-main/api/endpoints-builders/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export async function buildEndpoints(
async addAccount(
{
login,
contextMenu,
customCSS,
title,
entryUrl,
Expand All @@ -55,6 +56,7 @@ export async function buildEndpoints(

const account: AccountConfig = {
login,
contextMenu,
customCSS,
title,
entryUrl,
Expand Down Expand Up @@ -85,6 +87,7 @@ export async function buildEndpoints(
async updateAccount(
{
login,
contextMenu,
customCSS,
title,
entryUrl,
Expand Down Expand Up @@ -125,6 +128,7 @@ export async function buildEndpoints(
);
logger.verbose(JSON.stringify({shouldConfigureSession}));

account.contextMenu = contextMenu;
account.customCSS = customCSS;
account.title = title;
account.database = database;
Expand Down
1 change: 1 addition & 0 deletions src/shared/model/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type AccountConfig = NoExtraProps<{
rotateUserAgent?: boolean;
disabled?: boolean;
customCSS?: string;
contextMenu?: boolean;
}>;

export type AccountPersistentSession = NoExtraProps<{
Expand Down
1 change: 1 addition & 0 deletions src/shared/model/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface NewPasswordFieldContainer {
export interface PasswordChangeContainer extends PasswordFieldContainer, NewPasswordFieldContainer {}

export type AccountConfigCreateUpdatePatch = NoExtraProps<Pick<AccountConfig,
| "contextMenu"
| "customCSS"
| "credentials"
| "database"
Expand Down
10 changes: 10 additions & 0 deletions src/web/browser-window/app/_accounts/account-title.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
<ng-container *ngIf="(state$ | async)?.contextMenuOpen">
<ul class="dropdown-menu px-0 py-1" role="menu" style="display: block">
<li role="menuitem">
<button (click)="unloadContextMenuAction($event)" class="dropdown-item d-flex" title="Unload" type="button">
<i class="fa fa-window-close"></i>
<span class="ml-1">Unload</span>
</button>
</li>
</ul>
</ng-container>
<div
*ngIf="state$ | async; let state"
[ngClass]="{'selected': state.selected, 'stored': state.stored, 'login-filled-once': state.account.loginFilledOnce}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
:host {
display: flex;
padding: 0;
position: relative;

.dropdown-menu {
font-size: $app-font-size-base-small;
min-width: 0;
}

.btn-group {
display: flex;
Expand Down
82 changes: 64 additions & 18 deletions src/web/browser-window/app/_accounts/account-title.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import UUID from "pure-uuid";
import {BehaviorSubject, Subscription} from "rxjs";
import {ChangeDetectionStrategy, Component, Input} from "@angular/core";
import {ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener, Input, Output} from "@angular/core";
import type {OnDestroy, OnInit} from "@angular/core";
import {Store, select} from "@ngrx/store";
import type {Unsubscribable} from "rxjs";
import {filter, first, map} from "rxjs/operators";

import {ACCOUNTS_ACTIONS} from "src/web/browser-window/app/store/actions";
Expand All @@ -10,12 +12,21 @@ import {State} from "src/web/browser-window/app/store/reducers/accounts";
import {WebAccount} from "src/web/browser-window/app/model";

interface ComponentState {
account: WebAccount;
selected: boolean;
stored: boolean;
title: string;
account: WebAccount
selected: boolean
stored: boolean
title: string
contextMenuOpen: boolean
}

const initialComponentState: DeepReadonly<StrictOmit<ComponentState, "account">> = {
// account: null,
selected: false,
stored: false,
title: "",
contextMenuOpen: false,
};

@Component({
selector: "electron-mail-account-title",
templateUrl: "./account-title.component.html",
Expand All @@ -26,12 +37,12 @@ export class AccountTitleComponent implements OnInit, OnDestroy {
@Input()
highlighting = true;

private stateSubject$ = new BehaviorSubject<ComponentState>({
// account: null,
selected: false,
stored: false,
title: "",
} as ComponentState);
@Output()
private readonly accountUnloadRollback
= new EventEmitter<Parameters<typeof
import("./accounts.component")["AccountsComponent"]["prototype"]["accountUnloadRollback"]>[0]>();

private readonly stateSubject$ = new BehaviorSubject<ComponentState>({...initialComponentState} as ComponentState);

// TODO consider replacing observable with just an object explicitly triggering ChangeDetectorRef.detectChanges() after its mutation
// tslint:disable-next-line:member-ordering
Expand Down Expand Up @@ -71,30 +82,65 @@ export class AccountTitleComponent implements OnInit, OnDestroy {
}

constructor(
private store: Store<State>,
private readonly store: Store<State>,
private readonly elementRef: ElementRef,
) {}

ngOnInit(): void {
if (this.highlighting) {
this.subscription.add(
this.store
.pipe(select(AccountsSelectors.FEATURED.selectedLogin))
.subscribe((selectedLogin) => {
this.patchState({selected: this.accountLogin === selectedLogin});
}),
.subscribe((selectedLogin) => this.patchState({selected: this.accountLogin === selectedLogin})),
);
}
this.subscription.add(
((): Unsubscribable => {
const target = document.body;
const args = ["click", ({target: eventTarget}: MouseEvent) => {
const traversing: { el: Node | null, depthLimit: number } = {el: eventTarget as Node, depthLimit: 10};
const {nativeElement} = this.elementRef; // eslint-disable-line @typescript-eslint/no-unsafe-assignment
while (traversing.el && traversing.depthLimit) {
if (traversing.el === nativeElement) return;
traversing.el = traversing.el.parentNode;
traversing.depthLimit--;
}
this.patchState({contextMenuOpen: false});
}] as const;
target.addEventListener(...args);
return {unsubscribe: () => target.removeEventListener(...args)};
})(),
);
this.subscription.add(
this.state$
.pipe(first())
.subscribe(({account: {accountConfig: {localStoreViewByDefault}}}) => {
if (localStoreViewByDefault) {
this.store.dispatch(ACCOUNTS_ACTIONS.ToggleDatabaseView({login: this.accountLogin, forced: {databaseView: true}}));
.subscribe(({account: {accountConfig: {database, localStoreViewByDefault}}}) => {
if (database && localStoreViewByDefault) {
this.store.dispatch(ACCOUNTS_ACTIONS.ToggleDatabaseView(
{login: this.accountLogin, forced: {databaseView: true}},
));
}
}),
);
}

@HostListener("contextmenu", ["$event"])
onContextMenu(event: MouseEvent): void {
if (!this.stateSubject$.value.account.accountConfig.contextMenu) {
return;
}
event.preventDefault();
this.patchState({contextMenuOpen: true});
}

unloadContextMenuAction(event: MouseEvent): void {
event.preventDefault();
const uuid = new UUID(4).format();
this.accountUnloadRollback.emit({accountUnloadRollbackUuid: uuid});
this.store.dispatch(ACCOUNTS_ACTIONS.Unload({login: this.stateSubject$.value.account.accountConfig.login, uuid}));
this.patchState({contextMenuOpen: false});
}

toggleViewMode(event: Event): void {
event.stopPropagation();
this.store.dispatch(ACCOUNTS_ACTIONS.ToggleDatabaseView({login: this.accountLogin}));
Expand Down
3 changes: 3 additions & 0 deletions src/web/browser-window/app/_accounts/accounts.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
></electron-mail-unread-badge>
</div>
<electron-mail-account-title
(accountUnloadRollback)="accountUnloadRollback($event)"
(click)="cancelEvent($event)"
*ngIf="selectedAccount"
[account]="selectedAccount"
Expand All @@ -58,6 +59,7 @@
<ul *dropdownMenu class="dropdown-menu dropdown-menu-right" role="menu">
<li *ngFor="let login of logins" role="menuitem">
<electron-mail-account-title
(accountUnloadRollback)="accountUnloadRollback($event)"
(click)="activateAccountByLogin($event, login)"
[account]="getAccountByLogin(login)"
></electron-mail-account-title>
Expand All @@ -66,6 +68,7 @@
</div>
<ul class="list-group accounts-list d-none d-lg-flex">
<electron-mail-account-title
(accountUnloadRollback)="accountUnloadRollback($event)"
(click)="activateAccountByLogin($event, login)"
*ngFor="let login of logins"
[account]="getAccountByLogin(login)"
Expand Down
13 changes: 13 additions & 0 deletions src/web/browser-window/app/_accounts/accounts.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@
}
}

.layout-mode-left,
.layout-mode-left-thin {
&::ng-deep #{$app-prefix}-account-title {
.dropdown-item > span {
display: none;
}
}
}

@media (min-width: #{map-get($grid-breakpoints, lg)}) {
[class*="layout-mode-left"] {
flex-flow: row;
Expand Down Expand Up @@ -209,6 +218,10 @@
#{$app-prefix}-account-title {
$margin-v: ($app-spacer-1 / 1.5);

.dropdown-item {
padding: $app-spacer-2;
}

#{$app-prefix}-unread-badge {
margin-bottom: $margin-v;
padding: 0.3rem 0.1rem;
Expand Down
52 changes: 47 additions & 5 deletions src/web/browser-window/app/_accounts/accounts.component.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import {Component} from "@angular/core";
import type {OnDestroy, OnInit} from "@angular/core";
import {Observable, Subscription, combineLatest, race, throwError, timer} from "rxjs";
import {Store, select} from "@ngrx/store";
import {Subscription, combineLatest} from "rxjs";
import {distinctUntilChanged, map} from "rxjs/operators";
import {equals} from "remeda";
import {concatMap, distinctUntilChanged, first, map, pairwise, startWith, tap} from "rxjs/operators";
import {equals, noop} from "remeda";

import {ACCOUNTS_ACTIONS, NAVIGATION_ACTIONS, NOTIFICATION_ACTIONS} from "src/web/browser-window/app/store/actions";
import {AccountConfig} from "src/shared/model/account";
import {AccountsSelectors, OptionsSelectors} from "src/web/browser-window/app/store/selectors";
import {Component} from "@angular/core";
import {CoreService} from "src/web/browser-window/app/_core/core.service";
import {ElectronService} from "src/web/browser-window/app/_core/electron.service";
import {ONE_SECOND_MS} from "src/shared/constants";
import type {OnDestroy, OnInit} from "@angular/core";
import {SETTINGS_OUTLET, SETTINGS_PATH} from "src/web/browser-window/app/app.constants";
import {State} from "src/web/browser-window/app/store/reducers/accounts";
import {WebAccount} from "src/web/browser-window/app/model";
Expand Down Expand Up @@ -138,6 +139,47 @@ export class AccountsComponent implements OnInit, OnDestroy {
event.stopPropagation();
}

accountUnloadRollback({accountUnloadRollbackUuid: uuid}: { accountUnloadRollbackUuid: string }): void {
{ // reverting the accounts list back after the "minus one" list got rendered
const targetNode = document;
const renderedAccountsTimeoutMs = ONE_SECOND_MS;
const resolveRenderedAccountsCount =
// TODO read tag names from the "Component.selector" property
(): number => targetNode.querySelectorAll("electron-mail-accounts electron-mail-account").length;
race(
new Observable<ReturnType<typeof resolveRenderedAccountsCount>>(
(subscribe) => {
const mutation = new MutationObserver(() => {
subscribe.next(resolveRenderedAccountsCount());
});
mutation.observe(
targetNode,
{childList: true, subtree: true},
);
return (): void => mutation.disconnect();
},
).pipe(
startWith(resolveRenderedAccountsCount()),
distinctUntilChanged(),
pairwise(),
first(),
tap(([prev, curr]) => {
const countDiff = prev - curr;
if (countDiff !== 1 /* "minus one" list got rendered */) {
throw new Error(`Only single account disabling expected to happen (actual count diff: ${countDiff})`);
}
this.store.dispatch(ACCOUNTS_ACTIONS.UnloadRollback({uuid}));
}),
),
timer(renderedAccountsTimeoutMs).pipe(
concatMap(() => throwError(() => new Error(
`Failed to received the accounts count change in ${renderedAccountsTimeoutMs}ms`,
))),
),
).subscribe(noop);
}
}

ngOnDestroy(): void {
this.subscription.unsubscribe();
}
Expand Down
41 changes: 39 additions & 2 deletions src/web/browser-window/app/_accounts/accounts.effects.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {Action} from "@ngrx/store";
import {Actions, createEffect, ofType} from "@ngrx/effects";
import {EMPTY, Observable, concat, from, fromEvent, merge, of, throwError, timer} from "rxjs";
import {Injectable} from "@angular/core";
import {EMPTY, Observable, concat, from, fromEvent, merge, of, race, throwError, timer} from "rxjs";
import {Store, select} from "@ngrx/store";
import {
catchError,
Expand All @@ -11,6 +10,7 @@ import {
delay,
filter,
finalize,
first,
map,
mergeMap,
switchMap,
Expand All @@ -19,6 +19,7 @@ import {
tap,
withLatestFrom,
} from "rxjs/operators";
import {produce} from "immer";
import {serializeError} from "serialize-error";

import {
Expand All @@ -34,6 +35,7 @@ import {CoreService} from "src/web/browser-window/app/_core/core.service";
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-process/actions";
import {Injectable} from "@angular/core";
import {ONE_MINUTE_MS, ONE_SECOND_MS, PRODUCT_NAME} from "src/shared/constants";
import {State} from "src/web/browser-window/app/store/reducers/accounts";
import {consumeMemoryRateLimiter, curryFunctionMembers, isDatabaseBootstrapped, testProtonCalendarAppPage} from "src/shared/util";
Expand Down Expand Up @@ -736,6 +738,41 @@ export class AccountsEffects {
),
);

unload$ = createEffect(
() => this.actions$.pipe(
ofType(ACCOUNTS_ACTIONS.Unload),
withLatestFrom(this.store.pipe(select(AccountsSelectors.FEATURED.accounts))),
concatMap(([{payload}, accounts]) => {
const accountConfigs = accounts.map(({accountConfig}) => accountConfig);
const renderedAccountsTimeoutMs = ONE_SECOND_MS;
return concat(
of(
ACCOUNTS_ACTIONS.WireUpConfigs({
accountConfigs: produce(accountConfigs, (draftState) => {
const accountToUnload = draftState.find(({login}) => login === payload.login);
if (!accountToUnload) {
throw new Error("Failed to resolve account by login.");
}
accountToUnload.disabled = true;
}),
}),
),
race(
this.actions$.pipe(
ofType(ACCOUNTS_ACTIONS.UnloadRollback),
filter(({payload: {uuid}}) => uuid === payload.uuid),
first(),
concatMap(() => of(ACCOUNTS_ACTIONS.WireUpConfigs({accountConfigs}))),
),
timer(renderedAccountsTimeoutMs).pipe(
concatMap(() => throwError(() => new Error(`Failed to received response in ${renderedAccountsTimeoutMs}ms`))),
),
),
);
}),
),
);

constructor(
private readonly actions$: Actions,
private readonly api: ElectronService,
Expand Down

0 comments on commit 27bea7f

Please sign in to comment.