feat: adds authentication to ngrx store

This commit is contained in:
2023-01-05 18:05:43 +01:00
parent fb57344b1b
commit 9f09aae467
59 changed files with 1152 additions and 848 deletions

View File

@@ -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"

View File

@@ -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"
}
}
}
}

View File

@@ -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<RegisterAppResponse>(url, parameters)
.pipe(map((response) => {
return <RegisteredApp>{
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 <RegisteredApp>{
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) {

View File

@@ -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),
},
{

View File

@@ -6,7 +6,7 @@
<a [routerLink]="['/']">
<img height="40px" src="assets/images/novaloop.png" alt="Novaloop favicon">
</a>
<h2>Mastolists</h2>
<h2>{{appName}}</h2>
</div>
<!--add class button-responsive to the button-->
<button nbButton ghost class="button-responsive" [nbContextMenu]="navigationItems">
@@ -33,6 +33,7 @@
Novaloop AG<br/>
Niederdorfstrasse 88<br/>
8001 Zürich<br/>
<span class="version-number">v{{version}}</span>
</div>
<div class="col col-center">
<div class="mastodon-link">

View File

@@ -12,6 +12,10 @@
}
}
span.version-number {
color: darkgray;
}
img.mastodon-logo {
width: 15px;
margin-right: 10px;

View File

@@ -1,7 +1,10 @@
import {Component, isDevMode} from '@angular/core';
import {Store} from "@ngrx/store";
import {MastodonApiActions} from "./shared/state/store/actions";
import {PersistentStore} from "./shared/state/persistent/persistent-store.service";
import {select, Store} from "@ngrx/store";
import {fromApplication, fromAuthorize, selectIsLoggedIn} from "./shared/state/store";
import {environment} from "../environments/environment";
// @ts-ignore
import packageJson from '../../../../package.json';
import {tap} from "rxjs";
@Component({
selector: 'app-root',
@@ -9,10 +12,11 @@ import {PersistentStore} from "./shared/state/persistent/persistent-store.servic
styleUrls: ['./app.component.scss']
})
export class AppComponent {
title = 'mastolists';
appName = environment.appName;
version = packageJson.version;
navigationItems = [
{title: 'Authorize', link: '/auth'},
{title: 'Authorize', link: '/authorize'},
{title: 'Stats', link: '/sync'},
{title: 'Edit Lists', link: '/lists'},
{title: 'List view', link: '/followings/list'},
@@ -20,10 +24,15 @@ export class AppComponent {
{title: 'Table View', link: '/followings/table'},
];
constructor(private store: Store, private persistentStore: PersistentStore) {
if (this.persistentStore.isAuthorized() && !isDevMode()) {
this.store.dispatch(MastodonApiActions.loadLists());
this.store.dispatch(MastodonApiActions.loadFollowings());
}
constructor(private store: Store) {
this.store.dispatch(fromAuthorize.loadLocalStorage());
this.store.pipe(
select(selectIsLoggedIn),
).subscribe((loggedIn) => {
if (loggedIn && !isDevMode()) {
this.store.dispatch(fromApplication.loadLists());
this.store.dispatch(fromApplication.loadFollowings());
}
});
}
}

View File

@@ -6,23 +6,25 @@ import {AppComponent} from './app.component';
import {MastodonApiModule} from "../../../mastodon-api/src/lib/mastodon-api.module";
import {
NbActionsModule,
NbContextMenuModule, NbDialogModule,
NbContextMenuModule,
NbDialogModule,
NbGlobalPhysicalPosition,
NbLayoutModule,
NbMenuModule, NbProgressBarModule,
NbMenuModule,
NbThemeModule,
NbToastrModule
} from "@nebular/theme";
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {SharedModule} from './shared/shared.module';
import {NbEvaIconsModule} from "@nebular/eva-icons";
import {StoreModule} from '@ngrx/store';
import {ActionReducer, StoreModule} from '@ngrx/store';
import {StoreDevtoolsModule} from "@ngrx/store-devtools";
import {applicationStateReducers} from "./shared/state/store/reducers";
import {applicationStateReducer, authenticationStateReducer} from "./shared/state/store/reducers";
import {EffectsModule} from "@ngrx/effects";
import {ApplicationEffects} from "./shared/state/store/effects";
import {NgProgressModule} from "ngx-progressbar";
import {NgProgressHttpModule} from "ngx-progressbar/http";
import {AuthorizationModule} from "./authorization/authorization.module";
import {ApplicationStateEffects, AuthorizationStateEffects, fromAuthorize} from "./shared/state/store";
const toastrConfig = {
duration: 3000,
@@ -31,6 +33,28 @@ const toastrConfig = {
destroyByClick: true,
};
export function handlePersistentState(reducer: ActionReducer<any>): ActionReducer<any> {
return function (state, action) {
let nextState = reducer(state, action);
if (action.type === fromAuthorize.logout.type) {
localStorage.removeItem('authenticationState');
}
if (
action.type === fromAuthorize.registerApplicationSuccess.type ||
action.type === fromAuthorize.getAccessTokenSuccess.type
) {
localStorage.setItem('authenticationState', JSON.stringify(nextState));
}
if (action.type === fromAuthorize.loadLocalStorage.type) {
const savedState = JSON.parse(localStorage.getItem('authenticationState') || '{}');
nextState = {...nextState, ...savedState};
}
return nextState;
}
}
export const metaReducers = [handlePersistentState];
@NgModule({
bootstrap: [AppComponent],
declarations: [
@@ -41,9 +65,13 @@ const toastrConfig = {
BrowserAnimationsModule,
AppRoutingModule,
MastodonApiModule.forRoot(),
AuthorizationModule.forRoot(),
StoreModule.forRoot({}),
StoreModule.forFeature('applicationState', applicationStateReducers),
EffectsModule.forRoot(ApplicationEffects),
StoreModule.forFeature('authentication', authenticationStateReducer, {metaReducers}),
StoreModule.forFeature('application', applicationStateReducer),
EffectsModule.forRoot(),
EffectsModule.forFeature(ApplicationStateEffects),
EffectsModule.forFeature(AuthorizationStateEffects),
StoreDevtoolsModule.instrument({
maxAge: 25,
logOnly: false,

View File

@@ -1,10 +1,11 @@
import {NgModule} from '@angular/core';
import {ModuleWithProviders, NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AuthorizationRoutingModule} from "./authorization-routing.module";
import {AuthorizeComponent} from './authorize/authorize.component';
import {ReactiveFormsModule} from '@angular/forms';
import {SharedModule} from "../shared/shared.module";
import {NbSpinnerModule} from "@nebular/theme";
import {AuthGuard} from "./guards/auth.guard";
@NgModule({
declarations: [
@@ -20,4 +21,12 @@ import {NbSpinnerModule} from "@nebular/theme";
]
})
export class AuthorizationModule {
static forRoot(): ModuleWithProviders<AuthorizationModule> {
return {
ngModule: AuthorizationModule,
providers: [
AuthGuard,
]
};
}
}

View File

@@ -4,15 +4,27 @@
</nb-card-header>
<nb-card-body>
<div [formGroup]="serverForm">
<input nbInput id="serverUrl" type="text" placeholder="mastodon.social" [formControlName]="'instance'">
<button class="authorize-button" nbButton type="button"
[nbSpinner]="authorizing"
(click)="registerApplication()">Authorize
<input nbInput id="serverUrl" type="text" placeholder="mastodon.social" [formControlName]="'instance'" (keydown.enter)="registerApplication()">
<button class="button" nbButton type="button"
[nbSpinner]="(registeringApplication$ | async)!"
[disabled]="serverForm.invalid || (applicationRegistered$ | async) === true || (registeringApplication$ | async)"
(click)="registerApplication()">Register Application
</button>
<button class="button" nbButton type="button"
[nbSpinner]="(authorizingUser$ | async)!"
[disabled]="(instanceName$ | async) === undefined || !(applicationRegistered$ | async) || (loggedIn$ | async)"
(click)="authorizeUser()">Authorize User
</button>
<button class="button" nbButton type="button"
[nbSpinner]="(authorizingUser$ | async)!"
[disabled]="(instanceName$ | async) === undefined || !(applicationRegistered$ | async)"
(click)="logout()">Logout
</button>
</div>
<div *ngIf="isAuthorized.value">
<p>User is authorized with {{currentInstance?.instanceName}}</p>
<p>Next up: <a routerLink="/sync">Sync</a></p>
<div *ngIf="(loggedIn$ | async) && (instanceName$ | async) as instanceName">
<p>User is authorized with {{instanceName}}</p>
<p>Next up: Wait for <a routerLink="/sync">Sync</a> and manage</p>
</div>
</nb-card-body>
</nb-card>

View File

@@ -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;
}
}

View File

@@ -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<boolean> = new BehaviorSubject<boolean>(false);
authorizing: boolean = false;
registeringApplication$: Observable<boolean>;
applicationRegistered$: Observable<boolean>;
authorizingUser$: Observable<boolean>;
loggedIn$: Observable<boolean>;
instanceName$: Observable<string | undefined>;
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 = <Instance>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<Instance>('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',
<Instance>{
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());
}
}

View File

@@ -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<boolean> {
return this.store
.pipe(
select(selectIsLoggedIn),
tap((isLoggedIn) => {
if (!isLoggedIn) {
this.router.navigate(['/authorize']);
}
}),
);
}
}

View File

@@ -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}));
}
}

View File

@@ -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}));
}
}
}

