initial commit

This commit is contained in:
2022-12-22 16:38:25 +01:00
commit a3f54bb537
143 changed files with 24386 additions and 0 deletions

104
.drone.yml Normal file
View File

@@ -0,0 +1,104 @@
---
kind: pipeline
type: docker
name: default
environment:
K8S_REPO_URL: ssh://git@git.novaloop.ch:8022/novaloop-mastodon/mastodon-k8s.git
image_pull_secrets:
- docker-auth-config
.image-build: &image-build
- echo building docker image "git.novaloop.ch/${DRONE_REPO}:${DRONE_COMMIT_SHA}"
- mkdir -p /kaniko/.docker
- echo "$DOCKER_AUTH_CONFIG" > /kaniko/.docker/config.json
- >-
/kaniko/executor
--context .
--build-arg "ARG_APP_VERSION=${DRONE_TAG}"
--cache=true
--compressed-caching=false
--snapshotMode=redo
--use-new-run
--dockerfile "deployment/$ENVIRONMENT/$ENVIRONMENT.dockerfile"
--destination "git.novaloop.ch/${DRONE_REPO}:${DRONE_COMMIT_SHA}"
--destination "git.novaloop.ch/${DRONE_REPO}:${DRONE_TAG}"
--destination "git.novaloop.ch/${DRONE_REPO}:latest"
.image-deploy: &image-deploy
- echo deploy ${DRONE_TAG} of ${DRONE_REPO} to $ENVIRONMENT
# configure git
- mkdir -p ~/.ssh
- echo "$DRONE_CI_SSH_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -p 8022 -H git.novaloop.ch > ~/.ssh/known_hosts
- git config --global user.name ${DRONE_REPO}
- git config --global user.email ${DRONE_REPO}@git.novaloop.ch
# clone k8s repo
- git clone -b "$ENVIRONMENT" "$K8S_REPO_URL" k8s
# update manifests
- cd "k8s/mastolists/overlays/$ENVIRONMENT"
- kustomize edit set image "git.novaloop.ch/${DRONE_REPO}=git.novaloop.ch/${DRONE_REPO}:${DRONE_TAG}"
- git add .
- git commit -m "deploy ${DRONE_REPO_NAME} updating $ENVIRONMENT to ${DRONE_TAG}"
- git push
steps:
- name: build staging image
image: gcr.io/kaniko-project/executor:v1.9.1-debug # without DIND requirement
commands:
*image-build
environment:
ENVIRONMENT: staging
DOCKER_AUTH_CONFIG:
from_secret: docker-auth-config
when:
ref:
include:
- refs/tags/v[0-9]*.[0-9]*.[0-9]*-* # glob pattern matching (limited https://pkg.go.dev/github.com/bmatcuk/doublestar?utm_source=godoc)
- name: deploy staging image
image: git.novaloop.ch/novaloop-hosting/gitlab-build-docker-image:latest
commands:
*image-deploy
environment:
ENVIRONMENT: staging
DRONE_CI_SSH_KEY:
from_secret: DRONE_CI_SSH_KEY
when:
ref:
include:
- refs/tags/v[0-9]*.[0-9]*.[0-9]*-* # glob pattern matching (limited https://pkg.go.dev/github.com/bmatcuk/doublestar?utm_source=godoc)
- name: build production image
image: gcr.io/kaniko-project/executor:v1.9.1-debug # without DIND requirement
commands:
*image-build
environment:
ENVIRONMENT: production
DOCKER_AUTH_CONFIG:
from_secret: docker-auth-config
when:
ref:
include:
- refs/tags/v[0-9]*.[0-9]*.[0-9]* # glob pattern matching (limited https://pkg.go.dev/github.com/bmatcuk/doublestar?utm_source=godoc)
exclude:
- refs/tags/v[0-9]*.[0-9]*.[0-9]*-*
- name: deploy production image
image: git.novaloop.ch/novaloop-hosting/gitlab-build-docker-image:latest
commands:
*image-deploy
environment:
ENVIRONMENT: production
DRONE_CI_SSH_KEY:
from_secret: DRONE_CI_SSH_KEY
when:
ref:
include:
- refs/tags/v[0-9]*.[0-9]*.[0-9]* # glob pattern matching (limited https://pkg.go.dev/github.com/bmatcuk/doublestar?utm_source=godoc)
exclude:
- refs/tags/v[0-9]*.[0-9]*.[0-9]*-*

16
.editorconfig Normal file
View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
.vscode
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

27
README.md Normal file
View File

@@ -0,0 +1,27 @@
# Mastodon
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 15.0.2.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
## 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.

139
angular.json Normal file
View File

@@ -0,0 +1,139 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"mastolists": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "projects/mastolists",
"sourceRoot": "projects/mastolists/src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/mastolists",
"index": "projects/mastolists/src/index.html",
"main": "projects/mastolists/src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "projects/mastolists/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"projects/mastolists/src/favicon.ico",
"projects/mastolists/src/assets"
],
"styles": [
"projects/mastolists/src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "3mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "mastolists:build:production"
},
"development": {
"browserTarget": "mastolists:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "mastolists:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "projects/mastolists/tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
"projects/mastolists/src/favicon.ico",
"projects/mastolists/src/assets"
],
"styles": [
"projects/mastolists/src/styles.scss"
],
"scripts": []
}
}
}
},
"mastodon-api": {
"projectType": "library",
"root": "projects/mastodon-api",
"sourceRoot": "projects/mastodon-api/src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"project": "projects/mastodon-api/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/mastodon-api/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "projects/mastodon-api/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"tsConfig": "projects/mastodon-api/tsconfig.spec.json",
"polyfills": [
"zone.js",
"zone.js/testing"
]
}
}
}
}
},
"cli": {
"analytics": "48f19321-a0cb-4b6d-8ea6-27d1fd0792f5"
}
}

