feat: adds list editor

feat: adds list editor
This commit is contained in:
2022-12-23 15:15:11 +01:00
parent 08dcfd179b
commit 74e7d8c9e7
36 changed files with 389 additions and 182 deletions

View File

@@ -10,6 +10,10 @@ trim_trailing_whitespace = true
[*.ts] [*.ts]
quote_type = single quote_type = single
max_line_length = 180
[*.html]
max_line_length = 180
[*.md] [*.md]
max_line_length = off max_line_length = off

View File

@@ -75,4 +75,16 @@ export class MastodonApiListsService {
return this.mastodonApiService return this.mastodonApiService
.deleteAuthenticated(url, {account_ids: [accountId]}, accessToken); .deleteAuthenticated(url, {account_ids: [accountId]}, accessToken);
} }
updateList(instanceName: string, accessToken: string, listId: string, newTitle: string) {
const url = `https://${instanceName}/api/v1/lists/${listId}`;
return this.mastodonApiService
.putAuthenticated(url, {title: newTitle}, accessToken);
}
createList(instanceName: string, accessToken: string, title: string) {
const url = `https://${instanceName}/api/v1/lists`;
return this.mastodonApiService
.postAuthenticated(url, {title}, accessToken);
}
} }

View File

@@ -33,7 +33,7 @@ export class MastodonApiService {
return this.httpClient.post<T>(url, parameters); return this.httpClient.post<T>(url, parameters);
} }
postAuthenticated(url: string, body: { account_ids: string[] }, accessToken: string) { postAuthenticated(url: string, body: {}, accessToken: string) {
const reqHeader = new HttpHeaders({ const reqHeader = new HttpHeaders({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer ' + accessToken 'Authorization': 'Bearer ' + accessToken
@@ -41,6 +41,14 @@ export class MastodonApiService {
return this.httpClient.post(url, body, {headers: reqHeader}); return this.httpClient.post(url, body, {headers: reqHeader});
} }
putAuthenticated(url: string, body: {}, accessToken: string) {
const reqHeader = new HttpHeaders({
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + accessToken
});
return this.httpClient.put(url, body, {headers: reqHeader});
}
deleteAuthenticated(url: string, body: { account_ids: string[] }, accessToken: string) { deleteAuthenticated(url: string, body: { account_ids: string[] }, accessToken: string) {
const reqHeader = new HttpHeaders({ const reqHeader = new HttpHeaders({
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -8,6 +8,18 @@ const routes: Routes = [
redirectTo: 'home', redirectTo: 'home',
pathMatch: 'full' pathMatch: 'full'
}, },
{
path: 'list',
redirectTo: 'followings/list',
},
{
path: 'matrix',
redirectTo: 'followings/matrix',
},
{
path: 'table',
redirectTo: 'followings/table',
},
{ {
path: 'home', path: 'home',
loadChildren: () => import('./home/home.module').then(m => m.HomeModule), loadChildren: () => import('./home/home.module').then(m => m.HomeModule),
@@ -22,20 +34,16 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{ {
path: 'list', path: 'lists',
loadChildren: () => import('./followings-list/followings-list.module').then(m => m.FollowingsListModule), loadChildren: () => import('./lists/lists.module').then(m => m.ListsModule),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{ {
path: 'matrix', path: 'followings',
loadChildren: () => import('./followings-matrix/followings-matrix.module').then(m => m.FollowingsMatrixModule), loadChildren: () => import('./followings/followings.module').then(m => m.FollowingsModule),
canActivate: [AuthGuard],
},
{
path: 'table',
loadChildren: () => import('./followings-table/followings-table.module').then(m => m.FollowingsTableModule),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
]; ];
@NgModule({ @NgModule({

View File

@@ -14,9 +14,10 @@ export class AppComponent {
navigationItems = [ navigationItems = [
{title: 'Authorize', link: '/auth'}, {title: 'Authorize', link: '/auth'},
{title: 'Stats', link: '/sync'}, {title: 'Stats', link: '/sync'},
{title: 'List view', link: '/list'}, {title: 'Edit Lists', link: '/lists'},
{title: 'Matrix View', link: '/matrix'}, {title: 'List view', link: '/followings/list'},
{title: 'Table View', link: '/table'}, {title: 'Matrix View', link: '/followings/matrix'},
{title: 'Table View', link: '/followings/table'},
]; ];
constructor(private store: Store, private persistentStore: PersistentStore) { constructor(private store: Store, private persistentStore: PersistentStore) {

View File

@@ -1,17 +0,0 @@
import {RouterModule, Routes} from "@angular/router";
import {NgModule} from "@angular/core";
import {ListComponent} from "./list/list.component";
const routes: Routes = [
{
path: '',
component: ListComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FollowingsListRoutingModule {
}

View File

@@ -1,23 +0,0 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ListComponent} from './list/list.component';
import {SharedModule} from '../shared/shared.module';
import {FollowingsListRoutingModule} from './followings-list-routing.module';
import {NbListModule, NbToggleModule} from "@nebular/theme";
@NgModule({
declarations: [
ListComponent
],
imports: [
CommonModule,
SharedModule,
FollowingsListRoutingModule,
// Nebula
NbListModule,
NbToggleModule,
]
})
export class FollowingsListModule {
}

View File

@@ -1,17 +0,0 @@
import {RouterModule, Routes} from "@angular/router";
import {NgModule} from "@angular/core";
import {MatrixComponent} from "./matrix/matrix.component";
const routes: Routes = [
{
path: '',
component: MatrixComponent,
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FollowingsMatrixRoutingModule {
}

View File

@@ -1,25 +0,0 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {MatrixComponent} from './matrix/matrix.component';
import {FollowingsMatrixRoutingModule} from './followings-matrix-routing.module';
import {SharedModule} from "../shared/shared.module";
import {NbBadgeModule, NbCheckboxModule, NbPopoverModule} from "@nebular/theme";
@NgModule({
declarations: [
MatrixComponent
],
imports: [
CommonModule,
SharedModule,
FollowingsMatrixRoutingModule,
// Nebular
NbCheckboxModule,
NbBadgeModule,
NbPopoverModule,
]
})
export class FollowingsMatrixModule {
}

View File

@@ -1,22 +0,0 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {TableComponent} from './table/table.component';
import {SharedModule} from "../shared/shared.module";
import {FollowingsTableRoutingModule} from "./followings-table-routing.module";
import {NbUserModule} from "@nebular/theme";
@NgModule({
declarations: [
TableComponent
],
imports: [
CommonModule,
SharedModule,
FollowingsTableRoutingModule,
// Nebular
NbUserModule,
]
})
export class FollowingsTableModule {
}

View File

@@ -0,0 +1,27 @@
import {RouterModule, Routes} from "@angular/router";
import {NgModule} from "@angular/core";
import {ListComponent} from "./list/list.component";
import {MatrixComponent} from "./matrix/matrix.component";
import {TableComponent} from "./table/table.component";
const routes: Routes = [
{
path: 'list',
component: ListComponent,
},
{
path: 'matrix',
component: MatrixComponent,
},
{
path: 'table',
component: TableComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FollowingsRoutingModule {
}

View File

@@ -0,0 +1,31 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {SharedModule} from "../shared/shared.module";
import {FollowingsRoutingModule} from "./followings-list-routing.module";
import {NbBadgeModule, NbCheckboxModule, NbIconModule, NbListModule, NbPopoverModule, NbToggleModule, NbUserModule} from "@nebular/theme";
import {ListComponent} from "./list/list.component";
import {TableComponent} from "./table/table.component";
import {MatrixComponent} from "./matrix/matrix.component";
@NgModule({
declarations: [
ListComponent,
TableComponent,
MatrixComponent,
],
imports: [
CommonModule,
SharedModule,
FollowingsRoutingModule,
// Nebula
NbCheckboxModule,
NbListModule,
NbToggleModule,
NbBadgeModule,
NbPopoverModule,
NbUserModule,
]
})
export class FollowingsModule {
}

View File

@@ -8,8 +8,8 @@
<ul> <ul>
<li><a [routerLink]="['/auth']">Authorize</a> with your instance</li> <li><a [routerLink]="['/auth']">Authorize</a> with your instance</li>
<li><a [routerLink]="['/sync']">Sync</a> your lists and followings to local storage</li> <li><a [routerLink]="['/sync']">Sync</a> your lists and followings to local storage</li>
<li>Select the <a [routerLink]="['/list']">List view</a> to add and remove users from lists</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]="['/matrix']">Matrix view</a></li> <li>... or use the experimental <a [routerLink]="['/followings/matrix']">Matrix view</a></li>
</ul> </ul>
</nb-card-body> </nb-card-body>
</nb-card> </nb-card>

View File

@@ -1,11 +1,11 @@
import {RouterModule, Routes} from "@angular/router"; import {RouterModule, Routes} from "@angular/router";
import {NgModule} from "@angular/core"; import {NgModule} from "@angular/core";
import {TableComponent} from "./table/table.component"; import {ListsComponent} from "./lists/lists.component";
const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
component: TableComponent component: ListsComponent
} }
]; ];
@@ -13,5 +13,5 @@ const routes: Routes = [
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],
exports: [RouterModule] exports: [RouterModule]
}) })
export class FollowingsTableRoutingModule { export class ListsRoutingModule {
} }

View File

@@ -0,0 +1,24 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ListsComponent} from './lists/lists.component';
import {SharedModule} from "../shared/shared.module";
import {ListsRoutingModule} from "./lists-routing.module";
import {NbSpinnerModule} from "@nebular/theme";
import {ReactiveFormsModule} from "@angular/forms";
@NgModule({
declarations: [
ListsComponent
],
imports: [
CommonModule,
SharedModule,
ListsRoutingModule,
ReactiveFormsModule,
// Nebular
NbSpinnerModule,
]
})
export class ListsModule {
}

View File

@@ -0,0 +1,31 @@
<nb-card>
<nb-card-header>
<h1>Lists</h1>
<div class="divider"></div>
<div [formGroup]="newListForm">
<input nbInput id="title" type="text" [formControlName]="'title'" style="margin-right: 20px;">
<button class="new-list-button" nbButton type="button"
[nbSpinner]="creatingList"
(click)="createList()">Create List
</button>
</div>
</nb-card-header>
<nb-card-body>
<table>
<tr>
<th>Id</th>
<th>Title</th>
</tr>
<tr *ngFor="let list of lists$ | async">
<td>
<a [href]="'https://' + instanceName + '/lists/' + list.id" target="_blank" rel="noopener">
{{list.id}}
</a>
</td>
<td>
<input nbInput id="serverUrl" type="text" [value]="list.title" (change)="listNameChanged(list.id, $event)">
</td>
</tr>
</table>
</nb-card-body>
</nb-card>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ListsComponent } from './lists.component';
describe('ListsComponent', () => {
let component: ListsComponent;
let fixture: ComponentFixture<ListsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ListsComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(ListsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,43 @@
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 {FormControl, FormGroup} from "@angular/forms";
@Component({
selector: 'app-lists',
templateUrl: './lists.component.html',
styleUrls: ['./lists.component.scss']
})
export class ListsComponent {
lists$: Observable<ReadonlyArray<List>>;
instanceName: string;
creatingList: boolean = false;
newListForm = new FormGroup({
title: new FormControl(''),
});
constructor(private store: Store, private persistentStore: PersistentStore) {
this.instanceName = persistentStore.value.currentInstance.instanceName;
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}));
}
createList() {
let title = this.newListForm.value.title ?? '';
if (title.length > 0) {
this.store.dispatch(ListActions.createList({title}));
this.newListForm.reset();
}
}
}

View File

@@ -23,6 +23,30 @@ export class ListService {
.pipe(map(result => result.sort((a, b) => a.title.localeCompare(b.title)))); .pipe(map(result => result.sort((a, b) => a.title.localeCompare(b.title))));
} }
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),
);
}
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),
);
}
loadAccountsIdsForList(listId: string): Observable<{ [id: string]: string[] }> { loadAccountsIdsForList(listId: string): Observable<{ [id: string]: string[] }> {
const applicationState = this.store.value; const applicationState = this.store.value;
const instanceName = applicationState.currentInstance?.instanceName; const instanceName = applicationState.currentInstance?.instanceName;
@@ -74,6 +98,5 @@ export class ListService {
take(1), take(1),
) )
} }
} }

View File

@@ -25,6 +25,12 @@ export const ListActions = createActionGroup({
'Remove Account from List': props<{ accountId: string, listId: string }>(), 'Remove Account from List': props<{ accountId: string, listId: string }>(),
'Remove Account from List Success': emptyProps(), 'Remove Account from List Success': emptyProps(),
'Remove Account from List Error': 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(),
}, },
}); });

View File

@@ -1,10 +1,11 @@
import {Injectable} from "@angular/core"; import {Injectable} from "@angular/core";
import {act, Actions, createEffect, ofType} from "@ngrx/effects"; import {act, Actions, createEffect, ofType} from "@ngrx/effects";
import {ListActions, MastodonApiActions} from "./actions"; import {ListActions, MastodonApiActions} from "./actions";
import {catchError, concat, exhaustMap, forkJoin, map, merge, of, switchMap} from "rxjs"; import {catchError, concat, exhaustMap, forkJoin, map, merge, of, switchMap, tap} from "rxjs";
import {ListService} from "../../services/list.service"; import {ListService} from "../../services/list.service";
import {AccountService} from "../../services/account.service"; import {AccountService} from "../../services/account.service";
import {NbToastrService} from "@nebular/theme"; import {NbToastrService} from "@nebular/theme";
import {List} from "projects/mastodon-api/src/public-api";
@Injectable() @Injectable()
export class ApplicationEffects { export class ApplicationEffects {
@@ -19,6 +20,59 @@ export class ApplicationEffects {
) )
); );
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(() => listsLoaded$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(MastodonApiActions.listsLoaded), ofType(MastodonApiActions.listsLoaded),

View File

@@ -35,59 +35,74 @@ export const initialState: ApplicationState = {
} }
export const applicationStateReducers = createReducer( export const applicationStateReducers = createReducer(
initialState, initialState,
on(MastodonApiActions.loadLists, _state => { on(MastodonApiActions.loadLists, _state => {
return {..._state, listsLoading: true}; return {..._state, listsLoading: true};
}), }),
on(MastodonApiActions.listsLoaded, (_state, {lists}) => { on(MastodonApiActions.listsLoaded, (_state, {lists}) => {
return {..._state, listsLoading: false, listAccountsLoading: true, lists: lists,}; return {..._state, listsLoading: false, listAccountsLoading: true, lists: lists,};
}), }),
on(MastodonApiActions.accountIdsForListsLoaded, (_state, {mappings}) => { on(MastodonApiActions.accountIdsForListsLoaded, (_state, {mappings}) => {
return {..._state, listAccountsLoading: false, listsAccounts: mappings}; return {..._state, listAccountsLoading: false, listsAccounts: mappings};
}), }),
on(MastodonApiActions.followingsLoaded, (_state, {followings}) => { on(MastodonApiActions.followingsLoaded, (_state, {followings}) => {
return {..._state, followingsLoading: false, followings}; return {..._state, followingsLoading: false, followings};
}), }),
on(MastodonApiActions.followingsLoadedError, (_state, {error}) => { on(MastodonApiActions.followingsLoadedError, (_state, {error}) => {
console.error(error); console.error(error);
return {..._state}; return {..._state};
}), }),
on(ListActions.addAccountToList, (_state, {accountId, listId}) => { on(ListActions.addAccountToList, (_state, {accountId, listId}) => {
const existingAccountIds = _state.listsAccounts[listId] || []; const existingAccountIds = _state.listsAccounts[listId] || [];
const newAccountIds = [...existingAccountIds, accountId]; const newAccountIds = [...existingAccountIds, accountId];
const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds}; const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds};
return <ApplicationState>{ return <ApplicationState>{
..._state, ..._state,
listsAccounts: newMap, listsAccounts: newMap,
} }
}), }),
on(ListActions.removeAccountFromList, (_state, {accountId, listId}) => { on(ListActions.removeAccountFromList, (_state, {accountId, listId}) => {
const existingAccountIds = _state.listsAccounts[listId] || []; const existingAccountIds = _state.listsAccounts[listId] || [];
const newAccountIds = existingAccountIds.filter(id => id !== accountId); const newAccountIds = existingAccountIds.filter(id => id !== accountId);
const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds}; const newMap: { [listId: string]: string[] } = {..._state.listsAccounts, [listId]: newAccountIds};
return <ApplicationState>{ return <ApplicationState>{
..._state, ..._state,
listsAccounts: newMap, listsAccounts: newMap,
} }
}), }),
on(FiltersActions.setUsername, (_state, {username}) => { on(FiltersActions.setUsername, (_state, {username}) => {
return {..._state, filters: {..._state.filters, username}}; return {..._state, filters: {..._state.filters, username}};
}), }),
on(FiltersActions.setFreeText, (_state, {freeText}) => { on(FiltersActions.setFreeText, (_state, {freeText}) => {
return {..._state, filters: {..._state.filters, freeText}}; return {..._state, filters: {..._state.filters, freeText}};
}), }),
on(FiltersActions.setLists, (_state, {lists}) => { on(FiltersActions.setLists, (_state, {lists}) => {
return {..._state, filters: {..._state.filters, lists}}; return {..._state, filters: {..._state.filters, lists}};
}), }),
on(FiltersActions.setUnlisted, (_state, {unlisted}) => { on(FiltersActions.setUnlisted, (_state, {unlisted}) => {
let newState = {..._state, filters: {..._state.filters, unlisted}}; let newState = {..._state, filters: {..._state.filters, unlisted}};
if (unlisted) { if (unlisted) {
newState = {...newState, filters: {...newState.filters, lists: []}}; newState = {...newState, filters: {...newState.filters, lists: []}};
} }
return newState; return newState;
}), }),
on(FiltersActions.clearFilters, _state => { on(FiltersActions.clearFilters, _state => {
return {..._state, filters: initialState.filters}; 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

@@ -40,8 +40,9 @@
<div *ngIf="(lists$ | async) && (followings$ | async)"> <div *ngIf="(lists$ | async) && (followings$ | async)">
<p>Now you can:</p> <p>Now you can:</p>
<ul> <ul>
<li>Select the <a [routerLink]="['/list']">List view</a> to add and remove users from lists</li> <li>Edit the lists <a [routerLink]="['/lists']">Lists</a></li>
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></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>
</ul> </ul>
</div> </div>
</nb-card-footer> </nb-card-footer>