View File

@@ -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}));
}
}

View File

@@ -6,7 +6,7 @@
<p>Organize your Followings into lists.</p>
<ul>
<li><a [routerLink]="['/auth']">Authorize</a> with your instance</li>
<li><a [routerLink]="['/authorize']">Authorize</a> with your instance</li>
<li><a [routerLink]="['/sync']">Sync</a> your lists and followings to local storage</li>
<li>Select the <a [routerLink]="['/followings/list']">List view</a> to add and remove users from lists</li>
<li>... or use the experimental <a [routerLink]="['/followings/matrix']">Matrix view</a></li>

View File

@@ -11,7 +11,7 @@
</div>
</nb-card-header>
<nb-card-body>
<table>
<table *ngIf="(instanceName$ | async) as instanceName">
<tr>
<th>Id</th>
<th>Title</th>

View File

@@ -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<ReadonlyArray<List>>;
instanceName: string;
instanceName$: Observable<string | undefined>;
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();
}
}

View File

@@ -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}));
}
}

View File

@@ -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> | 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;
}
}

View File

@@ -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<ReadonlyArray<Account>> {
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;
}),

View File

@@ -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<List[]> {
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();
}
}

View File

@@ -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();
}
}

View File

@@ -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 = <State>{};
@Injectable({providedIn: 'root'})
export class PersistentStore {
private _localStorage: Storage;
private subject = new BehaviorSubject<State>(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<T>(name: string): Observable<T> {
return this.store.pipe(map(value => value ? <T>value[name as keyof State] : <T>{}));
}
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<string, Instance> | Map<string, string> | 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;
}
}

