initial commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# MastodonApi
|
||||
|
||||
This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.0.0.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Run `ng generate component component-name --project mastodon-api` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project mastodon-api`.
|
||||
> Note: Don't forget to add `--project mastodon-api` or else it will be added to the default project in your `angular.json` file.
|
||||
|
||||
## Build
|
||||
|
||||
Run `ng build mastodon-api` to build the project. The build artifacts will be stored in the `dist/` directory.
|
||||
|
||||
## Publishing
|
||||
|
||||
After building your library with `ng build mastodon-api`, go to the dist folder `cd dist/mastodon-api` and run `npm publish`.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `ng test mastodon-api` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
|
||||
## Further help
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "../../dist/mastodon-api",
|
||||
"lib": {
|
||||
"entryFile": "src/public-api.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "mastodon-api",
|
||||
"version": "0.0.1",
|
||||
"peerDependencies": {
|
||||
"@angular/common": "^15.0.0",
|
||||
"@angular/core": "^15.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './public/public';
|
||||
@@ -0,0 +1,20 @@
|
||||
import {List} from "./list";
|
||||
|
||||
export interface Field {
|
||||
name: string;
|
||||
value: string;
|
||||
verified_at: Date;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
username: string;
|
||||
acct: string;
|
||||
url: string;
|
||||
displayName: string;
|
||||
note: string;
|
||||
avatar: string;
|
||||
avatarStatic: string;
|
||||
fields: Field[];
|
||||
lists: List[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {Account} from "./account";
|
||||
|
||||
export interface List {
|
||||
id: string;
|
||||
title: string;
|
||||
repliesPolicy: string;
|
||||
accounts: Account[]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './account';
|
||||
export * from './list';
|
||||
export * from './registered_app';
|
||||
export * from './user_account';
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface RegisteredApp{
|
||||
id: string;
|
||||
name: string;
|
||||
website: string;
|
||||
redirectUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
vapidKey: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface UserAccount {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface GetAccessTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Field {
|
||||
name: string;
|
||||
value: string;
|
||||
verified_at: Date;
|
||||
}
|
||||
export interface GetAccountResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
acct: string;
|
||||
url: string;
|
||||
display_name: string;
|
||||
note: string;
|
||||
avatar: string;
|
||||
avatar_static: string;
|
||||
fields: Field[];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {GetAccountResponse} from "./get_account_response";
|
||||
|
||||
export interface GetListsResponse {
|
||||
id: string;
|
||||
title: string;
|
||||
repliesPolicy: string;
|
||||
accounts: GetAccountResponse[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface RegisterAppResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
website: string;
|
||||
redirect_uri: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
vapid_key: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface VerifyCredentialsResponse {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {ModuleWithProviders, NgModule} from '@angular/core';
|
||||
import {MastodonApiListsService} from "./services/mastodon-api-lists.service";
|
||||
import {MastodonApiAuthenticationService} from "./services/mastodon-api-authentication.service";
|
||||
import {HttpClientModule} from "@angular/common/http";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
imports: [
|
||||
HttpClientModule
|
||||
],
|
||||
exports: []
|
||||
})
|
||||
export class MastodonApiModule {
|
||||
static forRoot(): ModuleWithProviders<MastodonApiModule> {
|
||||
return {
|
||||
ngModule: MastodonApiModule,
|
||||
providers: [MastodonApiListsService, MastodonApiAuthenticationService]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
import {MastodonApiService} from "./mastodon-api.service";
|
||||
import {map, Observable} from "rxjs";
|
||||
import {GetAccountResponse} from "../interfaces/responses/get_account_response";
|
||||
import {Account} from "../interfaces/public/account";
|
||||
import {HttpResponse} from "@angular/common/http";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MastodonApiAccountsService {
|
||||
|
||||
constructor(private mastodonApiService: MastodonApiService) {
|
||||
}
|
||||
|
||||
getFollowingsForAccount(instanceName: string, accessToken: string, accountId: string, url: string = ''): Observable<[nextLink: string, accounts: Account[]]> {
|
||||
if (url === '') {
|
||||
url = `https://${instanceName}/api/v1/accounts/${accountId}/following?limit=80`;
|
||||
}
|
||||
return this.mastodonApiService
|
||||
.getAuthenticatedWithResponseHeaders<GetAccountResponse[]>(url, accessToken)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const links = response.headers.get('link');
|
||||
let rel = '';
|
||||
let nextUrl = '';
|
||||
if (links) {
|
||||
const nextLink = links!.split(',')[0];
|
||||
rel = nextLink.split(';')[1].replace(' rel="', '').replace('"', '');
|
||||
nextUrl = nextLink.split(';')[0].replace('<', '').replace('>', '');
|
||||
}
|
||||
|
||||
const accounts = response.body!.map((response) => {
|
||||
return <Account>{
|
||||
id: response.id,
|
||||
username: response.username,
|
||||
acct: response.acct,
|
||||
displayName: response.display_name,
|
||||
note: response.note,
|
||||
url: response.url,
|
||||
avatar: response.avatar,
|
||||
avatarStatic: response.avatar_static,
|
||||
fields: response.fields,
|
||||
};
|
||||
});
|
||||
return [rel === 'next' ? nextUrl : '', accounts];
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {map, Observable} 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";
|
||||
import {MastodonApiService} from "./mastodon-api.service";
|
||||
import {RegisteredApp} from "../interfaces/public/registered_app";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MastodonApiAuthenticationService {
|
||||
|
||||
constructor(private mastodonApiService: MastodonApiService) {
|
||||
}
|
||||
|
||||
createApp(instance: string, clientName: string, redirectUrl: string, website: string): Observable<RegisteredApp> {
|
||||
const scopes = ['read', 'write']; // no 'follow'
|
||||
|
||||
const parameters: {
|
||||
client_name: string,
|
||||
redirect_uris: string,
|
||||
scopes: string,
|
||||
website: string
|
||||
} = {
|
||||
client_name: clientName,
|
||||
redirect_uris: redirectUrl,
|
||||
scopes: scopes.join(' '),
|
||||
website
|
||||
}
|
||||
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,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
authorizeUser(instance: string, clientId: string, redirectUrl: string) {
|
||||
const parameters = [
|
||||
['response_type', 'code'].join("="),
|
||||
['scope', 'read write'].join("="),
|
||||
['client_id', clientId].join("="),
|
||||
['redirect_uri', redirectUrl].join("="),
|
||||
].join("&");
|
||||
window.location.href = `https://${instance}/oauth/authorize?${parameters}`;
|
||||
}
|
||||
|
||||
verifyCredentials(instance: string, accessToken: string): Observable<string> {
|
||||
const url = `https://${instance}/api/v1/accounts/verify_credentials`;
|
||||
return this.mastodonApiService
|
||||
.getAuthenticated<VerifyCredentialsResponse>(url, accessToken)
|
||||
.pipe(map((response) => response.id));
|
||||
|
||||
}
|
||||
|
||||
getAccessToken(instanceName: string, clientId: string, clientSecret: string, redirectUrl: string, code: string): Observable<string> {
|
||||
const parameters: {
|
||||
client_id: string,
|
||||
client_secret: string,
|
||||
redirect_uri: string,
|
||||
grant_type: string,
|
||||
code: string,
|
||||
scope: string
|
||||
} = {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUrl,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
scope: 'read write'
|
||||
}
|
||||
const url = `https://${instanceName}/oauth/token`;
|
||||
return this.mastodonApiService
|
||||
.post<GetAccessTokenResponse>(url, parameters)
|
||||
.pipe(map((response) => response.access_token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {concatMap, from, map, mergeAll, mergeMap, Observable, reduce, switchMap, tap, toArray} from "rxjs";
|
||||
import {VerifyCredentialsResponse} from "../interfaces/responses/verify_credentials_response";
|
||||
import {MastodonApiService} from "./mastodon-api.service";
|
||||
import {GetListsResponse} from "../interfaces/responses/get_lists_response";
|
||||
import {GetAccountResponse} from "../interfaces/responses/get_account_response";
|
||||
import {List} from "../interfaces/public/list";
|
||||
import {Account} from "../interfaces/public/account";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MastodonApiListsService {
|
||||
|
||||
constructor(private mastodonApiService: MastodonApiService) {
|
||||
}
|
||||
|
||||
getLists(instanceName: string, accessToken: string): Observable<List[]> {
|
||||
const url = `https://${instanceName}/api/v1/lists`;
|
||||
return this.mastodonApiService
|
||||
.getAuthenticated<GetListsResponse[]>(url, accessToken)
|
||||
.pipe(map((responses) => {
|
||||
return responses.map((response) => {
|
||||
return <List>{
|
||||
id: response.id,
|
||||
title: response.title,
|
||||
repliesPolicy: response.repliesPolicy,
|
||||
}
|
||||
})
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getAccountsForList(instanceName: string, accessToken: string, listId: string, url: string = ''): Observable<[nextLink: string, accounts: Account[]]> {
|
||||
if (url === '') {
|
||||
url = `https://${instanceName}/api/v1/lists/${listId}/accounts`;
|
||||
}
|
||||
return this.mastodonApiService
|
||||
.getAuthenticatedWithResponseHeaders<GetAccountResponse[]>(url, accessToken)
|
||||
.pipe(map(response => {
|
||||
const links = response.headers.get('link');
|
||||
let rel = '';
|
||||
let nextUrl = '';
|
||||
if (links) {
|
||||
const nextLink = links!.split(',')[0];
|
||||
rel = nextLink.split(';')[1].replace(' rel="', '').replace('"', '');
|
||||
nextUrl = nextLink.split(';')[0].replace('<', '').replace('>', '');
|
||||
}
|
||||
const accounts = response!.body!.map((response) => {
|
||||
return <Account>{
|
||||
id: response.id,
|
||||
username: response.username,
|
||||
acct: response.acct,
|
||||
displayName: response.display_name,
|
||||
note: response.note,
|
||||
url: response.url,
|
||||
avatar: response.avatar,
|
||||
avatarStatic: response.avatar_static,
|
||||
fields: response.fields,
|
||||
};
|
||||
});
|
||||
return [rel === 'next' ? nextUrl : '', accounts];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
addAccountToList(instanceName: string, accessToken: string, listId: string, accountId: string) {
|
||||
const url = `https://${instanceName}/api/v1/lists/${listId}/accounts`;
|
||||
return this.mastodonApiService
|
||||
.postAuthenticated(url, {account_ids: [accountId]}, accessToken);
|
||||
}
|
||||
|
||||
removeAccountFromList(instanceName: string, accessToken: string, listId: string, accountId: string) {
|
||||
const url = `https://${instanceName}/api/v1/lists/${listId}/accounts`;
|
||||
return this.mastodonApiService
|
||||
.deleteAuthenticated(url, {account_ids: [accountId]}, accessToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
import {Observable} from "rxjs";
|
||||
import {HttpClient, HttpHeaders, HttpResponse} from "@angular/common/http";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MastodonApiService {
|
||||
constructor(private httpClient: HttpClient) {
|
||||
}
|
||||
|
||||
get<T>(url: string) {
|
||||
return this.httpClient.get<T>(url);
|
||||
}
|
||||
|
||||
getAuthenticated<T>(url: string, accessToken: string): Observable<T> {
|
||||
const reqHeader = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
});
|
||||
return this.httpClient.get<T>(url, {headers: reqHeader});
|
||||
}
|
||||
|
||||
getAuthenticatedWithResponseHeaders<T>(url: string, accessToken: string): Observable<HttpResponse<T>> {
|
||||
const reqHeader = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
});
|
||||
return this.httpClient.get<T>(url, {headers: reqHeader, observe: 'response'});
|
||||
}
|
||||
|
||||
post<T>(url: string, parameters: object) {
|
||||
return this.httpClient.post<T>(url, parameters);
|
||||
}
|
||||
|
||||
postAuthenticated(url: string, body: { account_ids: string[] }, accessToken: string) {
|
||||
const reqHeader = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
});
|
||||
return this.httpClient.post(url, body, {headers: reqHeader});
|
||||
}
|
||||
|
||||
deleteAuthenticated(url: string, body: { account_ids: string[] }, accessToken: string) {
|
||||
const reqHeader = new HttpHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
});
|
||||
return this.httpClient.delete(url, {headers: reqHeader, body});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './mastodon-api.service';
|
||||
export * from './mastodon-api-accounts.service';
|
||||
export * from './mastodon-api-authentication.service';
|
||||
export * from './mastodon-api-lists.service';
|
||||
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* Public API Surface of mastodon-api
|
||||
*/
|
||||
|
||||
export * from './lib/services/services';
|
||||
export * from './lib/interfaces/interfaces';
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/lib",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "./tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"declarationMap": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"compilationMode": "partial"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/spec",
|
||||
"types": [
|
||||
"jasmine"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {RouterModule, Routes} from '@angular/router';
|
||||
import {AuthGuard} from './shared/guards/auth.guard';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'home',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./home/home.module').then(m => m.HomeModule),
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
loadChildren: () => import('./authorization/authorization.module').then(m => m.AuthorizationModule),
|
||||
},
|
||||
{
|
||||
path: 'sync',
|
||||
loadChildren: () => import('./sync/sync.module').then(m => m.SyncModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'followings',
|
||||
loadChildren: () => import('./followings/followings.module').then(m => m.FollowingsModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'matrix',
|
||||
loadChildren: () => import('./followings-matrix/followings-matrix.module').then(m => m.FollowingsMatrixModule),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<ng-progress [spinner]="false" [debounceTime]="300" [min]="20" [color]="'#83ebd6'" [thick]="true"></ng-progress>
|
||||
<nb-layout center>
|
||||
<nb-layout-header subheader>
|
||||
<div class="navbar-container">
|
||||
<div id="logo" style="padding-right: 20px;">
|
||||
<a [routerLink]="['/']">
|
||||
<img height="40px" src="assets/images/novaloop.png" alt="Novaloop favicon">
|
||||
</a>
|
||||
</div>
|
||||
<div><h2>Mastolists</h2></div>
|
||||
<!--add class button-responsive to the button-->
|
||||
<button nbButton ghost class="button-responsive" [nbContextMenu]="navigationItems">
|
||||
<nb-icon icon="menu-outline"></nb-icon>
|
||||
</button>
|
||||
<!--add class menu-responsive to nb-actions-->
|
||||
<nb-actions class="left menu-responsive">
|
||||
<nb-action *ngFor="let item of navigationItems"
|
||||
[routerLink]="item.link"
|
||||
[title]="item.title">
|
||||
{{ item.title }}
|
||||
</nb-action>
|
||||
</nb-actions>
|
||||
</div>
|
||||
</nb-layout-header>
|
||||
|
||||
<nb-layout-column>
|
||||
<router-outlet></router-outlet>
|
||||
</nb-layout-column>
|
||||
|
||||
<nb-layout-footer>
|
||||
<div style="width: 50%">
|
||||
Novaloop AG<br/>
|
||||
Niederdorfstrasse 88<br/>
|
||||
8001 Zürich<br/>
|
||||
</div>
|
||||
<div style="width: 50%; text-align: right;">
|
||||
<a href="tel:+41 44 500 54 60">+41 44 500 54 60</a><br/>
|
||||
<a href="https://www.novaloop.ch">www.novaloop.ch</a><br/>
|
||||
<a href="mailto:mail@novaloop.ch">mail@novaloop.ch</a><br/>
|
||||
</div>
|
||||
</nb-layout-footer>
|
||||
</nb-layout>
|
||||
@@ -0,0 +1,24 @@
|
||||
.button-responsive {
|
||||
display: none
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.button-responsive {
|
||||
display: inline-block
|
||||
}
|
||||
.menu-responsive {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
|
||||
nb-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
RouterTestingModule
|
||||
],
|
||||
declarations: [
|
||||
AppComponent
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should have as title 'mastolists'`, () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app.title).toEqual('mastolists');
|
||||
});
|
||||
|
||||
it('should render title', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('.content span')?.textContent).toContain('mastolists app is running!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import {Component, isDevMode} from '@angular/core';
|
||||
import {select, Store} from "@ngrx/store";
|
||||
import {MastodonApiActions} from "./shared/state/store/actions";
|
||||
import {Observable, tap} from "rxjs";
|
||||
import {selectLoadingPercentage} from "./shared/state/store/selectors";
|
||||
import {PersistentStore} from "./shared/state/persistent/persistent-store.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss']
|
||||
})
|
||||
export class AppComponent {
|
||||
title = 'mastolists';
|
||||
|
||||
navigationItems = [
|
||||
{title: 'Authorize', link: '/auth'},
|
||||
{title: 'Stats', link: '/sync'},
|
||||
{title: 'List view', link: '/followings'},
|
||||
{title: 'Matrix View', link: '/matrix'},
|
||||
];
|
||||
|
||||
constructor(private store: Store, private persistentStore: PersistentStore) {
|
||||
if (this.persistentStore.isAuthorized() && !isDevMode()) {
|
||||
this.store.dispatch(MastodonApiActions.loadLists());
|
||||
this.store.dispatch(MastodonApiActions.loadFollowings());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {BrowserModule} from '@angular/platform-browser';
|
||||
|
||||
import {AppRoutingModule} from './app-routing.module';
|
||||
import {AppComponent} from './app.component';
|
||||
import {MastodonApiModule} from "../../../mastodon-api/src/lib/mastodon-api.module";
|
||||
import {
|
||||
NbActionsModule,
|
||||
NbContextMenuModule, NbDialogModule,
|
||||
NbGlobalPhysicalPosition,
|
||||
NbLayoutModule,
|
||||
NbMenuModule, NbProgressBarModule,
|
||||
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 {StoreDevtoolsModule} from "@ngrx/store-devtools";
|
||||
import {applicationStateReducers} 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";
|
||||
|
||||
const toastrConfig = {
|
||||
duration: 3000,
|
||||
position: NbGlobalPhysicalPosition.BOTTOM_RIGHT,
|
||||
preventDuplicates: true,
|
||||
destroyByClick: true,
|
||||
};
|
||||
|
||||
@NgModule({
|
||||
bootstrap: [AppComponent],
|
||||
declarations: [
|
||||
AppComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
AppRoutingModule,
|
||||
MastodonApiModule.forRoot(),
|
||||
StoreModule.forRoot({}),
|
||||
StoreModule.forFeature('applicationState', applicationStateReducers),
|
||||
EffectsModule.forRoot(ApplicationEffects),
|
||||
StoreDevtoolsModule.instrument({
|
||||
maxAge: 25,
|
||||
logOnly: false,
|
||||
autoPause: true,
|
||||
features: {
|
||||
pause: false,
|
||||
lock: true,
|
||||
persist: true
|
||||
}
|
||||
}),
|
||||
SharedModule,
|
||||
NgProgressModule,
|
||||
NgProgressHttpModule,
|
||||
// Nebula
|
||||
NbContextMenuModule,
|
||||
NbMenuModule.forRoot(),
|
||||
NbDialogModule.forRoot(),
|
||||
NbEvaIconsModule,
|
||||
NbActionsModule,
|
||||
NbThemeModule.forRoot({name: 'dark'}),
|
||||
NbToastrModule.forRoot(toastrConfig),
|
||||
NbLayoutModule,
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class AppModule {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {AuthorizeComponent} from "./authorize/authorize.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AuthorizeComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AuthorizationRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {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";
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AuthorizeComponent
|
||||
],
|
||||
imports: [
|
||||
AuthorizationRoutingModule,
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
ReactiveFormsModule,
|
||||
// Nebula
|
||||
NbSpinnerModule,
|
||||
]
|
||||
})
|
||||
export class AuthorizationModule {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Authorize with your instance</h1>
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
<div *ngIf="isAuthorized.value">
|
||||
<p>User is authorized with {{currentInstance?.instanceName}}</p>
|
||||
<p>Next up: <a routerLink="/sync">Sync</a></p>
|
||||
</div>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,9 @@
|
||||
.authorize-button {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.authorize-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AuthorizeComponent } from './authorize.component';
|
||||
|
||||
describe('AuthorizeComponent', () => {
|
||||
let component: AuthorizeComponent;
|
||||
let fixture: ComponentFixture<AuthorizeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ AuthorizeComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AuthorizeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-authorize',
|
||||
templateUrl: './authorize.component.html',
|
||||
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;
|
||||
|
||||
serverForm = new FormGroup({
|
||||
instance: new FormControl(''),
|
||||
});
|
||||
|
||||
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));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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 {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 {
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<nb-card>
|
||||
<nb-card-header style="position: relative;">
|
||||
<h1>Matrix View for Followings</h1>
|
||||
<div class="divider"></div>
|
||||
<nb-badge text="experimental" position="top right" status="success"></nb-badge>
|
||||
<app-filters></app-filters>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th *ngFor="let list of lists$ | async" class="list-title">
|
||||
{{list.title}}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let account of followings$ | async">
|
||||
<th>
|
||||
<ng-template #templateRef>
|
||||
<div style="padding: 20px;">
|
||||
<p [innerHTML]="account.note"></p>
|
||||
<h4 *ngIf="account.fields.length > 0">Custom fields</h4>
|
||||
<div class="table">
|
||||
<div class="row" *ngFor="let field of account.fields">
|
||||
<div class="column first">{{ field.name }}</div>
|
||||
<div class="column" [innerHTML]="field.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
<span (click)="openMoreInfo(account)"
|
||||
[nbPopover]="templateRef"
|
||||
nbPopoverTrigger="hover"
|
||||
nbPopoverPlacement="bottom"
|
||||
>
|
||||
@{{account.username}}
|
||||
</span>
|
||||
</th>
|
||||
<td *ngFor="let list of lists$ | async">
|
||||
<nb-checkbox
|
||||
[checked]="isChecked(account.lists, list.id)"
|
||||
(checkedChange)="onCheckedChange($event, account.id, list.id)">
|
||||
</nb-checkbox>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
|
||||
<ng-template #dialog let-ref="dialogRef">
|
||||
<div class="container" *ngIf="selectedAccount">
|
||||
<app-account [account]="selectedAccount">
|
||||
<div footer>
|
||||
<button nbButton style="cursor: pointer;" (click)="ref.close()">Close Infos</button>
|
||||
</div>
|
||||
</app-account>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
table {
|
||||
display: block;
|
||||
height: max(600px, calc(100vh - 300px));
|
||||
width: 1080px;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
border: none;
|
||||
|
||||
td {
|
||||
border: none;
|
||||
}
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #222b45;
|
||||
z-index: 2;
|
||||
border: none;
|
||||
}
|
||||
|
||||
thead th.list-title {
|
||||
writing-mode: vertical-lr;
|
||||
transform: rotate(180deg);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: #222b45;
|
||||
border: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
line-height: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
nb-card.more-info-panel {
|
||||
min-width: 450px;
|
||||
max-width: 800px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MatrixComponent } from './matrix.component';
|
||||
|
||||
describe('MatrixComponent', () => {
|
||||
let component: MatrixComponent;
|
||||
let fixture: ComponentFixture<MatrixComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ MatrixComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MatrixComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-matrix',
|
||||
templateUrl: './matrix.component.html',
|
||||
styleUrls: ['./matrix.component.scss']
|
||||
})
|
||||
export class MatrixComponent {
|
||||
@ViewChild('dialog', {static: true}) dialog: TemplateRef<any> | undefined;
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
selectedAccount: Account | undefined;
|
||||
|
||||
constructor(private store: Store, private dialogService: NbDialogService) {
|
||||
this.followings$ = this.store.pipe(select(selectFilteredFollowingsWithLists));
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
}
|
||||
|
||||
openMoreInfo(account: Account) {
|
||||
this.selectedAccount = account;
|
||||
this.dialogService.open(this.dialog!);
|
||||
}
|
||||
|
||||
isChecked(lists: List[], id: string): boolean {
|
||||
return lists.some(list => list.id === id);
|
||||
}
|
||||
|
||||
onCheckedChange($event: boolean, accountId: string, listId: string) {
|
||||
if ($event) {
|
||||
this.store.dispatch(ListActions.addAccountToList({accountId, listId}));
|
||||
} else {
|
||||
this.store.dispatch(ListActions.removeAccountFromList({accountId, listId}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {FollowingsComponent} from "./followings/followings.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: FollowingsComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class FollowingsRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {FollowingsComponent} from './followings/followings.component';
|
||||
import {SharedModule} from '../shared/shared.module';
|
||||
import {FollowingsRoutingModule} from './followings-routing.module';
|
||||
import {NbListModule, NbToggleModule} from "@nebular/theme";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FollowingsComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
FollowingsRoutingModule,
|
||||
// Nebula
|
||||
NbListModule,
|
||||
NbToggleModule,
|
||||
]
|
||||
})
|
||||
export class FollowingsModule {
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Followings</h1>
|
||||
<div class="divider"></div>
|
||||
<app-filters></app-filters>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<nb-list>
|
||||
<nb-list-item *ngFor="let following of followings$ | async">
|
||||
<app-account [account]="following">
|
||||
<div body>
|
||||
<div *ngIf="following.lists.length > 0">
|
||||
<h4>Lists</h4>
|
||||
<ul>
|
||||
<li *ngFor="let list of following.lists">
|
||||
<a [routerLink]="['/lists']" [queryParams]="{listId: list.id}">{{list.title}}</a>
|
||||
<nb-icon icon="trash-2-outline" class="nb-icon-remove"
|
||||
(click)="removeAccountFromList(following.id, list.id)"></nb-icon>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div footer>
|
||||
<select class="list-select" #listSelect>
|
||||
<option *ngFor="let list of lists$ | async" [value]="list.id">{{list.title}}</option>
|
||||
</select>
|
||||
<button class="add-to-list-button" nbButton
|
||||
status="basic"
|
||||
(click)="addAccountToSelectedList(following.id, listSelect)">Add to list
|
||||
</button>
|
||||
</div>
|
||||
</app-account>
|
||||
</nb-list-item>
|
||||
</nb-list>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,22 @@
|
||||
select.list-select {
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nb-icon.nb-icon-remove {
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-to-list-button {
|
||||
margin-left: 20px;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.add-to-list-button {
|
||||
margin-top: 20px;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FollowingsComponent } from './followings.component';
|
||||
|
||||
describe('FollowingsComponent', () => {
|
||||
let component: FollowingsComponent;
|
||||
let fixture: ComponentFixture<FollowingsComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FollowingsComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FollowingsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
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 {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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-followings',
|
||||
templateUrl: './followings.component.html',
|
||||
styleUrls: ['./followings.component.scss']
|
||||
})
|
||||
export class FollowingsComponent {
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.followings$ = this.store.pipe(select(selectFilteredFollowingsWithLists));
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
}
|
||||
|
||||
addAccountToSelectedList(accountId: string, select: HTMLSelectElement) {
|
||||
this.store.dispatch(ListActions.addAccountToList({accountId, listId: select.value}));
|
||||
}
|
||||
|
||||
removeAccountFromList(accountId: string, listId: string) {
|
||||
this.store.dispatch(ListActions.removeAccountFromList({accountId, listId}));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {HomeComponent} from "./home/home.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomeComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class HomeRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {HomeComponent} from "./home/home.component";
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {HomeRoutingModule} from "./home-routing.module";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
HomeComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
HomeRoutingModule,
|
||||
]
|
||||
})
|
||||
export class HomeModule {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Welcome to Mastolists</h1>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<p>Organize your Followings into lists.</p>
|
||||
|
||||
<ul>
|
||||
<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>Select the <a [routerLink]="['/followings']">List view</a> to add and remove users from lists</li>
|
||||
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></li>
|
||||
</ul>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HomeComponent } from './home.component';
|
||||
|
||||
describe('HomeComponent', () => {
|
||||
let component: HomeComponent;
|
||||
let fixture: ComponentFixture<HomeComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ HomeComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomeComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss']
|
||||
})
|
||||
export class HomeComponent {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<nb-card accent="info" class="account-card">
|
||||
<nb-card-header>
|
||||
<a [href]="account.url">
|
||||
<nb-user
|
||||
size="medium"
|
||||
shape="semi-round"
|
||||
[name]="account.displayName"
|
||||
[title]="'@' + account.username"
|
||||
[picture]="account.avatar"
|
||||
>
|
||||
</nb-user>
|
||||
</a>
|
||||
<ng-content select="'[header]'"></ng-content>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<p [innerHTML]="account.note"></p>
|
||||
<h4 *ngIf="account.fields.length > 0">Custom fields</h4>
|
||||
<div class="table">
|
||||
<div class="row" *ngFor="let field of account.fields">
|
||||
<div class="column first">{{ field.name }}</div>
|
||||
<div class="column" [innerHTML]="field.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content select="'[body]'"></ng-content>
|
||||
</nb-card-body>
|
||||
<nb-card-footer>
|
||||
<ng-content select="'[footer]'"></ng-content>
|
||||
</nb-card-footer>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,29 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td.field-label {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 30px;
|
||||
|
||||
.first {
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AccountComponent } from './account.component';
|
||||
|
||||
describe('AccountComponent', () => {
|
||||
let component: AccountComponent;
|
||||
let fixture: ComponentFixture<AccountComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ AccountComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AccountComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {Account} from 'projects/mastodon-api/src/public-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
templateUrl: './account.component.html',
|
||||
styleUrls: ['./account.component.scss']
|
||||
})
|
||||
export class AccountComponent {
|
||||
|
||||
@Input() account!: Account;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="container">
|
||||
<div class="form">
|
||||
<form [formGroup]="filtersForm">
|
||||
<input nbInput id="text" type="text"
|
||||
placeholder="@username or free text"
|
||||
[formControlName]="'text'">
|
||||
<nb-select multiple placeholder="Multiple Select" [formControlName]="'lists'">
|
||||
<nb-option *ngFor="let list of lists$ | async" [value]="list.id">{{list.title}}</nb-option>
|
||||
</nb-select>
|
||||
</form>
|
||||
<div class="nb-toggle-container">
|
||||
<nb-toggle
|
||||
[checked]="(filters$ | async)?.unlisted"
|
||||
(checkedChange)="toggleUnlisted($event)"
|
||||
>Unlisted only
|
||||
</nb-toggle>
|
||||
</div>
|
||||
<button nbButton (click)="clearFilter()">Clear Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
|
||||
input {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
nb-select {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nb-toggle-container {
|
||||
padding-top: 2px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.buttons {
|
||||
padding-top: 20px;
|
||||
|
||||
button {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 573px) {
|
||||
.form {
|
||||
flex-direction: column;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 10px;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
nb-select {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.nb-toggle-container {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { FiltersComponent } from './filters.component';
|
||||
|
||||
describe('FiltersComponent', () => {
|
||||
let component: FiltersComponent;
|
||||
let fixture: ComponentFixture<FiltersComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ FiltersComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(FiltersComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
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";
|
||||
|
||||
interface FiltersForForm {
|
||||
fullText: string | undefined;
|
||||
lists: ReadonlyArray<string>;
|
||||
unlisted: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-filters',
|
||||
templateUrl: './filters.component.html',
|
||||
styleUrls: ['./filters.component.scss']
|
||||
})
|
||||
export class FiltersComponent implements OnDestroy {
|
||||
filtersForm = new FormGroup({
|
||||
text: new FormControl(),
|
||||
lists: new FormControl([] as string[]),
|
||||
});
|
||||
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
filters$: Observable<FiltersForForm>;
|
||||
formSubscription: Subscription;
|
||||
|
||||
constructor(private store: Store) {
|
||||
this.lists$ = this.store.pipe(select(selectLists));
|
||||
this.filters$ = this.store.pipe(
|
||||
select(selectFiltersForForm),
|
||||
tap(filters => {
|
||||
this.filtersForm.patchValue({
|
||||
text: filters.fullText,
|
||||
lists: filters.lists as string[],
|
||||
}, {emitEvent: false, onlySelf: true});
|
||||
}),
|
||||
);
|
||||
this.formSubscription = this.filtersForm.valueChanges
|
||||
.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}));
|
||||
}
|
||||
if (!value['lists'] || value['lists'].length === 0) {
|
||||
this.store.dispatch(FiltersActions.setLists({lists: []}));
|
||||
}
|
||||
if (value['text']) {
|
||||
const text = value['text'];
|
||||
if (text.length > 0) {
|
||||
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)}));
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
}
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: parts.filter((part: string) => !part.startsWith('@'))}));
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: []}));
|
||||
}
|
||||
} else {
|
||||
this.store.dispatch(FiltersActions.setUsername({username: ''}));
|
||||
this.store.dispatch(FiltersActions.setFreeText({freeText: []}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.formSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
clearFilter() {
|
||||
this.store.dispatch(FiltersActions.clearFilters());
|
||||
}
|
||||
|
||||
toggleUnlisted($event: boolean) {
|
||||
this.store.dispatch(FiltersActions.setUnlisted({unlisted: $event}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
describe('AccountService', () => {
|
||||
let service: AccountService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(AccountService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
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";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AccountService {
|
||||
|
||||
constructor(private mastodonApiAccountsService: MastodonApiAccountsService,
|
||||
private store: PersistentStore) {
|
||||
|
||||
}
|
||||
|
||||
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)
|
||||
.pipe(
|
||||
expand(result => {
|
||||
const nextLink = result[0];
|
||||
if (nextLink && nextLink.length > 0) {
|
||||
return this.mastodonApiAccountsService.getFollowingsForAccount(instanceName, accessToken!, accountId, nextLink);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
map(result => result[1]),
|
||||
reduce((acc: Account[], res: Account[]) => {
|
||||
return acc.concat(res);
|
||||
}, []),
|
||||
map(accounts => accounts.sort((a, b) => a.username.localeCompare(b.username)) as ReadonlyArray<Account>),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ListService } from './list.service';
|
||||
|
||||
describe('ListService', () => {
|
||||
let service: ListService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(ListService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
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";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ListService {
|
||||
|
||||
constructor(private mastodonApiListsService: MastodonApiListsService,
|
||||
private store: PersistentStore) {
|
||||
}
|
||||
|
||||
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))));
|
||||
}
|
||||
|
||||
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)
|
||||
.pipe(
|
||||
expand(result => {
|
||||
const nextLink = result[0];
|
||||
if (nextLink && nextLink.length > 0) {
|
||||
return this.mastodonApiListsService.getAccountsForList(instanceName, accessToken!, listId, nextLink);
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
map(result => {
|
||||
const accounts = result[1];
|
||||
return {[listId]: accounts.map(account => account.id)};
|
||||
}
|
||||
),
|
||||
reduce((acc: { [listId: string]: string[] }, res: { [listId: string]: string[] }) => {
|
||||
const listId = Object.keys(res)[0];
|
||||
if (acc[listId] !== undefined && acc[listId].length > 0) {
|
||||
acc[listId] = acc[listId].concat(res[listId]);
|
||||
return acc;
|
||||
}
|
||||
return {...acc, ...res};
|
||||
}, {}),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AccountComponent} from './components/account/account.component';
|
||||
import {
|
||||
NbActionsModule,
|
||||
NbButtonModule,
|
||||
NbCardModule,
|
||||
NbIconModule,
|
||||
NbInputModule,
|
||||
NbSelectModule,
|
||||
NbSpinnerModule,
|
||||
NbToggleModule,
|
||||
NbUserModule
|
||||
} from "@nebular/theme";
|
||||
import {FiltersComponent} from './components/filters/filters.component';
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AccountComponent,
|
||||
FiltersComponent,
|
||||
],
|
||||
providers: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
// Nebula import only
|
||||
NbInputModule,
|
||||
NbButtonModule,
|
||||
NbSpinnerModule,
|
||||
NbSelectModule,
|
||||
NbToggleModule,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbUserModule,
|
||||
NbActionsModule,
|
||||
NbIconModule,
|
||||
NbButtonModule,
|
||||
NbInputModule,
|
||||
],
|
||||
exports: [
|
||||
AccountComponent,
|
||||
FiltersComponent,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbActionsModule,
|
||||
NbIconModule,
|
||||
NbButtonModule,
|
||||
NbInputModule,
|
||||
]
|
||||
})
|
||||
export class SharedModule {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {Injectable} from "@angular/core";
|
||||
|
||||
function getLocalStorage(): Storage {
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
@Injectable({providedIn: "root"})
|
||||
export class LocalStorageRefService {
|
||||
get localStorage(): Storage {
|
||||
return getLocalStorage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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(),
|
||||
},
|
||||
});
|
||||
|
||||
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(),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
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} from "rxjs";
|
||||
import {ListService} from "../../services/list.service";
|
||||
import {AccountService} from "../../services/account.service";
|
||||
import {NbToastrService} from "@nebular/theme";
|
||||
|
||||
@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())),
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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};
|
||||
}),
|
||||
)
|
||||
;
|
||||
@@ -0,0 +1,114 @@
|
||||
import {ApplicationState} from "./reducers";
|
||||
import {createFeatureSelector, createSelector, State} from "@ngrx/store";
|
||||
|
||||
|
||||
export const applicationStateFeature = createFeatureSelector<ApplicationState>('applicationState');
|
||||
|
||||
export const selectLists = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.lists
|
||||
)
|
||||
export const selectFollowings = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.followings
|
||||
);
|
||||
|
||||
export const selectMappings = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.listsAccounts
|
||||
);
|
||||
|
||||
export const selectListsWithAccounts = createSelector(
|
||||
selectLists,
|
||||
selectFollowings,
|
||||
selectMappings,
|
||||
(lists, followings, mappings) => {
|
||||
return lists.map(list => {
|
||||
const accountIds = mappings[list.id];
|
||||
const accounts = followings.filter(account => accountIds.includes(account.id));
|
||||
return {...list, accounts};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFollowingsWithLists = createSelector(
|
||||
selectFollowings,
|
||||
selectLists,
|
||||
selectMappings,
|
||||
(followings, lists, mappings) => {
|
||||
return followings.map(account => {
|
||||
const listIds = Object.keys(mappings).filter(listId => mappings[listId].includes(account.id));
|
||||
const accountLists = lists.filter(list => listIds.includes(list.id));
|
||||
return {...account, lists: accountLists};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilters = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.filters
|
||||
);
|
||||
|
||||
export const selectFiltersForForm = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => {
|
||||
const filters = state.filters;
|
||||
const parts = [];
|
||||
if (filters.username) {
|
||||
parts.push(`@${filters.username}`);
|
||||
}
|
||||
parts.push(...filters.freeText);
|
||||
const fullText = parts.join(',');
|
||||
return {
|
||||
fullText: fullText.length > 0 ? fullText : undefined,
|
||||
lists: filters.lists,
|
||||
unlisted: filters.unlisted,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const selectFilteredFollowingsWithLists = createSelector(
|
||||
selectFollowingsWithLists,
|
||||
selectFilters,
|
||||
(followings, filters) => {
|
||||
return followings.filter(account => {
|
||||
const usernameMatch = filters.username.length === 0 || account.username.toLowerCase().startsWith(filters.username.toLowerCase());
|
||||
const freeTextMatch = filters.freeText.length === 0 || filters.freeText
|
||||
.every(text => {
|
||||
const lowerCaseText = text.toLowerCase();
|
||||
return account.username.toLowerCase().includes(lowerCaseText)
|
||||
|| account.note.toLowerCase().includes(lowerCaseText)
|
||||
|| account.fields.some(field => field.name.toLowerCase().includes(lowerCaseText))
|
||||
|| account.fields.some(field => field.value.toLowerCase().includes(lowerCaseText));
|
||||
});
|
||||
const listsMatch = filters.lists.length === 0 || filters.lists.some(listId => account.lists.some(list => list.id === listId));
|
||||
const unlistedMatch = filters.unlisted ? account.lists.length === 0 : true;
|
||||
return usernameMatch && freeTextMatch && listsMatch && unlistedMatch;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const selectLoading = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => state.listsLoading || state.listAccountsLoading || state.followingsLoading
|
||||
);
|
||||
|
||||
export const selectLoadingPercentage = createSelector(
|
||||
applicationStateFeature,
|
||||
(state: ApplicationState) => {
|
||||
if (!state.listsLoading && !state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 100;
|
||||
}
|
||||
if (state.listsLoading && !state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 50;
|
||||
}
|
||||
if (!state.listsLoading && state.listAccountsLoading && !state.followingsLoading) {
|
||||
return 80;
|
||||
}
|
||||
if (!state.listsLoading && !state.listAccountsLoading && state.followingsLoading) {
|
||||
return 50;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {RouterModule, Routes} from "@angular/router";
|
||||
import {NgModule} from "@angular/core";
|
||||
import {SyncComponent} from "./sync/sync.component";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: SyncComponent
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class SyncRoutingModule {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {NgModule} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {SyncComponent} from './sync/sync.component';
|
||||
import {SharedModule} from "../shared/shared.module";
|
||||
import {SyncRoutingModule} from "./sync-routing.module";
|
||||
import {NbCardModule, NbSpinnerModule} from "@nebular/theme";
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SyncComponent
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
SyncRoutingModule,
|
||||
// Nebula
|
||||
NbCardModule,
|
||||
NbSpinnerModule,
|
||||
]
|
||||
})
|
||||
export class SyncModule {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
<h1>Sync Remote Data with your Local Storage</h1>
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<nb-card>
|
||||
<nb-card-header>
|
||||
Currently in Local Store
|
||||
</nb-card-header>
|
||||
<nb-card-body>
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<th colspan="2">Followings</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Number of followings</td>
|
||||
<td>{{(followings$ | async)?.length}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="divider"></div>
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<th colspan="2">Number of Accounts in List</th>
|
||||
</tr>
|
||||
<tr *ngFor="let list of lists$ | async">
|
||||
<td>{{list?.title}}</td>
|
||||
<td>{{list?.accounts?.length}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</nb-card-body>
|
||||
<nb-card-footer>
|
||||
<div>
|
||||
<button nbButton type="button"
|
||||
[nbSpinner]="(loading$ | async)!"
|
||||
[disabled]="(loading$ | async)!"
|
||||
(click)="loadListsAndAccounts()">Get Lists and Accounts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="(lists$ | async) && (followings$ | async)">
|
||||
<p>Now you can:</p>
|
||||
<ul>
|
||||
<li>Select the <a [routerLink]="['/followings']">List view</a> to add and remove users from lists</li>
|
||||
<li>... or use the experimental <a [routerLink]="['/matrix']">Matrix view</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nb-card-footer>
|
||||
</nb-card>
|
||||
</nb-card-body>
|
||||
</nb-card>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { SyncComponent } from './sync.component';
|
||||
|
||||
describe('SyncComponent', () => {
|
||||
let component: SyncComponent;
|
||||
let fixture: ComponentFixture<SyncComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ SyncComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SyncComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
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 {Account, List} from 'projects/mastodon-api/src/public-api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sync',
|
||||
templateUrl: './sync.component.html',
|
||||
styleUrls: ['./sync.component.scss']
|
||||
})
|
||||
export class SyncComponent {
|
||||
loading$: Observable<boolean>;
|
||||
followings$: Observable<ReadonlyArray<Account>>;
|
||||
lists$: Observable<ReadonlyArray<List>>;
|
||||
|
||||
constructor(private store: Store) {
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user