20
deployment/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 0.0.0.0:8080;
listen [::]:8080;
default_type application/octet-stream;
gzip on;
gzip_comp_level 6;
gzip_vary on;
gzip_min_length 1000;
gzip_proxied any;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
gzip_buffers 16 8k;
client_max_body_size 256M;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html =404;
}
}

View File

@@ -0,0 +1,17 @@
FROM node:lts-alpine AS build-env
WORKDIR /app
COPY ./package.json ./
COPY ./package-lock.json ./
RUN npm ci
COPY ./ ./
ARG ARG_APP_VERSION
ENV APP_VERSION=$ARG_APP_VERSION
RUN npm version -no-git-tag-version $APP_VERSION
RUN node ./node_modules/@angular/cli/bin/ng build mastolists --configuration=production
FROM nginx:alpine
LABEL author="Novaloop AG"
COPY --from=build-env /app/dist/mastolists /usr/share/nginx/html
COPY ./deployment/nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -0,0 +1,17 @@
FROM node:lts-alpine AS build-env
WORKDIR /app
COPY ./package.json ./
COPY ./package-lock.json ./
RUN npm ci
COPY ./ ./
ARG ARG_APP_VERSION
ENV APP_VERSION=$ARG_APP_VERSION
RUN npm version -no-git-tag-version $APP_VERSION
RUN node ./node_modules/@angular/cli/bin/ng build mastolists --configuration=staging
FROM nginx:alpine
LABEL author="Novaloop AG"
COPY --from=build-env /app/dist/mastolists /usr/share/nginx/html
COPY ./deployment/nginx.conf /etc/nginx/conf.d/default.conf

21194
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

79
package.json Normal file
View File

@@ -0,0 +1,79 @@
{
"name": "mastodon",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^15.0.0",
"@angular/common": "^15.0.0",
"@angular/compiler": "^15.0.0",
"@angular/core": "^15.0.0",
"@angular/forms": "^15.0.0",
"@angular/platform-browser": "^15.0.0",
"@angular/platform-browser-dynamic": "^15.0.0",
"@angular/router": "^15.0.0",
"@nebular/auth": "10.0.0",
"@nebular/eva-icons": "^10.0.0",
"@nebular/security": "10.0.0",
"@nebular/theme": "10.0.0",
"@ngrx/effects": "^15.0.0",
"@ngrx/entity": "^15.0.0",
"@ngrx/store": "^15.0.0",
"@ngrx/store-devtools": "^15.0.0",
"axios": "^1.2.1",
"eva-icons": "^1.1.3",
"ngx-progressbar": "^9.0.0",
"oauth": "^0.10.0",
"object-assign-deep": "^0.4.0",
"parse-link-header": "^2.0.0",
"rxjs": "~7.5.0",
"tslib": "^2.3.0",
"zone.js": "~0.12.0"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^15.0.0-beta.0",
"@angular-devkit/build-angular": "^15.0.3",
"@angular/cli": "~15.0.2",
"@angular/compiler-cli": "^15.0.0",
"@types/jasmine": "~4.3.0",
"@types/oauth": "^0.9.1",
"@types/object-assign-deep": "^0.4.0",
"@types/parse-link-header": "^2.0.0",
"jasmine-core": "~4.5.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.0.0",
"ng-packagr": "^15.0.0",
"typescript": "~4.8.2"
},
"overrides": {
"@nebular/auth@10.0.0": {
"@angular/animations": "15.0.3",
"@angular/cdk": "15.0.0",
"@angular/common": "15.0.3",
"@angular/core": "15.0.3",
"@angular/forms": "15.0.3",
"@angular/router": "15.0.3"
},
"@nebular/theme@10.0.0": {
"@angular/animations": "15.0.3",
"@angular/cdk": "15.0.0",
"@angular/common": "15.0.3",
"@angular/core": "15.0.3",
"@angular/router": "15.0.3"
},
"@nebular/security@10.0.0": {
"@angular/common": "15.0.3",
"@angular/core": "15.0.3",
"@angular/router": "15.0.3"
}
}
}

View File

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

View File

@@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/mastodon-api",
"lib": {
"entryFile": "src/public-api.ts"
}
}

View File

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

View File

@@ -0,0 +1 @@
export * from './public/public';

View File

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

View File

@@ -0,0 +1,8 @@
import {Account} from "./account";
export interface List {
id: string;
title: string;
repliesPolicy: string;
accounts: Account[]
}

View File

@@ -0,0 +1,4 @@
export * from './account';
export * from './list';
export * from './registered_app';
export * from './user_account';

View File

@@ -0,0 +1,9 @@
export interface RegisteredApp{
id: string;
name: string;
website: string;
redirectUri: string;
clientId: string;
clientSecret: string;
vapidKey: string;
}

View File

@@ -0,0 +1,3 @@
export interface UserAccount {
id: string;
}

View File

@@ -0,0 +1,6 @@
export interface GetAccessTokenResponse {
access_token: string;
token_type: string;
scope: string;
created_at: Date;
}

View File

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

View File

@@ -0,0 +1,8 @@
import {GetAccountResponse} from "./get_account_response";
export interface GetListsResponse {
id: string;
title: string;
repliesPolicy: string;
accounts: GetAccountResponse[]
}

View File

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

View File

@@ -0,0 +1,3 @@
export interface VerifyCredentialsResponse {
id: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
/*
* Public API Surface of mastodon-api
*/
export * from './lib/services/services';
export * from './lib/interfaces/interfaces';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
.authorize-button {
margin: 20px;
}
@media (max-width: 573px) {
.authorize-button {
margin-left: 0;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss']
})
export class HomeComponent {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Some files were not shown because too many files have changed in this diff Show More