View File

@@ -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;
}

View File

@@ -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<List> }>(),
'Lists Loaded Error': emptyProps(),
'Account Ids for Lists Loaded': props<{ mappings: { [listId: string]: string[] } }>(),
'Followings Loaded': props<{ followings: ReadonlyArray<Account> }>(),
'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<string> }>(),
'Set Lists': props<{ lists: ReadonlyArray<string> }>(),
'Set Unlisted': props<{ unlisted: boolean }>(),
'Clear Filters': emptyProps(),
},
})

View File

@@ -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(),
}
});

View File

@@ -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(),
}
});

View File

@@ -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<string> }>(),
'Set Lists': props<{ lists: ReadonlyArray<string> }>(),
'Set Unlisted': props<{ unlisted: boolean }>(),
'Clear Filters': emptyProps(),
},
});

View File

@@ -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';

View File

@@ -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 }>(),
},
});

View File

@@ -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 }>(),
},
});

View File

@@ -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<List> }>(),
'Lists Loaded Error': emptyProps(),
'Account Ids for Lists Loaded Success': props<{ mappings: { [listId: string]: string[] } }>(),
'Followings Loaded Success': props<{ followings: ReadonlyArray<Account> }>(),
'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(),
}
});

View File

@@ -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 }>(),
},
});

