From 9f09aae467880b45cefe8f78b6baec3c98133105 Mon Sep 17 00:00:00 2001 From: Markus Huggler Date: Thu, 5 Jan 2023 18:05:43 +0100 Subject: [PATCH] feat: adds authentication to ngrx store --- angular.json | 32 +++- package.json | 150 ++++++++------- .../mastodon-api-authentication.service.ts | 27 +-- .../mastolists/src/app/app-routing.module.ts | 4 +- .../mastolists/src/app/app.component.html | 3 +- .../mastolists/src/app/app.component.scss | 4 + projects/mastolists/src/app/app.component.ts | 29 ++- projects/mastolists/src/app/app.module.ts | 42 +++- .../app/authorization/authorization.module.ts | 11 +- .../authorize/authorize.component.html | 26 ++- .../authorize/authorize.component.scss | 8 +- .../authorize/authorize.component.ts | 175 ++++++++--------- .../app/authorization/guards/auth.guard.ts | 27 +++ .../src/app/followings/list/list.component.ts | 12 +- .../app/followings/matrix/matrix.component.ts | 12 +- .../app/followings/table/table.component.ts | 6 +- .../src/app/home/home/home.component.html | 2 +- .../src/app/lists/lists/lists.component.html | 2 +- .../src/app/lists/lists/lists.component.ts | 15 +- .../components/filters/filters.component.ts | 29 ++- .../src/app/shared/guards/auth.guard.ts | 34 ---- .../app/shared/services/account.service.ts | 36 ++-- .../src/app/shared/services/list.service.ts | 78 +++----- .../persistent/local-storage-ref.service.ts | 12 -- .../persistent/persistent-store.service.ts | 75 -------- .../src/app/shared/state/persistent/state.ts | 19 -- .../src/app/shared/state/store/actions.ts | 46 ----- .../store/actions/application.actions.ts | 10 + .../state/store/actions/authorize.actions.ts | 16 ++ .../state/store/actions/filters.actions.ts | 12 ++ .../app/shared/state/store/actions/index.ts | 9 + .../state/store/actions/list-view.actions.ts | 10 + .../state/store/actions/lists.actions.ts | 9 + .../store/actions/mastodon-api.actions.ts | 27 +++ .../store/actions/matrix-view.actions.ts | 9 + .../state/store/actions/sync.actions.ts | 10 + .../state/store/actions/table-view.actions.ts | 9 + .../shared/state/store/application-state.ts | 34 ++++ .../state/store/authentication-state.ts | 21 ++ .../src/app/shared/state/store/effects.ts | 162 ---------------- .../effects/application-state.effects.ts | 180 ++++++++++++++++++ .../effects/authorization-state.effects.ts | 86 +++++++++ .../app/shared/state/store/effects/index.ts | 2 + .../src/app/shared/state/store/index.ts | 3 + .../src/app/shared/state/store/reducers.ts | 108 ----------- .../reducers/application-state.reducer.ts | 95 +++++++++ .../reducers/authentication-state.reducer.ts | 59 ++++++ .../app/shared/state/store/reducers/index.ts | 2 + .../application-state.selectors.ts} | 11 +- .../authentication-state.selectors.ts | 56 ++++++ .../app/shared/state/store/selectors/index.ts | 2 + .../src/app/shared/state/store/store.ts | 0 .../src/app/sync/sync/sync.component.html | 2 +- .../src/app/sync/sync/sync.component.ts | 16 +- .../src/environments/environment.prod.ts | 6 + .../src/environments/environment.staging.ts | 6 + .../src/environments/environment.ts | 6 + projects/mastolists/tsconfig.app.json | 24 +-- tsconfig.json | 82 ++++---- 59 files changed, 1152 insertions(+), 848 deletions(-) create mode 100644 projects/mastolists/src/app/authorization/guards/auth.guard.ts delete mode 100644 projects/mastolists/src/app/shared/guards/auth.guard.ts delete mode 100644 projects/mastolists/src/app/shared/state/persistent/local-storage-ref.service.ts delete mode 100644 projects/mastolists/src/app/shared/state/persistent/persistent-store.service.ts delete mode 100644 projects/mastolists/src/app/shared/state/persistent/state.ts delete mode 100644 projects/mastolists/src/app/shared/state/store/actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/application.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/authorize.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/filters.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/index.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/list-view.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/lists.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/mastodon-api.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/matrix-view.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/sync.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/actions/table-view.actions.ts create mode 100644 projects/mastolists/src/app/shared/state/store/application-state.ts create mode 100644 projects/mastolists/src/app/shared/state/store/authentication-state.ts delete mode 100644 projects/mastolists/src/app/shared/state/store/effects.ts create mode 100644 projects/mastolists/src/app/shared/state/store/effects/application-state.effects.ts create mode 100644 projects/mastolists/src/app/shared/state/store/effects/authorization-state.effects.ts create mode 100644 projects/mastolists/src/app/shared/state/store/effects/index.ts delete mode 100644 projects/mastolists/src/app/shared/state/store/reducers.ts create mode 100644 projects/mastolists/src/app/shared/state/store/reducers/application-state.reducer.ts create mode 100644 projects/mastolists/src/app/shared/state/store/reducers/authentication-state.reducer.ts create mode 100644 projects/mastolists/src/app/shared/state/store/reducers/index.ts rename projects/mastolists/src/app/shared/state/store/{selectors.ts => selectors/application-state.selectors.ts} (92%) create mode 100644 projects/mastolists/src/app/shared/state/store/selectors/authentication-state.selectors.ts create mode 100644 projects/mastolists/src/app/shared/state/store/selectors/index.ts delete mode 100644 projects/mastolists/src/app/shared/state/store/store.ts create mode 100644 projects/mastolists/src/environments/environment.prod.ts create mode 100644 projects/mastolists/src/environments/environment.staging.ts create mode 100644 projects/mastolists/src/environments/environment.ts diff --git a/angular.json b/angular.json index 36ea1a2..9d77b76 100644 --- a/angular.json +++ b/angular.json @@ -36,7 +36,14 @@ }, "configurations": { "production": { - "budgets": [{ + "fileReplacements": [ + { + "replace": "projects/mastolists/src/environments/environment.ts", + "with": "projects/mastolists/src/environments/environment.prod.ts" + } + ], + "budgets": [ + { "type": "initial", "maximumWarning": "2mb", "maximumError": "3mb" @@ -56,6 +63,27 @@ "extractLicenses": false, "sourceMap": true, "namedChunks": true + }, + "staging": { + "fileReplacements": [ + { + "replace": "projects/mastolists/src/environments/environment.ts", + "with": "projects/mastolists/src/environments/environment.staging.ts" + } + ], + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "3mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" } }, "defaultConfiguration": "production" @@ -136,4 +164,4 @@ "cli": { "analytics": "48f19321-a0cb-4b6d-8ea6-27d1fd0792f5" } -} \ No newline at end of file +} diff --git a/package.json b/package.json index f43f08b..3d66b1d 100644 --- a/package.json +++ b/package.json @@ -1,79 +1,81 @@ { - "name": "mastodon", - "version": "0.0.0", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test" - }, - "private": true, - "dependencies": { - "@angular/animations": "^15.0.0", - "@angular/common": "^15.0.0", - "@angular/compiler": "^15.0.0", - "@angular/core": "^15.0.0", - "@angular/forms": "^15.0.0", - "@angular/platform-browser": "^15.0.0", - "@angular/platform-browser-dynamic": "^15.0.0", - "@angular/router": "^15.0.0", - "@nebular/auth": "10.0.0", - "@nebular/eva-icons": "^10.0.0", - "@nebular/security": "10.0.0", - "@nebular/theme": "10.0.0", - "@ngrx/effects": "^15.0.0", - "@ngrx/entity": "^15.0.0", - "@ngrx/store": "^15.0.0", - "@ngrx/store-devtools": "^15.0.0", - "axios": "^1.2.1", - "eva-icons": "^1.1.3", - "ngx-progressbar": "^9.0.0", - "oauth": "^0.10.0", - "object-assign-deep": "^0.4.0", - "parse-link-header": "^2.0.0", - "rxjs": "~7.5.0", - "tslib": "^2.3.0", - "zone.js": "~0.12.0" - }, - "devDependencies": { - "@angular-builders/custom-webpack": "^15.0.0-beta.0", - "@angular-devkit/build-angular": "^15.0.3", - "@angular/cli": "~15.0.2", - "@angular/compiler-cli": "^15.0.0", - "@types/jasmine": "~4.3.0", - "@types/oauth": "^0.9.1", - "@types/object-assign-deep": "^0.4.0", - "@types/parse-link-header": "^2.0.0", - "jasmine-core": "~4.5.0", - "karma": "~6.4.0", - "karma-chrome-launcher": "~3.1.0", - "karma-coverage": "~2.2.0", - "karma-jasmine": "~5.1.0", - "karma-jasmine-html-reporter": "~2.0.0", - "ng-packagr": "^15.0.0", - "typescript": "~4.8.2" - }, - "overrides": { - "@nebular/auth@10.0.0": { - "@angular/animations": "15.0.3", - "@angular/cdk": "15.0.0", - "@angular/common": "15.0.3", - "@angular/core": "15.0.3", - "@angular/forms": "15.0.3", - "@angular/router": "15.0.3" + "name": "mastodon", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "build:prod": "ng build --configuration=production", + "build:staging": "ng build --configuration=staging", + "watch": "ng build --watch --configuration development", + "test": "ng test" }, - "@nebular/theme@10.0.0": { - "@angular/animations": "15.0.3", - "@angular/cdk": "15.0.0", - "@angular/common": "15.0.3", - "@angular/core": "15.0.3", - "@angular/router": "15.0.3" + "private": true, + "dependencies": { + "@angular/animations": "^15.0.0", + "@angular/common": "^15.0.0", + "@angular/compiler": "^15.0.0", + "@angular/core": "^15.0.0", + "@angular/forms": "^15.0.0", + "@angular/platform-browser": "^15.0.0", + "@angular/platform-browser-dynamic": "^15.0.0", + "@angular/router": "^15.0.0", + "@nebular/auth": "10.0.0", + "@nebular/eva-icons": "^10.0.0", + "@nebular/security": "10.0.0", + "@nebular/theme": "10.0.0", + "@ngrx/effects": "^15.0.0", + "@ngrx/entity": "^15.0.0", + "@ngrx/store": "^15.0.0", + "@ngrx/store-devtools": "^15.0.0", + "axios": "^1.2.1", + "eva-icons": "^1.1.3", + "ngx-progressbar": "^9.0.0", + "oauth": "^0.10.0", + "object-assign-deep": "^0.4.0", + "parse-link-header": "^2.0.0", + "rxjs": "~7.5.0", + "tslib": "^2.3.0", + "zone.js": "~0.12.0" }, - "@nebular/security@10.0.0": { - "@angular/common": "15.0.3", - "@angular/core": "15.0.3", - "@angular/router": "15.0.3" + "devDependencies": { + "@angular-builders/custom-webpack": "^15.0.0-beta.0", + "@angular-devkit/build-angular": "^15.0.3", + "@angular/cli": "~15.0.2", + "@angular/compiler-cli": "^15.0.0", + "@types/jasmine": "~4.3.0", + "@types/oauth": "^0.9.1", + "@types/object-assign-deep": "^0.4.0", + "@types/parse-link-header": "^2.0.0", + "jasmine-core": "~4.5.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.1.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.0.0", + "ng-packagr": "^15.0.0", + "typescript": "~4.8.2" + }, + "overrides": { + "@nebular/auth@10.0.0": { + "@angular/animations": "15.0.3", + "@angular/cdk": "15.0.0", + "@angular/common": "15.0.3", + "@angular/core": "15.0.3", + "@angular/forms": "15.0.3", + "@angular/router": "15.0.3" + }, + "@nebular/theme@10.0.0": { + "@angular/animations": "15.0.3", + "@angular/cdk": "15.0.0", + "@angular/common": "15.0.3", + "@angular/core": "15.0.3", + "@angular/router": "15.0.3" + }, + "@nebular/security@10.0.0": { + "@angular/common": "15.0.3", + "@angular/core": "15.0.3", + "@angular/router": "15.0.3" + } } - } } diff --git a/projects/mastodon-api/src/lib/services/mastodon-api-authentication.service.ts b/projects/mastodon-api/src/lib/services/mastodon-api-authentication.service.ts index 6b35102..895180c 100644 --- a/projects/mastodon-api/src/lib/services/mastodon-api-authentication.service.ts +++ b/projects/mastodon-api/src/lib/services/mastodon-api-authentication.service.ts @@ -1,5 +1,5 @@ import {Injectable} from '@angular/core'; -import {map, Observable} from "rxjs"; +import {catchError, map, Observable, of, throwError} from "rxjs"; import {RegisterAppResponse} from "../interfaces/responses/register_app_response"; import {GetAccessTokenResponse} from "../interfaces/responses/get_access_token_response"; import {VerifyCredentialsResponse} from "../interfaces/responses/verify_credentials_response"; @@ -31,17 +31,20 @@ export class MastodonApiAuthenticationService { const url = `https://${instance}/api/v1/apps`; return this.mastodonApiService .post(url, parameters) - .pipe(map((response) => { - return { - id: response.id, - name: response.name, - website: response.website, - redirectUri: response.redirect_uri, - clientId: response.client_id, - clientSecret: response.client_secret, - vapidKey: response.vapid_key, - } - })); + .pipe( + map((response) => { + return { + id: response.id, + name: response.name, + website: response.website, + redirectUri: response.redirect_uri, + clientId: response.client_id, + clientSecret: response.client_secret, + vapidKey: response.vapid_key, + } + }), + catchError((error) => throwError(error)), + ); } authorizeUser(instance: string, clientId: string, redirectUrl: string) { diff --git a/projects/mastolists/src/app/app-routing.module.ts b/projects/mastolists/src/app/app-routing.module.ts index 1321db6..581f5ef 100644 --- a/projects/mastolists/src/app/app-routing.module.ts +++ b/projects/mastolists/src/app/app-routing.module.ts @@ -1,6 +1,6 @@ import {NgModule} from '@angular/core'; import {RouterModule, Routes} from '@angular/router'; -import {AuthGuard} from './shared/guards/auth.guard'; +import {AuthGuard} from './authorization/guards/auth.guard'; const routes: Routes = [ { @@ -25,7 +25,7 @@ const routes: Routes = [ loadChildren: () => import('./home/home.module').then(m => m.HomeModule), }, { - path: 'auth', + path: 'authorize', loadChildren: () => import('./authorization/authorization.module').then(m => m.AuthorizationModule), }, { diff --git a/projects/mastolists/src/app/app.component.html b/projects/mastolists/src/app/app.component.html index c0951b7..c5cf870 100644 --- a/projects/mastolists/src/app/app.component.html +++ b/projects/mastolists/src/app/app.component.html @@ -6,7 +6,7 @@ Novaloop favicon -

Mastolists

+

{{appName}}

+ + -
-

User is authorized with {{currentInstance?.instanceName}}

-

Next up: Sync

+
+

User is authorized with {{instanceName}}

+

Next up: Wait for Sync and manage

diff --git a/projects/mastolists/src/app/authorization/authorize/authorize.component.scss b/projects/mastolists/src/app/authorization/authorize/authorize.component.scss index 2930793..47f33e1 100644 --- a/projects/mastolists/src/app/authorization/authorize/authorize.component.scss +++ b/projects/mastolists/src/app/authorization/authorize/authorize.component.scss @@ -1,9 +1,11 @@ -.authorize-button { - margin: 20px; +.button { + margin-left: 20px; } @media (max-width: 573px) { - .authorize-button { + .button { margin-left: 0; + margin-top: 20px; + display: block; } } diff --git a/projects/mastolists/src/app/authorization/authorize/authorize.component.ts b/projects/mastolists/src/app/authorization/authorize/authorize.component.ts index 1470cda..288e6d6 100644 --- a/projects/mastolists/src/app/authorization/authorize/authorize.component.ts +++ b/projects/mastolists/src/app/authorization/authorize/authorize.component.ts @@ -1,10 +1,16 @@ import {Component, OnDestroy, OnInit} from '@angular/core'; -import {FormControl, FormGroup} from "@angular/forms"; -import {ActivatedRoute, Params, Router} from "@angular/router"; -import {BehaviorSubject, filter, Subscription, take} from "rxjs"; -import {PersistentStore} from "../../shared/state/persistent/persistent-store.service"; -import {MastodonApiAccountsService, MastodonApiAuthenticationService, MastodonApiListsService} from "projects/mastodon-api/src/public-api"; -import {Instance} from "../../shared/state/persistent/state"; +import {FormControl, FormGroup, Validators} from "@angular/forms"; +import {ActivatedRoute, Params} from "@angular/router"; +import {concat, filter, map, merge, Observable, take, tap, withLatestFrom, zip, zipWith} from "rxjs"; +import {select, Store} from "@ngrx/store"; +import { + selectAuthorizingUserFlag, + selectCurrentInstance, + fromAuthorize, + selectIsLoggedIn, + selectRegisteringApplicationFlag, + selectDataForAuthorizationFlow, selectApplicationRegisteredFlag, selectCurrentInstanceWithApplicationRegisteredState +} from "../../shared/state/store"; @Component({ selector: 'app-authorize', @@ -12,109 +18,86 @@ import {Instance} from "../../shared/state/persistent/state"; styleUrls: ['./authorize.component.scss'] }) export class AuthorizeComponent implements OnInit, OnDestroy { - subscriptions: Subscription[] = []; - currentInstance?: Instance; - accessToken?: string; - accountId?: string; - isAuthorized: BehaviorSubject = new BehaviorSubject(false); - authorizing: boolean = false; - + registeringApplication$: Observable; + applicationRegistered$: Observable; + authorizingUser$: Observable; + loggedIn$: Observable; + instanceName$: Observable; serverForm = new FormGroup({ - instance: new FormControl(''), + instance: new FormControl('', [Validators.required]), }); - constructor(private mastodonApiAuthService: MastodonApiAuthenticationService, - private store: PersistentStore, - private mastodonApiAccountsService: MastodonApiAccountsService, - private mastodonApiListsService: MastodonApiListsService, - private router: Router, - private route: ActivatedRoute,) { - this.subscriptions.push(this.store - .select('currentInstance') - .subscribe((currentInstance) => { - this.currentInstance = currentInstance - this.accessToken = this.currentInstance?.accessToken; - this.accountId = this.currentInstance?.accountId; - this.isAuthorized.next(!!(this.currentInstance && this.accessToken && this.accountId)); - })); + constructor(private store: Store, private route: ActivatedRoute) { + this.registeringApplication$ = this.store.pipe(select(selectRegisteringApplicationFlag)); + this.applicationRegistered$ = this.store.pipe(select(selectApplicationRegisteredFlag)); + this.authorizingUser$ = this.store.pipe(select(selectAuthorizingUserFlag)); + this.loggedIn$ = this.store.pipe(select(selectIsLoggedIn)); + this.instanceName$ = this.store.pipe( + select(selectCurrentInstanceWithApplicationRegisteredState), + tap((result) => { + if (result.instanceName !== undefined && result.applicationRegistered) { + this.serverForm.get('instance')?.setValue(result.instanceName); + this.serverForm.get('instance')?.disable(); + } else { + this.serverForm.get('instance')?.enable(); + } + }), + map((result) => result.instanceName) + ); + zip( + this.route.queryParams, + this.instanceName$, + this.loggedIn$, + ) + .pipe( + filter(([params, instanceName, isLoggedIn]) => params['code'] && instanceName !== undefined && !isLoggedIn), + take(1), + ).subscribe(([params, instanceName, isLoggedIn]) => { + this.store.pipe( + select(selectDataForAuthorizationFlow), + take(1), + ).subscribe((data) => { + this.store.dispatch(fromAuthorize.getAccessToken({ + code: params['code'], + instanceName: data.instanceName!, + clientId: data.clientId!, + clientSecret: data.clientSecret!, + redirectUrl: data.redirectUrl!, + })); + }); + } + ); } ngOnInit(): void { - this.route.queryParams - .pipe( - filter((params: Params) => params['code']), - take(1) - ) - .subscribe(params => { - if (this.currentInstance && !this.accessToken && params['code']) { - this.mastodonApiAuthService - .getAccessToken(this.currentInstance.instanceName, this.currentInstance.clientId, this.currentInstance.clientSecret, this.currentInstance.redirectUrl, params['code']) - .pipe(take(1)) - .subscribe(accessToken => { - this.store.set( - 'currentInstance', - {...this.currentInstance, 'accessToken': accessToken} - ); - this.mastodonApiAuthService.verifyCredentials(this.currentInstance!.instanceName, accessToken) - .pipe(take(1)) - .subscribe((accountId) => { - this.store.set( - 'currentInstance', - {...this.currentInstance, 'accountId': accountId} - ); - this.router.navigate(['/sync']); - this.isAuthorized.next(true); - } - ); - }); - } - }); } async registerApplication() { - this.authorizing = true; - this.subscriptions.push(this.store - .select('currentInstance') - .subscribe((currentInstance) => { - this.authorizing = false; - let instanceName = this.serverForm.value.instance ?? ''; - if (instanceName === '') { - return; - } - if (currentInstance?.instanceName === instanceName) { - this.mastodonApiAuthService - .authorizeUser(currentInstance.instanceName, currentInstance.clientId, currentInstance.redirectUrl); - return; - } - - // let redirectUrl = window.location.toString(); - let redirectUrl = window.location.protocol + '//' + window.location.host + window.location.pathname; - this.authorizing = true; - this.mastodonApiAuthService - .createApp(instanceName, 'Mastolists', redirectUrl, 'https://mastolists.novaloop.cloud') - .pipe(take(1)) - .subscribe(res => { - this.authorizing = false; - this.store.set( - 'currentInstance', - { - id: res.id, - instanceName: instanceName, - appName: res.name, - website: res.website, - redirectUrl: res.redirectUri, - clientId: res.clientId, - clientSecret: res.clientSecret, - } - ); - this.mastodonApiAuthService.authorizeUser(instanceName, res.clientId, res.redirectUri); - }); - })); + const instanceName = this.serverForm.get('instance')?.value; + if (instanceName) { + const redirectUrl = window.location.protocol + '//' + window.location.host + window.location.pathname; + this.store.dispatch(fromAuthorize.registerApplication({instanceName, redirectUrl})); + } } ngOnDestroy(): void { - this.subscriptions.forEach(subscription => subscription.unsubscribe()); } + authorizeUser() { + this.store.pipe( + select(selectDataForAuthorizationFlow), + take(1), + ).subscribe((data) => { + this.store.dispatch(fromAuthorize.authorizeUser({ + instanceName: data.instanceName!, + clientId: data.clientId!, + redirectUrl: data.redirectUrl!, + })); + }); + } + + logout() { + this.store.dispatch(fromAuthorize.logout()); + } } diff --git a/projects/mastolists/src/app/authorization/guards/auth.guard.ts b/projects/mastolists/src/app/authorization/guards/auth.guard.ts new file mode 100644 index 0000000..d620eca --- /dev/null +++ b/projects/mastolists/src/app/authorization/guards/auth.guard.ts @@ -0,0 +1,27 @@ +import {Injectable} from '@angular/core'; +import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from "@angular/router"; +import {Observable, tap} from "rxjs"; +import {select, Store} from "@ngrx/store"; +import {selectIsLoggedIn} from "../../shared/state/store"; +import {MastodonApiAuthenticationService} from 'projects/mastodon-api/src/public-api'; + +@Injectable({providedIn: 'root'}) +export class AuthGuard implements CanActivate { + + constructor(private store: Store, + private router: Router, + private mastodonApiAuthService: MastodonApiAuthenticationService) { + } + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.store + .pipe( + select(selectIsLoggedIn), + tap((isLoggedIn) => { + if (!isLoggedIn) { + this.router.navigate(['/authorize']); + } + }), + ); + } +} diff --git a/projects/mastolists/src/app/followings/list/list.component.ts b/projects/mastolists/src/app/followings/list/list.component.ts index a58bb23..0abc746 100644 --- a/projects/mastolists/src/app/followings/list/list.component.ts +++ b/projects/mastolists/src/app/followings/list/list.component.ts @@ -1,12 +1,8 @@ import {Component} from '@angular/core'; -import {PersistentStore} from "../../shared/state/persistent/persistent-store.service"; -import {Account, List, MastodonApiListsService} from 'projects/mastodon-api/src/public-api'; -import {NbToastrService} from "@nebular/theme"; -import {ListService} from "../../shared/services/list.service"; +import {Account, List} from 'projects/mastodon-api/src/public-api'; import {Observable} from "rxjs"; import {select, Store} from "@ngrx/store"; -import {selectFilteredFollowingsWithLists, selectFollowings, selectFollowingsWithLists, selectLists} from "../../shared/state/store/selectors"; -import {FiltersActions, ListActions, MastodonApiActions} from "../../shared/state/store/actions"; +import {fromListViewPage, selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store"; @Component({ selector: 'app-list', @@ -23,11 +19,11 @@ export class ListComponent { } addAccountToSelectedList(accountId: string, select: HTMLSelectElement) { - this.store.dispatch(ListActions.addAccountToList({accountId, listId: select.value})); + this.store.dispatch(fromListViewPage.addAccountToList({accountId, listId: select.value})); } removeAccountFromList(accountId: string, listId: string) { - this.store.dispatch(ListActions.removeAccountFromList({accountId, listId})); + this.store.dispatch(fromListViewPage.removeAccountFromList({accountId, listId})); } } diff --git a/projects/mastolists/src/app/followings/matrix/matrix.component.ts b/projects/mastolists/src/app/followings/matrix/matrix.component.ts index 68da4a2..b4918dc 100644 --- a/projects/mastolists/src/app/followings/matrix/matrix.component.ts +++ b/projects/mastolists/src/app/followings/matrix/matrix.component.ts @@ -1,13 +1,9 @@ import {Component, TemplateRef, ViewChild} from '@angular/core'; -import {PersistentStore} from "../../shared/state/persistent/persistent-store.service"; -import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account"; -import {List} from "../../../../../mastodon-api/src/lib/interfaces/public/list"; -import {ListService} from "../../shared/services/list.service"; import {Observable} from "rxjs"; import {select, Store} from "@ngrx/store"; -import {selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store/selectors"; -import {ListActions} from "../../shared/state/store/actions"; import {NbDialogService} from "@nebular/theme"; +import {fromMatrixViewPage, selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store"; +import {Account, List} from 'projects/mastodon-api/src/public-api'; @Component({ selector: 'app-matrix', @@ -36,9 +32,9 @@ export class MatrixComponent { onCheckedChange($event: boolean, accountId: string, listId: string) { if ($event) { - this.store.dispatch(ListActions.addAccountToList({accountId, listId})); + this.store.dispatch(fromMatrixViewPage.addAccountToList({accountId, listId})); } else { - this.store.dispatch(ListActions.removeAccountFromList({accountId, listId})); + this.store.dispatch(fromMatrixViewPage.removeAccountFromList({accountId, listId})); } } } diff --git a/projects/mastolists/src/app/followings/table/table.component.ts b/projects/mastolists/src/app/followings/table/table.component.ts index 39043f0..a29aa60 100644 --- a/projects/mastolists/src/app/followings/table/table.component.ts +++ b/projects/mastolists/src/app/followings/table/table.component.ts @@ -4,7 +4,7 @@ import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/acc import {List} from "../../../../../mastodon-api/src/lib/interfaces/public/list"; import {select, Store} from "@ngrx/store"; import {selectFilteredFollowingsWithLists, selectLists} from "../../shared/state/store/selectors"; -import {ListActions} from "../../shared/state/store/actions"; +import {fromTableViewPage} from "../../shared/state/store"; interface DataGridRow { id: string; @@ -49,10 +49,10 @@ export class TableComponent { } addAccountToSelectedList(accountId: string, listId: string) { - this.store.dispatch(ListActions.addAccountToList({accountId, listId})); + this.store.dispatch(fromTableViewPage.addAccountToList({accountId, listId})); } removeAccountFromList(accountId: string, listId: string) { - this.store.dispatch(ListActions.removeAccountFromList({accountId, listId})); + this.store.dispatch(fromTableViewPage.removeAccountFromList({accountId, listId})); } } diff --git a/projects/mastolists/src/app/home/home/home.component.html b/projects/mastolists/src/app/home/home/home.component.html index 604e62a..9011b0f 100644 --- a/projects/mastolists/src/app/home/home/home.component.html +++ b/projects/mastolists/src/app/home/home/home.component.html @@ -6,7 +6,7 @@

Organize your Followings into lists.

    -
  • Authorize with your instance
  • +
  • Authorize with your instance
  • Sync your lists and followings to local storage
  • Select the List view to add and remove users from lists
  • ... or use the experimental Matrix view
  • diff --git a/projects/mastolists/src/app/lists/lists/lists.component.html b/projects/mastolists/src/app/lists/lists/lists.component.html index 218d674..4033a3e 100644 --- a/projects/mastolists/src/app/lists/lists/lists.component.html +++ b/projects/mastolists/src/app/lists/lists/lists.component.html @@ -11,7 +11,7 @@
- +
diff --git a/projects/mastolists/src/app/lists/lists/lists.component.ts b/projects/mastolists/src/app/lists/lists/lists.component.ts index 61ef34f..2651ea8 100644 --- a/projects/mastolists/src/app/lists/lists/lists.component.ts +++ b/projects/mastolists/src/app/lists/lists/lists.component.ts @@ -2,10 +2,9 @@ import {Component} from '@angular/core'; import {List} from 'projects/mastodon-api/src/public-api'; import {Observable} from "rxjs"; import {select, Store} from "@ngrx/store"; -import {selectLists} from "../../shared/state/store/selectors"; -import {PersistentStore} from "../../shared/state/persistent/persistent-store.service"; -import {ListActions} from "../../shared/state/store/actions"; +import {selectCurrentInstance, selectLists} from "../../shared/state/store/selectors"; import {FormControl, FormGroup} from "@angular/forms"; +import {fromListsPage} from '../../shared/state/store'; @Component({ selector: 'app-lists', @@ -15,28 +14,28 @@ import {FormControl, FormGroup} from "@angular/forms"; export class ListsComponent { lists$: Observable>; - instanceName: string; + instanceName$: Observable; creatingList: boolean = false; newListForm = new FormGroup({ title: new FormControl(''), }); - constructor(private store: Store, private persistentStore: PersistentStore) { - this.instanceName = persistentStore.value.currentInstance.instanceName; + constructor(private store: Store) { + this.instanceName$ = this.store.pipe(select(selectCurrentInstance)); this.lists$ = this.store.pipe(select(selectLists)); } listNameChanged(id: string, event: Event) { const target = event.target as HTMLInputElement; const newTitle = target.value; - this.store.dispatch(ListActions.updateList({listId: id, newTitle})); + this.store.dispatch(fromListsPage.updateList({listId: id, newTitle})); } createList() { let title = this.newListForm.value.title ?? ''; if (title.length > 0) { - this.store.dispatch(ListActions.createList({title})); + this.store.dispatch(fromListsPage.createList({title})); this.newListForm.reset(); } } diff --git a/projects/mastolists/src/app/shared/components/filters/filters.component.ts b/projects/mastolists/src/app/shared/components/filters/filters.component.ts index d90c75b..9e12bf5 100644 --- a/projects/mastolists/src/app/shared/components/filters/filters.component.ts +++ b/projects/mastolists/src/app/shared/components/filters/filters.component.ts @@ -1,11 +1,9 @@ import {Component, OnDestroy} from '@angular/core'; import {FormControl, FormGroup} from "@angular/forms"; import {select, Store} from "@ngrx/store"; -import {FiltersActions, MastodonApiActions} from "../../state/store/actions"; import {debounceTime, Observable, Subscription, tap} from "rxjs"; import {List} from 'projects/mastodon-api/src/public-api'; -import {selectFilters, selectFiltersForForm, selectLists} from "../../state/store/selectors"; -import {Filters} from "../../state/store/reducers"; +import {fromFilters, selectFiltersForForm, selectLists} from "../../state/store"; interface FiltersForForm { fullText: string | undefined; @@ -43,11 +41,11 @@ export class FiltersComponent implements OnDestroy { .pipe(debounceTime(500)) .subscribe(value => { if (value['lists'] && value['lists'].length > 0) { - this.store.dispatch(FiltersActions.setLists({lists: value.lists})); - this.store.dispatch(FiltersActions.setUnlisted({unlisted: false})); + this.store.dispatch(fromFilters.setLists({lists: value.lists})); + this.store.dispatch(fromFilters.setUnlisted({unlisted: false})); } if (!value['lists'] || value['lists'].length === 0) { - this.store.dispatch(FiltersActions.setLists({lists: []})); + this.store.dispatch(fromFilters.setLists({lists: []})); } if (value['text']) { const text = value['text']; @@ -55,18 +53,18 @@ export class FiltersComponent implements OnDestroy { const parts = text.split(/[\s,]+/); if (parts.some((part: string) => part.startsWith('@'))) { const username = parts.find((part: string) => part.startsWith('@')); - this.store.dispatch(FiltersActions.setUsername({username: username.substring(1)})); + this.store.dispatch(fromFilters.setUsername({username: username.substring(1)})); } else { - this.store.dispatch(FiltersActions.setUsername({username: ''})); + this.store.dispatch(fromFilters.setUsername({username: ''})); } - this.store.dispatch(FiltersActions.setFreeText({freeText: parts.filter((part: string) => !part.startsWith('@'))})); + this.store.dispatch(fromFilters.setFreeText({freeText: parts.filter((part: string) => !part.startsWith('@'))})); } else { - this.store.dispatch(FiltersActions.setUsername({username: ''})); - this.store.dispatch(FiltersActions.setFreeText({freeText: []})); + this.store.dispatch(fromFilters.setUsername({username: ''})); + this.store.dispatch(fromFilters.setFreeText({freeText: []})); } } else { - this.store.dispatch(FiltersActions.setUsername({username: ''})); - this.store.dispatch(FiltersActions.setFreeText({freeText: []})); + this.store.dispatch(fromFilters.setUsername({username: ''})); + this.store.dispatch(fromFilters.setFreeText({freeText: []})); } }); } @@ -76,10 +74,11 @@ export class FiltersComponent implements OnDestroy { } clearFilter() { - this.store.dispatch(FiltersActions.clearFilters()); + this.store.dispatch(fromFilters.clearFilters()); + } toggleUnlisted($event: boolean) { - this.store.dispatch(FiltersActions.setUnlisted({unlisted: $event})); + this.store.dispatch(fromFilters.setUnlisted({unlisted: $event})); } } diff --git a/projects/mastolists/src/app/shared/guards/auth.guard.ts b/projects/mastolists/src/app/shared/guards/auth.guard.ts deleted file mode 100644 index 8ea1068..0000000 --- a/projects/mastolists/src/app/shared/guards/auth.guard.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {Injectable} from '@angular/core'; -import {MastodonApiAuthenticationService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-authentication.service"; -import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot} from "@angular/router"; -import {map, Observable, tap} from "rxjs"; -import {PersistentStore} from "../state/persistent/persistent-store.service"; -import {Store} from "@ngrx/store"; - -@Injectable({providedIn: 'root'}) -export class AuthGuard implements CanActivate { - - constructor(private persistentStore: PersistentStore, - private store: Store, - private router: Router, - private mastodonApiAuthService: MastodonApiAuthenticationService) { - } - - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | boolean { - const currentInstance = this.persistentStore.value.currentInstance; - if (currentInstance && currentInstance.accessToken && currentInstance.accountId) return true; - if (currentInstance && currentInstance.accessToken && !currentInstance.accountId) { - return this.mastodonApiAuthService.verifyCredentials(currentInstance.instanceName, currentInstance.accessToken).pipe( - map((accountId) => { - this.persistentStore.set( - 'currentInstance', - {...currentInstance, 'accountId': accountId} - ); - return !!accountId; - }) - ); - } - this.router.navigate(['/auth']); - return false; - } -} diff --git a/projects/mastolists/src/app/shared/services/account.service.ts b/projects/mastolists/src/app/shared/services/account.service.ts index 5b5ae9f..5e070a0 100644 --- a/projects/mastolists/src/app/shared/services/account.service.ts +++ b/projects/mastolists/src/app/shared/services/account.service.ts @@ -1,31 +1,39 @@ -import {Injectable} from '@angular/core'; -import {MastodonApiAccountsService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-accounts.service"; -import {PersistentStore} from "../state/persistent/persistent-store.service"; -import {EMPTY, expand, map, Observable, reduce} from "rxjs"; -import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account"; +import {Injectable, OnDestroy} from '@angular/core'; +import {EMPTY, expand, map, Observable, reduce, Subscription, switchMap, tap} from "rxjs"; +import {select, Store} from "@ngrx/store"; +import {Account, MastodonApiAccountsService} from 'projects/mastodon-api/src/public-api'; +import {selectDataForAuthorizedRequest} from "../state/store"; @Injectable({ providedIn: 'root' }) -export class AccountService { +export class AccountService implements OnDestroy { + private instanceName: string | undefined; + private accessToken: string | undefined; + private accountId: string | undefined; + private storeSubscription: Subscription; - constructor(private mastodonApiAccountsService: MastodonApiAccountsService, - private store: PersistentStore) { + constructor(private mastodonApiAccountsService: MastodonApiAccountsService, private store: Store) { + this.storeSubscription = this.store.pipe(select(selectDataForAuthorizedRequest)).subscribe(data => { + this.instanceName = data?.instanceName; + this.accessToken = data?.accessToken; + this.accountId = data?.accountId; + }); + } + ngOnDestroy(): void { + this.storeSubscription.unsubscribe(); } loadFollowings(): Observable> { - const applicationState = this.store.value; - const instanceName = applicationState.currentInstance?.instanceName; - const accessToken = applicationState.currentInstance?.accessToken; - const accountId = applicationState.currentInstance?.accountId; return this.mastodonApiAccountsService - .getFollowingsForAccount(instanceName, accessToken!, accountId) + .getFollowingsForAccount(this.instanceName!, this.accessToken!, this.accountId!) .pipe( expand(result => { const nextLink = result[0]; if (nextLink && nextLink.length > 0) { - return this.mastodonApiAccountsService.getFollowingsForAccount(instanceName, accessToken!, accountId, nextLink); + return this.mastodonApiAccountsService + .getFollowingsForAccount(this.instanceName!, this.accessToken!, this.accountId!, nextLink); } return EMPTY; }), diff --git a/projects/mastolists/src/app/shared/services/list.service.ts b/projects/mastolists/src/app/shared/services/list.service.ts index c8c5073..8770a86 100644 --- a/projects/mastolists/src/app/shared/services/list.service.ts +++ b/projects/mastolists/src/app/shared/services/list.service.ts @@ -1,63 +1,47 @@ -import {Injectable} from '@angular/core'; -import {EMPTY, expand, map, Observable, reduce, take} from "rxjs"; -import {MastodonApiListsService} from "../../../../../mastodon-api/src/lib/services/mastodon-api-lists.service"; -import {PersistentStore} from "../state/persistent/persistent-store.service"; -import {Account} from "../../../../../mastodon-api/src/lib/interfaces/public/account"; -import {List} from "../../../../../mastodon-api/src/lib/interfaces/public/list"; +import {Injectable, OnDestroy} from '@angular/core'; +import {EMPTY, expand, filter, map, Observable, of, reduce, Subscription, switchMap, tap} from "rxjs"; +import {select, Store} from "@ngrx/store"; +import {List, MastodonApiListsService} from 'projects/mastodon-api/src/public-api'; +import {selectDataForAuthorizedRequest} from "../state/store"; @Injectable({ providedIn: 'root' }) -export class ListService { +export class ListService implements OnDestroy { - constructor(private mastodonApiListsService: MastodonApiListsService, - private store: PersistentStore) { + private instanceName: string | undefined; + private accessToken: string | undefined; + private storeSubscription: Subscription; + + constructor(private mastodonApiListsService: MastodonApiListsService, private store: Store) { + this.storeSubscription = this.store.pipe(select(selectDataForAuthorizedRequest)).subscribe(data => { + this.instanceName = data?.instanceName; + this.accessToken = data?.accessToken; + }); } loadLists(): Observable { - const applicationState = this.store.value; - const instanceName = applicationState.currentInstance?.instanceName; - const accessToken = applicationState.currentInstance?.accessToken; - return this.mastodonApiListsService - .getLists(instanceName, accessToken!) - .pipe(map(result => result.sort((a, b) => a.title.localeCompare(b.title)))); + return this.mastodonApiListsService.getLists(this.instanceName!, this.accessToken!) } createList(title: string) { - const applicationState = this.store.value; - const instanceName = applicationState.currentInstance?.instanceName; - const accessToken = applicationState.currentInstance?.accessToken; - return this.mastodonApiListsService - .createList(instanceName, accessToken!, title) - .pipe( - take(1), - ); + return this.mastodonApiListsService.createList(this.instanceName!, this.accessToken!, title); } updateList(listId: string, newName: string) { - const applicationState = this.store.value; - const instanceName = applicationState.currentInstance?.instanceName; - const accessToken = applicationState.currentInstance?.accessToken; - return this.mastodonApiListsService - .updateList(instanceName, accessToken!, listId, newName) - .pipe( - take(1), - ); + return this.mastodonApiListsService.updateList(this.instanceName!, this.accessToken!, listId, newName); } loadAccountsIdsForList(listId: string): Observable<{ [id: string]: string[] }> { - const applicationState = this.store.value; - const instanceName = applicationState.currentInstance?.instanceName; - const accessToken = applicationState.currentInstance?.accessToken; return this.mastodonApiListsService - .getAccountsForList(instanceName, accessToken!, listId) + .getAccountsForList(this.instanceName!, this.accessToken!, listId) .pipe( expand(result => { const nextLink = result[0]; if (nextLink && nextLink.length > 0) { - return this.mastodonApiListsService.getAccountsForList(instanceName, accessToken!, listId, nextLink); + return this.mastodonApiListsService.getAccountsForList(this.instanceName!, this.accessToken!, listId, nextLink); } return EMPTY; }), @@ -78,25 +62,15 @@ export class ListService { } addAccountToSelectedList(accountId: string, listId: string) { - const applicationState = this.store.value; - const instanceName = applicationState.currentInstance?.instanceName; - const accessToken = applicationState.currentInstance?.accessToken; - return this.mastodonApiListsService - .addAccountToList(instanceName, accessToken!, listId, accountId) - .pipe( - take(1), - ); + return this.mastodonApiListsService.addAccountToList(this.instanceName!, this.accessToken!, listId, accountId) } removeAccountFromList(accountId: string, listId: string) { - const applicationState = this.store.value; - const instanceName = applicationState.currentInstance?.instanceName; - const accessToken = applicationState.currentInstance?.accessToken; - return this.mastodonApiListsService - .removeAccountFromList(instanceName, accessToken!, listId, accountId) - .pipe( - take(1), - ) + return this.mastodonApiListsService.removeAccountFromList(this.instanceName!, this.accessToken!, listId, accountId) + } + + ngOnDestroy(): void { + this.storeSubscription.unsubscribe(); } } diff --git a/projects/mastolists/src/app/shared/state/persistent/local-storage-ref.service.ts b/projects/mastolists/src/app/shared/state/persistent/local-storage-ref.service.ts deleted file mode 100644 index 2830e18..0000000 --- a/projects/mastolists/src/app/shared/state/persistent/local-storage-ref.service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {Injectable} from "@angular/core"; - -function getLocalStorage(): Storage { - return localStorage; -} - -@Injectable({providedIn: "root"}) -export class LocalStorageRefService { - get localStorage(): Storage { - return getLocalStorage(); - } -} diff --git a/projects/mastolists/src/app/shared/state/persistent/persistent-store.service.ts b/projects/mastolists/src/app/shared/state/persistent/persistent-store.service.ts deleted file mode 100644 index c5d0d24..0000000 --- a/projects/mastolists/src/app/shared/state/persistent/persistent-store.service.ts +++ /dev/null @@ -1,75 +0,0 @@ -import {BehaviorSubject, distinctUntilChanged, map, Observable} from "rxjs"; -import {Injectable} from "@angular/core"; -import {LocalStorageRefService} from "./local-storage-ref.service"; -import {Instance, State} from "./state"; - -const state = {}; - -@Injectable({providedIn: 'root'}) -export class PersistentStore { - private _localStorage: Storage; - private subject = new BehaviorSubject(state); - private store = this.subject.asObservable().pipe(distinctUntilChanged()); - - constructor(private _localStorageRefService: LocalStorageRefService) { - this._localStorage = _localStorageRefService.localStorage; - this.loadState(); - } - - get value() { - return this.subject.value; - } - - isAuthorized(): boolean { - const currentInstance = this.value.currentInstance; - if (!currentInstance) return false; - if (!currentInstance.accessToken) return false; - if (!currentInstance.accountId) return false; - return true; - } - - select(name: string): Observable { - return this.store.pipe(map(value => value ? value[name as keyof State] : {})); - } - - set(name: string, state: any) { - this.subject.next({ - ...this.value, [name as keyof State]: state - }); - this.persistState(); - } - - private persistState() { - const jsonData = JSON.stringify(this.subject.value, this.stringifyMap); - this._localStorage.setItem('applicationData', jsonData); - } - - private loadState() { - const jsonData = this._localStorage.getItem('applicationData'); - if (jsonData) { - const data = JSON.parse(jsonData, this.parseMap); - this.subject.next(data); - } - } - - - private stringifyMap(key: string, value: Map | Map | object) { - if (value instanceof Map) { - return { - dataType: 'Map', - value: [...value] - }; - } else { - return value; - } - } - - private parseMap(key: string, value: any) { - if (typeof value === 'object' && value !== null) { - if (value.dataType === 'Map') { - return new Map(value.value); - } - } - return value; - } -} diff --git a/projects/mastolists/src/app/shared/state/persistent/state.ts b/projects/mastolists/src/app/shared/state/persistent/state.ts deleted file mode 100644 index 893de34..0000000 --- a/projects/mastolists/src/app/shared/state/persistent/state.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {Account} from "../../../../../../mastodon-api/src/lib/interfaces/public/account"; -import {List} from "../../../../../../mastodon-api/src/lib/interfaces/public/list"; - -export interface Instance { - instanceName: string; - accessToken?: string; - accountId: string; - id: string; - appName: string; - website: string; - redirectUrl: string; - clientId: string; - clientSecret: string; - vapidKey: string; -} - -export interface State { - currentInstance: Instance; -} diff --git a/projects/mastolists/src/app/shared/state/store/actions.ts b/projects/mastolists/src/app/shared/state/store/actions.ts deleted file mode 100644 index ee262a8..0000000 --- a/projects/mastolists/src/app/shared/state/store/actions.ts +++ /dev/null @@ -1,46 +0,0 @@ -import {createAction, createActionGroup, emptyProps, props} from "@ngrx/store"; -import {List} from "../../../../../../mastodon-api/src/lib/interfaces/public/list"; -import {Account} from "../../../../../../mastodon-api/src/lib/interfaces/public/account"; - -export const MastodonApiActions = createActionGroup({ - source: 'Mastodon API', - events: { - 'Load Lists': emptyProps(), - 'Load Followings': emptyProps(), - 'Load Accounts For List': emptyProps(), - 'Lists Loaded': props<{ lists: ReadonlyArray }>(), - 'Lists Loaded Error': emptyProps(), - 'Account Ids for Lists Loaded': props<{ mappings: { [listId: string]: string[] } }>(), - 'Followings Loaded': props<{ followings: ReadonlyArray }>(), - 'Followings Loaded Error': props<{ error: string }>(), - } -}); - -export const ListActions = createActionGroup({ - source: 'List', - events: { - 'Add Account to List': props<{ accountId: string, listId: string }>(), - 'Add Account to List Success': emptyProps(), - 'Add Account to List Error': emptyProps(), - 'Remove Account from List': props<{ accountId: string, listId: string }>(), - 'Remove Account from List Success': emptyProps(), - 'Remove Account from List Error': emptyProps(), - 'Create List': props<{ title: string }>(), - 'Create List Success': props<{ listId: string, title: string }>(), - 'Create List Error': emptyProps(), - 'Update List': props<{ listId: string, newTitle: string }>(), - 'Update List Success': props<{ listId: string, newTitle: string }>(), - 'Update List Error': emptyProps(), - }, -}); - -export const FiltersActions = createActionGroup({ - source: 'Filters', - events: { - 'Set Username': props<{ username: string }>(), - 'Set Free Text': props<{ freeText: ReadonlyArray }>(), - 'Set Lists': props<{ lists: ReadonlyArray }>(), - 'Set Unlisted': props<{ unlisted: boolean }>(), - 'Clear Filters': emptyProps(), - }, -}) diff --git a/projects/mastolists/src/app/shared/state/store/actions/application.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/application.actions.ts new file mode 100644 index 0000000..ab8c417 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/application.actions.ts @@ -0,0 +1,10 @@ +import {createActionGroup, emptyProps} from "@ngrx/store"; + +export const fromApplication = createActionGroup({ + source: 'Application', + events: { + 'Load Lists': emptyProps(), + 'Load Followings': emptyProps(), + 'Load Accounts For List': emptyProps(), + } +}); diff --git a/projects/mastolists/src/app/shared/state/store/actions/authorize.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/authorize.actions.ts new file mode 100644 index 0000000..99f6334 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/authorize.actions.ts @@ -0,0 +1,16 @@ +import {createActionGroup, emptyProps, props} from "@ngrx/store"; + +export const fromAuthorize = createActionGroup({ + source: 'Authorize', + events: { + 'Load Local Storage': emptyProps(), + 'Register Application': props<{ instanceName: string, redirectUrl: string }>(), + 'Register Application Success': props<{ id: string, appName: string, website: string, clientId: string, clientSecret: string }>(), + 'Register Application Error': props<{ error: any }>(), + 'Authorize User': props<{ instanceName: string, clientId: string, redirectUrl: string }>(), + 'Get Access Token': props<{ code: string, instanceName: string, clientId: string, clientSecret: string, redirectUrl: string }>(), + 'Get Access Token Success': props<{ accessToken: string, accountId: string }>(), + 'Get Access Token Error': emptyProps(), + 'Logout': emptyProps(), + } +}); diff --git a/projects/mastolists/src/app/shared/state/store/actions/filters.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/filters.actions.ts new file mode 100644 index 0000000..892b609 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/filters.actions.ts @@ -0,0 +1,12 @@ +import {createActionGroup, emptyProps, props} from "@ngrx/store"; + +export const fromFilters = createActionGroup({ + source: 'Filters', + events: { + 'Set Username': props<{ username: string }>(), + 'Set Free Text': props<{ freeText: ReadonlyArray }>(), + 'Set Lists': props<{ lists: ReadonlyArray }>(), + 'Set Unlisted': props<{ unlisted: boolean }>(), + 'Clear Filters': emptyProps(), + }, +}); diff --git a/projects/mastolists/src/app/shared/state/store/actions/index.ts b/projects/mastolists/src/app/shared/state/store/actions/index.ts new file mode 100644 index 0000000..e14143e --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/index.ts @@ -0,0 +1,9 @@ +export * from "./authorize.actions"; +export * from "./application.actions"; +export * from "./filters.actions"; +export * from './lists.actions'; +export * from './list-view.actions'; +export * from "./mastodon-api.actions"; +export * from './matrix-view.actions'; +export * from './sync.actions' +export * from './table-view.actions'; diff --git a/projects/mastolists/src/app/shared/state/store/actions/list-view.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/list-view.actions.ts new file mode 100644 index 0000000..e5a2088 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/list-view.actions.ts @@ -0,0 +1,10 @@ +import {createActionGroup, props} from "@ngrx/store"; + +export const fromListViewPage = createActionGroup({ + source: 'ListViewPage', + events: { + 'Add Account to List': props<{ accountId: string, listId: string }>(), + 'Remove Account from List': props<{ accountId: string, listId: string }>(), + }, +}); + diff --git a/projects/mastolists/src/app/shared/state/store/actions/lists.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/lists.actions.ts new file mode 100644 index 0000000..8de2807 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/lists.actions.ts @@ -0,0 +1,9 @@ +import {createActionGroup, props} from "@ngrx/store"; + +export const fromListsPage = createActionGroup({ + source: 'ListsPage', + events: { + 'Create List': props<{ title: string }>(), + 'Update List': props<{ listId: string, newTitle: string }>(), + }, +}); diff --git a/projects/mastolists/src/app/shared/state/store/actions/mastodon-api.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/mastodon-api.actions.ts new file mode 100644 index 0000000..2696da9 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/mastodon-api.actions.ts @@ -0,0 +1,27 @@ +import {createActionGroup, emptyProps, props} from "@ngrx/store"; +import {Account, List} from "projects/mastodon-api/src/public-api"; + +export const fromMastodonApi = createActionGroup({ + source: 'MastodonApi', + events: { + 'Lists Loaded Success': props<{ lists: ReadonlyArray }>(), + 'Lists Loaded Error': emptyProps(), + + 'Account Ids for Lists Loaded Success': props<{ mappings: { [listId: string]: string[] } }>(), + + 'Followings Loaded Success': props<{ followings: ReadonlyArray }>(), + 'Followings Loaded Error': props<{ error: string }>(), + + 'Add Account to List Success': emptyProps(), + 'Add Account to List Error': emptyProps(), + + 'Remove Account from List Success': emptyProps(), + 'Remove Account from List Error': emptyProps(), + + 'Create List Success': props<{ listId: string, title: string }>(), + 'Create List Error': emptyProps(), + + 'Update List Success': props<{ listId: string, newTitle: string }>(), + 'Update List Error': emptyProps(), + } +}); diff --git a/projects/mastolists/src/app/shared/state/store/actions/matrix-view.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/matrix-view.actions.ts new file mode 100644 index 0000000..6c17364 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/matrix-view.actions.ts @@ -0,0 +1,9 @@ +import {createActionGroup, props} from "@ngrx/store"; + +export const fromMatrixViewPage = createActionGroup({ + source: 'MatrixViewPage', + events: { + 'Add Account to List': props<{ accountId: string, listId: string }>(), + 'Remove Account from List': props<{ accountId: string, listId: string }>(), + }, +}); diff --git a/projects/mastolists/src/app/shared/state/store/actions/sync.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/sync.actions.ts new file mode 100644 index 0000000..bc8dfc4 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/sync.actions.ts @@ -0,0 +1,10 @@ +import {createActionGroup, emptyProps} from "@ngrx/store"; + +export const fromSyncPage = createActionGroup({ + source: 'SyncPage', + events: { + 'Load Lists': emptyProps(), + 'Load Followings': emptyProps(), + 'Load Accounts For List': emptyProps(), + } +}); diff --git a/projects/mastolists/src/app/shared/state/store/actions/table-view.actions.ts b/projects/mastolists/src/app/shared/state/store/actions/table-view.actions.ts new file mode 100644 index 0000000..16eed41 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/actions/table-view.actions.ts @@ -0,0 +1,9 @@ +import {createActionGroup, props} from "@ngrx/store"; + +export const fromTableViewPage = createActionGroup({ + source: 'TableViewPage', + events: { + 'Add Account to List': props<{ accountId: string, listId: string }>(), + 'Remove Account from List': props<{ accountId: string, listId: string }>(), + }, +}); diff --git a/projects/mastolists/src/app/shared/state/store/application-state.ts b/projects/mastolists/src/app/shared/state/store/application-state.ts new file mode 100644 index 0000000..bf8b9de --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/application-state.ts @@ -0,0 +1,34 @@ +import {List} from "../../../../../../mastodon-api/src/lib/interfaces/public/list"; +import {Account} from "../../../../../../mastodon-api/src/lib/interfaces/public/account"; + +export interface Filters { + username: string; + freeText: ReadonlyArray; + lists: ReadonlyArray; + unlisted: boolean; +} + +export interface ApplicationState { + listsLoading: boolean; + listAccountsLoading: boolean; + followingsLoading: boolean; + lists: ReadonlyArray; + listsAccounts: { [listId: string]: string[] }; + followings: ReadonlyArray; + filters: Filters; +} + +export const initialState: ApplicationState = { + listsLoading: false, + listAccountsLoading: false, + followingsLoading: false, + lists: [], + listsAccounts: {}, + followings: [], + filters: { + username: '', + freeText: [], + lists: [], + unlisted: false, + } +} diff --git a/projects/mastolists/src/app/shared/state/store/authentication-state.ts b/projects/mastolists/src/app/shared/state/store/authentication-state.ts new file mode 100644 index 0000000..33bf848 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/authentication-state.ts @@ -0,0 +1,21 @@ +export interface AuthenticationState { + registeringApplication: boolean; + applicationRegistered: boolean; + authorizingUser: boolean; + instanceName?: string; + accessToken?: string; + accountId?: string; + id?: string; + appName?: string; + website?: string; + redirectUrl?: string; + clientId?: string; + clientSecret?: string; + vapidKey?: string; +} + +export const initialState: AuthenticationState = { + registeringApplication: false, + applicationRegistered: false, + authorizingUser: false, +} diff --git a/projects/mastolists/src/app/shared/state/store/effects.ts b/projects/mastolists/src/app/shared/state/store/effects.ts deleted file mode 100644 index 6ccadce..0000000 --- a/projects/mastolists/src/app/shared/state/store/effects.ts +++ /dev/null @@ -1,162 +0,0 @@ -import {Injectable} from "@angular/core"; -import {act, Actions, createEffect, ofType} from "@ngrx/effects"; -import {ListActions, MastodonApiActions} from "./actions"; -import {catchError, concat, exhaustMap, forkJoin, map, merge, of, switchMap, tap} from "rxjs"; -import {ListService} from "../../services/list.service"; -import {AccountService} from "../../services/account.service"; -import {NbToastrService} from "@nebular/theme"; -import {List} from "projects/mastodon-api/src/public-api"; - -@Injectable() -export class ApplicationEffects { - loadLists$ = createEffect(() => - this.actions$.pipe( - ofType(MastodonApiActions.loadLists), - exhaustMap(() => this.listService.loadLists().pipe( - map(lists => MastodonApiActions.listsLoaded({lists})), - catchError(() => of(MastodonApiActions.listsLoadedError())), - ) - ) - ) - ); - - updateList$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.updateList), - exhaustMap((action) => this.listService.updateList(action.listId, action.newTitle).pipe( - map((resp) => { - const list = resp as List; - return ListActions.updateListSuccess({listId: list.id, newTitle: list.title}); - }), - catchError(() => of(ListActions.updateListError)), - ) - ) - ) - ); - updateListSuccess$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.updateListSuccess), - map(() => this.toastService.success('List updated successfully', 'Success')), - ), {dispatch: false} - ); - updateListError$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.updateListError), - map(() => this.toastService.success('Could not update the list', 'Error')), - ), {dispatch: false} - ); - - createList$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.createList), - exhaustMap((action) => this.listService.createList(action.title).pipe( - map((resp) => { - const list = resp as List; - return ListActions.createListSuccess({listId: list.id, title: list.title}); - }), - catchError(() => of(ListActions.createListError())), - ) - ) - ) - ); - createListSuccess$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.createListSuccess), - map(() => this.toastService.success('List created successfully', 'Success')), - ), {dispatch: false} - ); - createListError$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.createListError), - map(() => this.toastService.success('Could not create the list', 'Error')), - ), {dispatch: false} - ); - - - listsLoaded$ = createEffect(() => - this.actions$.pipe( - ofType(MastodonApiActions.listsLoaded), - switchMap((action) => { - return forkJoin(action.lists.map(list => this.listService.loadAccountsIdsForList(list.id))) - .pipe( - map(mappings => { - const accountsInList: { [id: string]: string[] } = mappings.reduce((acc, mapping) => { - return {...acc, ...mapping}; - }, {}); - return MastodonApiActions.accountIdsForListsLoaded({mappings: accountsInList}) - } - ), - ); - } - ) - ) - ); - - loadFollowings$ = createEffect(() => - this.actions$.pipe( - ofType(MastodonApiActions.loadFollowings), - exhaustMap(() => this.accountService.loadFollowings().pipe( - map(followings => MastodonApiActions.followingsLoaded({followings})), - catchError((error) => of(MastodonApiActions.followingsLoadedError({error}))), - ) - ) - ) - ); - - addAccountToList$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.addAccountToList), - exhaustMap((action) => this.listService.addAccountToSelectedList(action.accountId, action.listId).pipe( - map(() => ListActions.addAccountToListSuccess()), - catchError(() => of(ListActions.addAccountToListError())), - ) - ) - ) - ); - - addAccountToListSuccess$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.addAccountToListSuccess), - map(() => this.toastService.success('Account added to list', 'Success')), - ), {dispatch: false} - ); - - addCountsToListError$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.addAccountToListError), - map(() => this.toastService.danger('Could not add account to list, please reload', 'Error')), - ), {dispatch: false} - ); - - removeAccountFromList$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.removeAccountFromList), - exhaustMap((action) => this.listService.removeAccountFromList(action.accountId, action.listId).pipe( - map(() => ListActions.removeAccountFromListSuccess()), - catchError(() => of(ListActions.removeAccountFromListError())), - ) - ) - ) - ); - removeAccountFromListSuccess$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.removeAccountFromListSuccess), - map(() => this.toastService.success('Account removed from list', 'Success')), - ), {dispatch: false} - ); - - removeAccountFromListError$ = createEffect(() => - this.actions$.pipe( - ofType(ListActions.removeAccountFromListError), - map(() => this.toastService.danger('Could not remove account from list, please reload', 'Error')), - ), {dispatch: false} - ); - - constructor( - private actions$: Actions, - private listService: ListService, - private accountService: AccountService, - private toastService: NbToastrService, - ) { - } -} diff --git a/projects/mastolists/src/app/shared/state/store/effects/application-state.effects.ts b/projects/mastolists/src/app/shared/state/store/effects/application-state.effects.ts new file mode 100644 index 0000000..4eb32c2 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/effects/application-state.effects.ts @@ -0,0 +1,180 @@ +import {Injectable} from "@angular/core"; +import {Actions, createEffect, ofType} from "@ngrx/effects"; +import {catchError, exhaustMap, map, of, switchMap, tap, zip} from "rxjs"; +import {NbToastrService} from "@nebular/theme"; +import {List} from "projects/mastodon-api/src/public-api"; +import {fromApplication, fromListsPage, fromListViewPage, fromMastodonApi, fromMatrixViewPage, fromSyncPage, fromTableViewPage} from "../actions"; +import {ListService} from "../../../services/list.service"; +import {AccountService} from "../../../services/account.service"; + +@Injectable() +export class ApplicationStateEffects { + loadLists$ = createEffect(() => + this.actions$.pipe( + ofType(fromApplication.loadLists, fromSyncPage.loadLists), + exhaustMap(() => this.listService.loadLists().pipe( + map(lists => fromMastodonApi.listsLoadedSuccess({lists})), + catchError(() => of(fromMastodonApi.listsLoadedError())), + ) + ) + ) + ); + + updateList$ = createEffect(() => + this.actions$.pipe( + ofType(fromListsPage.updateList), + exhaustMap((action) => this.listService.updateList(action.listId, action.newTitle).pipe( + map((resp) => { + const list = resp as List; + return fromMastodonApi.updateListSuccess({listId: list.id, newTitle: list.title}); + }), + catchError(() => of(fromMastodonApi.updateListError)), + ) + ) + ) + ); + updateListSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.updateListSuccess), + map(() => this.toastService.success('List updated successfully', 'Success')), + ), {dispatch: false} + ); + updateListError$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.updateListError), + map(() => this.toastService.danger('Could not update the list', 'Error')), + ), {dispatch: false} + ); + + createList$ = createEffect(() => + this.actions$.pipe( + ofType(fromListsPage.createList), + exhaustMap((action) => this.listService.createList(action.title).pipe( + map((resp) => { + const list = resp as List; + return fromMastodonApi.createListSuccess({listId: list.id, title: list.title}); + }), + catchError(() => of(fromMastodonApi.createListError())), + ) + ) + ) + ); + createListSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.createListSuccess), + map(() => this.toastService.success('List created successfully', 'Success')), + ), {dispatch: false} + ); + createListError$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.createListError), + map(() => this.toastService.danger('Could not create the list', 'Error')), + ), {dispatch: false} + ); + + listsLoaded$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.listsLoadedSuccess), + switchMap((action) => { + this.toastService.success('Lists loaded successfully', 'Success'); + if (action.lists.length === 0) return of(fromMastodonApi.accountIdsForListsLoadedSuccess({mappings: {}})); + const mappingsArr = action.lists.map((list) => this.listService.loadAccountsIdsForList(list.id)); + return zip(mappingsArr) + .pipe( + map(mappings => { + const accountsInList: { [id: string]: string[] } = mappings.reduce((acc, mapping) => { + return {...acc, ...mapping}; + }, {}); + return fromMastodonApi.accountIdsForListsLoadedSuccess({mappings: accountsInList}) + } + ), + ); + } + ) + ) + ); + + loadFollowings$ = createEffect(() => + this.actions$.pipe( + ofType(fromApplication.loadFollowings, fromSyncPage.loadFollowings), + switchMap(() => this.accountService.loadFollowings().pipe( + map(followings => { + return fromMastodonApi.followingsLoadedSuccess({followings}); + }), + catchError((error) => of(fromMastodonApi.followingsLoadedError({error}))), + ) + ) + ) + ); + + loadFollowingsSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.followingsLoadedSuccess), + tap(() => this.toastService.success('Followings loaded successfully', 'Success')), + ) + , {dispatch: false}); + + loadFollowingsError$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.followingsLoadedError), + tap(() => this.toastService.danger('Followings could not be loaded', 'Error')), + ) + , {dispatch: false}); + + addAccountToList$ = createEffect(() => + this.actions$.pipe( + ofType(fromListViewPage.addAccountToList, fromMatrixViewPage.addAccountToList, fromTableViewPage.addAccountToList), + exhaustMap((action) => this.listService.addAccountToSelectedList(action.accountId, action.listId).pipe( + map(() => fromMastodonApi.addAccountToListSuccess()), + catchError(() => of(fromMastodonApi.addAccountToListError())), + ) + ) + ) + ); + + addAccountToListSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.addAccountToListSuccess), + map(() => this.toastService.success('Account added to list', 'Success')), + ), {dispatch: false} + ); + + addCountsToListError$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.addAccountToListError), + map(() => this.toastService.danger('Could not add account to list, please reload', 'Error')), + ), {dispatch: false} + ); + + removeAccountFromList$ = createEffect(() => + this.actions$.pipe( + ofType(fromListViewPage.removeAccountFromList, fromMatrixViewPage.removeAccountFromList, fromTableViewPage.removeAccountFromList), + exhaustMap((action) => this.listService.removeAccountFromList(action.accountId, action.listId).pipe( + map(() => fromMastodonApi.removeAccountFromListSuccess()), + catchError(() => of(fromMastodonApi.removeAccountFromListError())), + ) + ) + ) + ); + removeAccountFromListSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.removeAccountFromListSuccess), + map(() => this.toastService.success('Account removed from list', 'Success')), + ), {dispatch: false} + ); + + removeAccountFromListError$ = createEffect(() => + this.actions$.pipe( + ofType(fromMastodonApi.removeAccountFromListError), + map(() => this.toastService.danger('Could not remove account from list, please reload', 'Error')), + ), {dispatch: false} + ); + + constructor( + private actions$: Actions, + private listService: ListService, + private accountService: AccountService, + private toastService: NbToastrService, + ) { + } +} diff --git a/projects/mastolists/src/app/shared/state/store/effects/authorization-state.effects.ts b/projects/mastolists/src/app/shared/state/store/effects/authorization-state.effects.ts new file mode 100644 index 0000000..f3a8ec0 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/effects/authorization-state.effects.ts @@ -0,0 +1,86 @@ +import {Injectable} from "@angular/core"; +import {Actions, createEffect, ofType} from "@ngrx/effects"; +import {fromAuthorize} from "../actions"; +import {catchError, exhaustMap, map, of, switchMap, tap, throwError} from "rxjs"; +import {NbToastrService} from "@nebular/theme"; +import {MastodonApiAuthenticationService, RegisteredApp} from "projects/mastodon-api/src/public-api"; +import {Store} from "@ngrx/store"; +import {environment} from "../../../../../environments/environment"; + +@Injectable() +export class AuthorizationStateEffects { + registerApplication$ = createEffect(() => + this.actions$.pipe( + ofType(fromAuthorize.registerApplication), + exhaustMap((action) => + this.mastodonApiAuthService + .createApp(action.instanceName, environment.appName, action.redirectUrl, environment.appWebsite) + .pipe( + map((registeredApp: RegisteredApp) => { + this.toastService.success('Successfully registered the application with the instance', 'Success'); + return fromAuthorize.registerApplicationSuccess({ + id: registeredApp.id, + appName: registeredApp.name, + website: registeredApp.website, + clientId: registeredApp.clientId, + clientSecret: registeredApp.clientSecret, + }) + }), + catchError((error) => { + this.toastService.danger('Could not register application. Please check the instance name.', 'Error'); + return of(fromAuthorize.registerApplicationError({error})); + }), + ) + ) + ) + ); + + getAccessToken$ = createEffect(() => + this.actions$.pipe( + ofType(fromAuthorize.getAccessToken), + switchMap((action) => + this.mastodonApiAuthService + .getAccessToken(action.instanceName!, action.clientId!, action.clientSecret!, action.redirectUrl!, action.code) + .pipe( + map(accessToken => { + return {action, accessToken}; + }) + ) + ), + switchMap((result) => + this.mastodonApiAuthService + .verifyCredentials(result.action.instanceName, result.accessToken) + .pipe( + map((accountId) => { + return {accessToken: result.accessToken, accountId}; + }) + ) + ), + map((result) => { + this.toastService.success('User successfully authenticated', 'Success'); + return fromAuthorize.getAccessTokenSuccess({accessToken: result.accessToken, accountId: result.accountId}); + }), + catchError((error) => { + console.error(error); + this.toastService.danger('Could not get access token', 'Error'); + return of(fromAuthorize.getAccessTokenError()); + }), + ) + ); + + authorizeUser$ = createEffect(() => + this.actions$.pipe( + ofType(fromAuthorize.authorizeUser), + map((action) => { + this.mastodonApiAuthService.authorizeUser(action.instanceName!, action.clientId!, action.redirectUrl!); + }) + ), {dispatch: false}); + + constructor( + private store: Store, + private actions$: Actions, + private mastodonApiAuthService: MastodonApiAuthenticationService, + private toastService: NbToastrService, + ) { + } +} diff --git a/projects/mastolists/src/app/shared/state/store/effects/index.ts b/projects/mastolists/src/app/shared/state/store/effects/index.ts new file mode 100644 index 0000000..15c9059 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/effects/index.ts @@ -0,0 +1,2 @@ +export * from './application-state.effects'; +export * from './authorization-state.effects'; diff --git a/projects/mastolists/src/app/shared/state/store/index.ts b/projects/mastolists/src/app/shared/state/store/index.ts index e69de29..d8f075c 100644 --- a/projects/mastolists/src/app/shared/state/store/index.ts +++ b/projects/mastolists/src/app/shared/state/store/index.ts @@ -0,0 +1,3 @@ +export * from "./actions/index"; +export * from "./selectors"; +export * from "./effects"; diff --git a/projects/mastolists/src/app/shared/state/store/reducers.ts b/projects/mastolists/src/app/shared/state/store/reducers.ts deleted file mode 100644 index 948c473..0000000 --- a/projects/mastolists/src/app/shared/state/store/reducers.ts +++ /dev/null @@ -1,108 +0,0 @@ -import {createReducer, on} from "@ngrx/store"; -import {Account, List} from "projects/mastodon-api/src/public-api"; -import {FiltersActions, ListActions, MastodonApiActions} from "./actions"; - -export interface Filters { - username: string; - freeText: ReadonlyArray; - lists: ReadonlyArray; - unlisted: boolean; -} - -export interface ApplicationState { - listsLoading: boolean; - listAccountsLoading: boolean; - followingsLoading: boolean; - lists: ReadonlyArray; - listsAccounts: { [listId: string]: string[] }; - followings: ReadonlyArray; - filters: Filters; -} - -export const initialState: ApplicationState = { - listsLoading: false, - listAccountsLoading: false, - followingsLoading: false, - lists: [], - listsAccounts: {}, - followings: [], - filters: { - username: '', - freeText: [], - lists: [], - unlisted: false, - } -} - -export const applicationStateReducers = createReducer( - initialState, - on(MastodonApiActions.loadLists, _state => { - return {..._state, listsLoading: true}; - }), - on(MastodonApiActions.listsLoaded, (_state, {lists}) => { - return {..._state, listsLoading: false, listAccountsLoading: true, lists: lists,}; - }), - on(MastodonApiActions.accountIdsForListsLoaded, (_state, {mappings}) => { - return {..._state, listAccountsLoading: false, listsAccounts: mappings}; - }), - on(MastodonApiActions.followingsLoaded, (_state, {followings}) => { - return {..._state, followingsLoading: false, followings}; - }), - on(MastodonApiActions.followingsLoadedError, (_state, {error}) => { - console.error(error); - return {..._state}; - }), - on(ListActions.addAccountToList, (_state, {accountId, listId}) => { - const existingAccountIds = _state.listsAccounts[listId] || []; - const newAccountIds = [...existingAccountIds, accountId]; - const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds}; - return { - ..._state, - listsAccounts: newMap, - } - }), - on(ListActions.removeAccountFromList, (_state, {accountId, listId}) => { - const existingAccountIds = _state.listsAccounts[listId] || []; - const newAccountIds = existingAccountIds.filter(id => id !== accountId); - const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds}; - return { - ..._state, - listsAccounts: newMap, - } - }), - on(FiltersActions.setUsername, (_state, {username}) => { - return {..._state, filters: {..._state.filters, username}}; - }), - on(FiltersActions.setFreeText, (_state, {freeText}) => { - return {..._state, filters: {..._state.filters, freeText}}; - }), - on(FiltersActions.setLists, (_state, {lists}) => { - return {..._state, filters: {..._state.filters, lists}}; - }), - on(FiltersActions.setUnlisted, (_state, {unlisted}) => { - let newState = {..._state, filters: {..._state.filters, unlisted}}; - if (unlisted) { - newState = {...newState, filters: {...newState.filters, lists: []}}; - } - return newState; - }), - on(FiltersActions.clearFilters, _state => { - return {..._state, filters: initialState.filters}; - }), - on(ListActions.createListSuccess, (_state, {listId, title}) => { - const newListsArray = [..._state.lists, {id: listId, title: title, repliesPolicy: 'list', accounts: []}].sort((a, b) => a.title.localeCompare(b.title)); - return { - ..._state, - listsAccounts: {..._state.listsAccounts, [listId]: []}, - lists: newListsArray, - }; - }), - on(ListActions.updateListSuccess, (_state, {listId, newTitle}) => { - const listToUpdate = _state.lists.find(list => list.id === listId) as List; - const newListsArray = [..._state.lists.filter(list => list.id !== listId), {...listToUpdate, title: newTitle}].sort((a, b) => a.title.localeCompare(b.title)); - return { - ..._state, - lists: newListsArray, - }; - }), -); diff --git a/projects/mastolists/src/app/shared/state/store/reducers/application-state.reducer.ts b/projects/mastolists/src/app/shared/state/store/reducers/application-state.reducer.ts new file mode 100644 index 0000000..5ded3e2 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/reducers/application-state.reducer.ts @@ -0,0 +1,95 @@ +import {createReducer, on} from "@ngrx/store"; +import {List} from "projects/mastodon-api/src/public-api"; +import {fromApplication, fromFilters, fromListViewPage, fromMastodonApi, fromMatrixViewPage, fromTableViewPage} from "../actions"; +import {ApplicationState, initialState} from "../application-state"; + +export const applicationStateReducer = createReducer( + initialState, + on(fromApplication.loadLists, _state => { + return {..._state, listsLoading: true}; + }), + on(fromMastodonApi.listsLoadedSuccess, (_state, {lists}) => { + return {..._state, listsLoading: false, listAccountsLoading: true, lists: lists,}; + }), + on(fromMastodonApi.accountIdsForListsLoadedSuccess, (_state, {mappings}) => { + return {..._state, listAccountsLoading: false, listsAccounts: mappings}; + }), + on(fromMastodonApi.followingsLoadedSuccess, (_state, {followings}) => { + return {..._state, followingsLoading: false, followings}; + }), + on(fromMastodonApi.followingsLoadedError, (_state, {error}) => { + console.error(error); + return {..._state}; + }), + on( + fromListViewPage.addAccountToList, + fromMatrixViewPage.addAccountToList, + fromTableViewPage.addAccountToList, + (_state, {accountId, listId}) => { + const existingAccountIds = _state.listsAccounts[listId] || []; + const newAccountIds = [...existingAccountIds, accountId]; + const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds}; + return { + ..._state, + listsAccounts: newMap, + } + }), + on( + fromListViewPage.removeAccountFromList, + fromMatrixViewPage.removeAccountFromList, + fromTableViewPage.removeAccountFromList, + (_state, {accountId, listId}) => { + const existingAccountIds = _state.listsAccounts[listId] || []; + const newAccountIds = existingAccountIds.filter(id => id !== accountId); + const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds}; + return { + ..._state, + listsAccounts: newMap, + } + }), + on( + fromFilters.setUsername, + (_state, {username}) => { + return {..._state, filters: {..._state.filters, username}}; + }), + on( + fromFilters.setFreeText, + (_state, {freeText}) => { + return {..._state, filters: {..._state.filters, freeText}}; + }), + on( + fromFilters.setLists, + (_state, {lists}) => { + return {..._state, filters: {..._state.filters, lists}}; + }), + on( + fromFilters.setUnlisted, + (_state, {unlisted}) => { + let newState = {..._state, filters: {..._state.filters, unlisted}}; + if (unlisted) { + newState = {...newState, filters: {...newState.filters, lists: []}}; + } + return newState; + }), + on( + fromFilters.clearFilters, + _state => { + return {..._state, filters: initialState.filters}; + }), + on(fromMastodonApi.createListSuccess, (_state, {listId, title}) => { + const newListsArray = [..._state.lists, {id: listId, title: title, repliesPolicy: 'list', accounts: []}].sort((a, b) => a.title.localeCompare(b.title)); + return { + ..._state, + listsAccounts: {..._state.listsAccounts, [listId]: []}, + lists: newListsArray, + }; + }), + on(fromMastodonApi.updateListSuccess, (_state, {listId, newTitle}) => { + const listToUpdate = _state.lists.find(list => list.id === listId) as List; + const newListsArray = [..._state.lists.filter(list => list.id !== listId), {...listToUpdate, title: newTitle}].sort((a, b) => a.title.localeCompare(b.title)); + return { + ..._state, + lists: newListsArray, + }; + }), +); diff --git a/projects/mastolists/src/app/shared/state/store/reducers/authentication-state.reducer.ts b/projects/mastolists/src/app/shared/state/store/reducers/authentication-state.reducer.ts new file mode 100644 index 0000000..577a11a --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/reducers/authentication-state.reducer.ts @@ -0,0 +1,59 @@ +import {createReducer, on} from "@ngrx/store"; +import {initialState} from "../authentication-state"; +import {fromAuthorize} from "../actions"; + +export const authenticationStateReducer = createReducer( + initialState, + on(fromAuthorize.registerApplication, (_state, {instanceName, redirectUrl}) => { + return { + ..._state, + registeringApplication: true, + instanceName, + redirectUrl, + }; + }), + on(fromAuthorize.registerApplicationSuccess, (_state, {id, appName, website, clientId, clientSecret}) => { + return { + ..._state, + registeringApplication: false, + applicationRegistered: true, + id, + appName, + website, + clientId, + clientSecret, + }; + }), + on(fromAuthorize.registerApplicationError, (_state) => { + return { + ..._state, + registeringApplication: false, + applicationRegistered: false, + }; + }), + on(fromAuthorize.logout, (_state) => { + return { + ...initialState, + }; + }), + on(fromAuthorize.authorizeUser, (_state) => { + return { + ..._state, + authorizingUser: true, + } + }), + on(fromAuthorize.getAccessTokenSuccess, (_state, {accessToken, accountId}) => { + return { + ..._state, + accessToken, + accountId, + authorizingUser: false, + }; + }), + on(fromAuthorize.getAccessTokenError, (_state) => { + return { + ..._state, + authorizingUser: false, + }; + }), +); diff --git a/projects/mastolists/src/app/shared/state/store/reducers/index.ts b/projects/mastolists/src/app/shared/state/store/reducers/index.ts new file mode 100644 index 0000000..d282928 --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/reducers/index.ts @@ -0,0 +1,2 @@ +export * from './application-state.reducer'; +export * from './authentication-state.reducer'; diff --git a/projects/mastolists/src/app/shared/state/store/selectors.ts b/projects/mastolists/src/app/shared/state/store/selectors/application-state.selectors.ts similarity index 92% rename from projects/mastolists/src/app/shared/state/store/selectors.ts rename to projects/mastolists/src/app/shared/state/store/selectors/application-state.selectors.ts index 76b28cf..e700a92 100644 --- a/projects/mastolists/src/app/shared/state/store/selectors.ts +++ b/projects/mastolists/src/app/shared/state/store/selectors/application-state.selectors.ts @@ -1,12 +1,11 @@ -import {ApplicationState} from "./reducers"; -import {createFeatureSelector, createSelector, State} from "@ngrx/store"; +import {createFeatureSelector, createSelector} from "@ngrx/store"; +import {ApplicationState} from "../application-state"; - -export const applicationStateFeature = createFeatureSelector('applicationState'); +export const applicationStateFeature = createFeatureSelector('application'); export const selectLists = createSelector( applicationStateFeature, - (state: ApplicationState) => state.lists + (state: ApplicationState) => [...state.lists].sort((a, b) => a.title.localeCompare(b.title)) ) export const selectFollowings = createSelector( applicationStateFeature, @@ -24,7 +23,7 @@ export const selectListsWithAccounts = createSelector( selectMappings, (lists, followings, mappings) => { return lists.map(list => { - const accountIds = mappings[list.id]; + const accountIds = mappings[list.id] || []; const accounts = followings.filter(account => accountIds.includes(account.id)); return {...list, accounts}; }); diff --git a/projects/mastolists/src/app/shared/state/store/selectors/authentication-state.selectors.ts b/projects/mastolists/src/app/shared/state/store/selectors/authentication-state.selectors.ts new file mode 100644 index 0000000..ba5c1bf --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/selectors/authentication-state.selectors.ts @@ -0,0 +1,56 @@ +import {createFeatureSelector, createSelector} from "@ngrx/store"; +import {AuthenticationState} from "../authentication-state"; + +export const authenticationStateFeature = createFeatureSelector('authentication'); + +export const selectIsLoggedIn = createSelector( + authenticationStateFeature, + (state: AuthenticationState) => state.instanceName !== undefined && state.accessToken !== undefined && state.accountId !== undefined +) + +export const selectDataForAuthorizedRequest = createSelector( + authenticationStateFeature, + (state: AuthenticationState) => { + return { + instanceName: state.instanceName, + accessToken: state.accessToken, + accountId: state.accountId, + } + } +); +export const selectDataForAuthorizationFlow = createSelector( + authenticationStateFeature, + (state: AuthenticationState) => { + return { + instanceName: state.instanceName, + clientId: state.clientId, + clientSecret: state.clientSecret, + redirectUrl: state.redirectUrl, + } + } +); + +export const selectCurrentInstance = createSelector( + authenticationStateFeature, + (state: AuthenticationState) => state.instanceName +) +export const selectCurrentInstanceWithApplicationRegisteredState = createSelector( + authenticationStateFeature, + (state: AuthenticationState) => { + return {instanceName: state.instanceName, applicationRegistered: state.applicationRegistered}; + } +) + +export const selectRegisteringApplicationFlag = createSelector( + authenticationStateFeature, + (state: AuthenticationState) => state.registeringApplication +) +export const selectApplicationRegisteredFlag = createSelector( + authenticationStateFeature, + (state: AuthenticationState) => state.applicationRegistered +) + +export const selectAuthorizingUserFlag = createSelector( + authenticationStateFeature, + (state: AuthenticationState) => state.authorizingUser +) diff --git a/projects/mastolists/src/app/shared/state/store/selectors/index.ts b/projects/mastolists/src/app/shared/state/store/selectors/index.ts new file mode 100644 index 0000000..dbfe05f --- /dev/null +++ b/projects/mastolists/src/app/shared/state/store/selectors/index.ts @@ -0,0 +1,2 @@ +export * from './application-state.selectors'; +export * from './authentication-state.selectors'; diff --git a/projects/mastolists/src/app/shared/state/store/store.ts b/projects/mastolists/src/app/shared/state/store/store.ts deleted file mode 100644 index e69de29..0000000 diff --git a/projects/mastolists/src/app/sync/sync/sync.component.html b/projects/mastolists/src/app/sync/sync/sync.component.html index f4376d0..7d3c725 100644 --- a/projects/mastolists/src/app/sync/sync/sync.component.html +++ b/projects/mastolists/src/app/sync/sync/sync.component.html @@ -39,7 +39,7 @@
-
+
Export lists using your account settings page diff --git a/projects/mastolists/src/app/sync/sync/sync.component.ts b/projects/mastolists/src/app/sync/sync/sync.component.ts index 87ec1a3..a744651 100644 --- a/projects/mastolists/src/app/sync/sync/sync.component.ts +++ b/projects/mastolists/src/app/sync/sync/sync.component.ts @@ -1,10 +1,8 @@ import {Component} from '@angular/core'; import {select, Store} from "@ngrx/store"; -import {MastodonApiActions} from "../../shared/state/store/actions"; -import {selectFollowings, selectLists, selectListsWithAccounts, selectLoading} from "../../shared/state/store/selectors"; -import {Observable, tap} from "rxjs"; +import {selectCurrentInstance, fromSyncPage, selectFollowings, selectListsWithAccounts, selectLoading} from "../../shared/state/store"; +import {Observable} from "rxjs"; import {Account, List} from 'projects/mastodon-api/src/public-api'; -import {PersistentStore} from "../../shared/state/persistent/persistent-store.service"; @Component({ selector: 'app-sync', @@ -15,18 +13,18 @@ export class SyncComponent { loading$: Observable; followings$: Observable>; lists$: Observable>; - instanceName: string; + instanceName$: Observable; - constructor(private store: Store, private persistentStore: PersistentStore) { - this.instanceName = persistentStore.value.currentInstance.instanceName; + constructor(private store: Store) { + this.instanceName$ = this.store.pipe(select(selectCurrentInstance)); this.followings$ = this.store.pipe(select(selectFollowings)) this.lists$ = this.store.pipe(select(selectListsWithAccounts)); this.loading$ = this.store.pipe(select(selectLoading)); } loadListsAndAccounts() { - this.store.dispatch(MastodonApiActions.loadLists()); - this.store.dispatch(MastodonApiActions.loadFollowings()); + this.store.dispatch(fromSyncPage.loadLists()); + this.store.dispatch(fromSyncPage.loadFollowings()); } } diff --git a/projects/mastolists/src/environments/environment.prod.ts b/projects/mastolists/src/environments/environment.prod.ts new file mode 100644 index 0000000..2d6b117 --- /dev/null +++ b/projects/mastolists/src/environments/environment.prod.ts @@ -0,0 +1,6 @@ +export const environment = { + production: true, + defaultInstance: 'mastodon.social', + appName: 'Mastolists', + appWebsite: 'https://mastolists.novaloop.cloud', +} diff --git a/projects/mastolists/src/environments/environment.staging.ts b/projects/mastolists/src/environments/environment.staging.ts new file mode 100644 index 0000000..12623e9 --- /dev/null +++ b/projects/mastolists/src/environments/environment.staging.ts @@ -0,0 +1,6 @@ +export const environment = { + production: false, + defaultInstance: 'novaloop.social', + appName: 'Mastolists (staging)', + appWebsite: 'https://mastolists-staging.novaloop.cloud', +} diff --git a/projects/mastolists/src/environments/environment.ts b/projects/mastolists/src/environments/environment.ts new file mode 100644 index 0000000..e74918c --- /dev/null +++ b/projects/mastolists/src/environments/environment.ts @@ -0,0 +1,6 @@ +export const environment = { + production: false, + defaultInstance: 'novaloop.social', + appName: 'Masterlists (dev)', + appWebsite: 'http://localhost:4200', +} diff --git a/projects/mastolists/tsconfig.app.json b/projects/mastolists/tsconfig.app.json index e4e0762..4410c9d 100644 --- a/projects/mastolists/tsconfig.app.json +++ b/projects/mastolists/tsconfig.app.json @@ -1,14 +1,16 @@ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "../../out-tsc/app", - "types": [] - }, - "files": [ - "src/main.ts" - ], - "include": [ - "src/**/*.d.ts" - ] + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/app", + "types": [], + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] } diff --git a/tsconfig.json b/tsconfig.json index e5b106a..65d3cfb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,44 +1,46 @@ /* To learn more about this file see: https://angular.io/config/tsconfig. */ { - "compileOnSave": false, - "compilerOptions": { - "baseUrl": "./", - "outDir": "./dist/out-tsc", - "forceConsistentCasingInFileNames": true, - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "paths": { - "lib-mastodon": [ - "dist/lib-mastodon" - ], - "mastodon": [ - "dist/mastodon" - ], - "mastodon-api": [ - "dist/mastodon-api" - ] + "compileOnSave": false, + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/out-tsc", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "paths": { + "lib-mastodon": [ + "dist/lib-mastodon" + ], + "mastodon": [ + "dist/mastodon" + ], + "mastodon-api": [ + "dist/mastodon-api" + ] + }, + "noFallthroughCasesInSwitch": true, + "sourceMap": true, + "declaration": false, + "downlevelIteration": true, + "experimentalDecorators": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] }, - "noFallthroughCasesInSwitch": true, - "sourceMap": true, - "declaration": false, - "downlevelIteration": true, - "experimentalDecorators": true, - "moduleResolution": "node", - "importHelpers": true, - "target": "ES2022", - "module": "ES2022", - "useDefineForClassFields": false, - "lib": [ - "ES2022", - "dom" - ] - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - } + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } }
Id Title