View File

@@ -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(),
}
});

View File

@@ -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 }>(),
},
});

View File

@@ -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<string>;
lists: ReadonlyArray<string>;
unlisted: boolean;
}
export interface ApplicationState {
listsLoading: boolean;
listAccountsLoading: boolean;
followingsLoading: boolean;
lists: ReadonlyArray<List>;
listsAccounts: { [listId: string]: string[] };
followings: ReadonlyArray<Account>;
filters: Filters;
}
export const initialState: ApplicationState = {
listsLoading: false,
listAccountsLoading: false,
followingsLoading: false,
lists: [],
listsAccounts: {},
followings: [],
filters: {
username: '',
freeText: [],
lists: [],
unlisted: false,
}
}

View File

@@ -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,
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
) {
}
}

View File

@@ -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,
) {
}
}

View File

@@ -0,0 +1,2 @@
export * from './application-state.effects';
export * from './authorization-state.effects';

View File

@@ -0,0 +1,3 @@
export * from "./actions/index";
export * from "./selectors";
export * from "./effects";

View File

@@ -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<string>;
lists: ReadonlyArray<string>;
unlisted: boolean;
}
export interface ApplicationState {
listsLoading: boolean;
listAccountsLoading: boolean;
followingsLoading: boolean;
lists: ReadonlyArray<List>;
listsAccounts: { [listId: string]: string[] };
followings: ReadonlyArray<Account>;
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 <ApplicationState>{
..._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 <ApplicationState>{
..._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,
};
}),
);

View File

@@ -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 <ApplicationState>{
..._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 <ApplicationState>{
..._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,
};
}),
);

View File

@@ -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,
};
}),
);

View File

@@ -0,0 +1,2 @@
export * from './application-state.reducer';
export * from './authentication-state.reducer';

View File

@@ -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>('applicationState');
export const applicationStateFeature = createFeatureSelector<ApplicationState>('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};
});

View File

@@ -0,0 +1,56 @@
import {createFeatureSelector, createSelector} from "@ngrx/store";
import {AuthenticationState} from "../authentication-state";
export const authenticationStateFeature = createFeatureSelector<AuthenticationState>('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
)

View File

@@ -0,0 +1,2 @@
export * from './application-state.selectors';
export * from './authentication-state.selectors';

View File

@@ -39,7 +39,7 @@
<div class="divider"></div>
<div style="display:flex; flex-direction: column;">
<div style="display:flex; flex-direction: column;" *ngIf="(instanceName$ | async) as instanceName">
<a [href]="'https://' + instanceName + '/settings/export'" rel="noreferrer" target="_blank">
Export lists using your account settings page
</a>

View File

@@ -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<boolean>;
followings$: Observable<ReadonlyArray<Account>>;
lists$: Observable<ReadonlyArray<List>>;
instanceName: string;
instanceName$: Observable<string | undefined>;
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());
}
}

View File

@@ -0,0 +1,6 @@
export const environment = {
production: true,
defaultInstance: 'mastodon.social',
appName: 'Mastolists',
appWebsite: 'https://mastolists.novaloop.cloud',
}

View File

@@ -0,0 +1,6 @@
export const environment = {
production: false,
defaultInstance: 'novaloop.social',
appName: 'Mastolists (staging)',
appWebsite: 'https://mastolists-staging.novaloop.cloud',
}

View File

@@ -0,0 +1,6 @@
export const environment = {
production: false,
defaultInstance: 'novaloop.social',
appName: 'Masterlists (dev)',
appWebsite: 'http://localhost:4200',
}

View File

@@ -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"
]
}

View File

@@ -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
}
}