From ae12f1f5fc37f2e800118cd5917e8b97f49fa740 Mon Sep 17 00:00:00 2001 From: Dominik Chrastecky Date: Sat, 6 Jan 2024 18:16:45 +0100 Subject: [PATCH] Feat: Add rebuttals --- src/app/app-routing.module.ts | 4 + .../instance-detail.component.html | 44 ++++++ .../instance-detail.component.ts | 39 +++++ .../create-edit-rebuttal.component.html | 69 +++++++++ .../create-edit-rebuttal.component.scss | 3 + .../create-edit-rebuttal.component.ts | 146 ++++++++++++++++++ src/app/rebuttals/rebuttals.module.ts | 33 ++++ src/app/response/instance-detail.response.ts | 1 + .../normalized-instance-detail.response.ts | 2 + src/app/services/cache/cache.ts | 2 + .../services/cache/permanent-cache.service.ts | 22 ++- .../services/cache/runtime-cache.service.ts | 16 +- .../services/cached-fediseer-api.service.ts | 53 +++++++ src/app/services/fediseer-api.service.ts | 16 ++ src/assets/i18n/en.json | 22 ++- 15 files changed, 463 insertions(+), 9 deletions(-) create mode 100644 src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.html create mode 100644 src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.scss create mode 100644 src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.ts create mode 100644 src/app/rebuttals/rebuttals.module.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 0259756..69bd307 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -47,6 +47,10 @@ const routes: Routes = [ path: 'solicitations', loadChildren: () => import('./solicitations/solicitations.module').then(m => m.SolicitationsModule), }, + { + path: 'rebuttals', + loadChildren: () => import('./rebuttals/rebuttals.module').then(m => m.RebuttalsModule), + }, ]; @NgModule({ diff --git a/src/app/instances/pages/instance-detail/instance-detail.component.html b/src/app/instances/pages/instance-detail/instance-detail.component.html index 17f2963..44663e7 100644 --- a/src/app/instances/pages/instance-detail/instance-detail.component.html +++ b/src/app/instances/pages/instance-detail/instance-detail.component.html @@ -179,6 +179,7 @@

Censures received ({{censuresRecei Instance Reasons Evidence + {{'app.rebuttal' | transloco}} @@ -196,6 +197,27 @@

Censures received ({{censuresRecei N/A {{instance.censuresEvidence}} + + + + {{'app.rebuttal.create' | transloco}} + + + + {{'app.not_applicable' | transloco}} + + + + + {{instance.rebuttal}} +
+ +
+ {{'app.rebuttal.edit' | transloco}}  + +
+
+ @@ -323,6 +345,7 @@

Hesitations received ({{hesitat Instance Reasons Evidence + {{'app.rebuttal' | transloco}} @@ -340,6 +363,27 @@

Hesitations received ({{hesitat N/A {{instance.hesitationsEvidence}} + + + + {{'app.rebuttal.create' | transloco}} + + + + {{'app.not_applicable' | transloco}} + + + + + {{instance.rebuttal}} +
+ +
+ {{'app.rebuttal.edit' | transloco}}  + +
+
+ diff --git a/src/app/instances/pages/instance-detail/instance-detail.component.ts b/src/app/instances/pages/instance-detail/instance-detail.component.ts index c88fb33..0b2641c 100644 --- a/src/app/instances/pages/instance-detail/instance-detail.component.ts +++ b/src/app/instances/pages/instance-detail/instance-detail.component.ts @@ -123,4 +123,43 @@ export class InstanceDetailComponent implements OnInit { public async onInstanceMoved(event: InstanceMoveEvent) { } + + public async removeRebuttal(sourceInstance: string) { + this.loading = true; + + await toPromise(this.api.removeRebuttal(sourceInstance)); + const responses = await Promise.all([ + toPromise(this.cachedApi.getHesitationsForInstance(this.authManager.currentInstanceSnapshot.name, {clear: true})), + toPromise(this.cachedApi.getCensuresForInstance(this.authManager.currentInstanceSnapshot.name, {clear: true})), + ]); + this.cachedApi.clearHesitationsByInstanceCache(sourceInstance); + this.cachedApi.clearCensuresByInstanceCache(sourceInstance); + + if (this.apiResponseHelper.handleErrors(responses)) { + this.loading = false; + return; + } + + this.censuresReceived = responses[1].successResponse!.instances.map( + instance => { + const result = NormalizedInstanceDetailResponse.fromInstanceDetail(instance); + if (result.domain === sourceInstance) { + result.rebuttal = null; + } + + return result; + }, + ); + this.hesitationsReceived = responses[0].successResponse!.instances.map( + instance => { + const result = NormalizedInstanceDetailResponse.fromInstanceDetail(instance); + if (result.domain === sourceInstance) { + result.rebuttal = null; + } + + return result; + }, + ); + this.loading = false; + } } diff --git a/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.html b/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.html new file mode 100644 index 0000000..521384c --- /dev/null +++ b/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.html @@ -0,0 +1,69 @@ + + + +
+
+
+

+ +

+
+
+

+ +

+
    +
  • + {{reason}} +
  • +
+

+ +

+
    +
  • + {{censure.evidence}} +
  • +
+
+
+
+
+

+ +

+
+
+

+ +

+
    +
  • + {{reason}} +
  • +
+

+ +

+
    +
  • + {{hesitation.evidence}} +
  • +
+
+
+
+
+

{{"app.rebuttal.your_rebuttal" | transloco}}

+
+
+
+
+ +
+ +
+
+
+
+
diff --git a/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.scss b/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.scss new file mode 100644 index 0000000..5f5d12d --- /dev/null +++ b/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.scss @@ -0,0 +1,3 @@ +:host { + min-width: 100%; +} diff --git a/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.ts b/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.ts new file mode 100644 index 0000000..4325ee7 --- /dev/null +++ b/src/app/rebuttals/pages/create-edit-rebuttal/create-edit-rebuttal.component.ts @@ -0,0 +1,146 @@ +import {Component, OnInit} from '@angular/core'; +import {TitleService} from "../../../services/title.service"; +import {TranslatorService} from "../../../services/translator.service"; +import {CachedFediseerApiService} from "../../../services/cached-fediseer-api.service"; +import {ActivatedRoute, Router} from "@angular/router"; +import {Resolvable, toPromise} from "../../../types/resolvable"; +import {ApiResponseHelperService} from "../../../services/api-response-helper.service"; +import {AuthenticationManagerService} from "../../../services/authentication-manager.service"; +import {NormalizedInstanceDetailResponse} from "../../../response/normalized-instance-detail.response"; +import {MessageService} from "../../../services/message.service"; +import {FormControl, FormGroup} from "@angular/forms"; +import {ApiResponse, FediseerApiService} from "../../../services/fediseer-api.service"; +import {SuccessResponse} from "../../../response/success.response"; + +interface Reason { + reasons: string[]; + evidence: string; +} + +@Component({ + selector: 'app-create-rebuttal', + templateUrl: './create-edit-rebuttal.component.html', + styleUrls: ['./create-edit-rebuttal.component.scss'] +}) +export class CreateEditRebuttalComponent implements OnInit { + public hesitation: Reason | null = null; + public censure: Reason | null = null; + public sourceInstance: string | null = null; + + public form = new FormGroup({ + rebuttal: new FormControl(''), + }); + + public loading: boolean = true; + public isNew: boolean = false; + + constructor( + private readonly titleService: TitleService, + private readonly translator: TranslatorService, + private readonly cachedApi: CachedFediseerApiService, + private readonly api: FediseerApiService, + private readonly activatedRoute: ActivatedRoute, + private readonly apiResponseHelper: ApiResponseHelperService, + private readonly authManager: AuthenticationManagerService, + private readonly messageService: MessageService, + private readonly router: Router, + ) { + } + + public async ngOnInit(): Promise { + this.activatedRoute.params.subscribe(async params => { + this.loading = true; + this.sourceInstance = params['sourceInstance']; + + this.titleService.title = this.translator.get('app.rebuttal.title', { + instanceName: this.sourceInstance, + }); + + const responses = await Promise.all([ + toPromise(this.cachedApi.getHesitationsForInstance(this.authManager.currentInstanceSnapshot.name)), + toPromise(this.cachedApi.getCensuresForInstance(this.authManager.currentInstanceSnapshot.name)), + ]); + if (this.apiResponseHelper.handleErrors(responses)) { + this.loading = false; + return; + } + + const hesitations = responses[0].successResponse!.instances.filter( + instance => instance.domain === this.sourceInstance!, + ).map(instance => NormalizedInstanceDetailResponse.fromInstanceDetail(instance)); + const censures = responses[1].successResponse!.instances.filter( + instance => instance.domain === this.sourceInstance!, + ).map(instance => NormalizedInstanceDetailResponse.fromInstanceDetail(instance)); + + let rebuttal: string | null = null; + if (hesitations.length && (hesitations[0].hesitationsEvidence || hesitations[0].hesitationReasons.length)) { + this.hesitation = { + evidence: hesitations[0].hesitationsEvidence, + reasons: hesitations[0].hesitationReasons, + }; + if (hesitations[0].rebuttal !== null) { + rebuttal = hesitations[0].rebuttal; + } + } + if (censures.length && (censures[0].censuresEvidence || censures[0].censureReasons.length)) { + this.censure = { + evidence: censures[0].censuresEvidence, + reasons: censures[0].censureReasons, + }; + if (censures[0].rebuttal !== null) { + rebuttal = censures[0].rebuttal; + } + } + + this.isNew = rebuttal === null; + + if (this.hesitation === null && this.censure === null) { + this.loading = false; + this.messageService.createError(this.translator.get('app.error.rebuttal.no_censure_hesitation')); + return; + } + + this.form.patchValue({rebuttal: rebuttal ?? ''}); + + this.loading = false; + }); + } + + public async onSubmit() { + this.loading = true; + const rebuttal = this.form.controls.rebuttal.value ?? ''; + if (this.isNew && !rebuttal) { + this.messageService.createWarning(this.translator.get('app.warning.empty_rebuttal')); + this.loading = false; + return; + } + + let message: Resolvable; + let response: ApiResponse; + if (!rebuttal) { + response = await toPromise(this.api.removeRebuttal(this.sourceInstance!)); + message = this.translator.get('app.rebuttal.deleted'); + } else if (this.isNew) { + response = await toPromise(this.api.createRebuttal(this.sourceInstance!, rebuttal)); + message = this.translator.get('app.rebuttal.created'); + } else { + response = await toPromise(this.api.updateRebuttal(this.sourceInstance!, rebuttal)); + message = this.translator.get('app.rebuttal.updated'); + } + if (this.apiResponseHelper.handleErrors([response])) { + this.loading = false; + return; + } + + await Promise.all([ + toPromise(this.cachedApi.getHesitationsForInstance(this.authManager.currentInstanceSnapshot.name, {clear: true})), + toPromise(this.cachedApi.getCensuresForInstance(this.authManager.currentInstanceSnapshot.name, {clear: true})), + ]); + this.cachedApi.clearCensuresByInstanceCache(this.sourceInstance!); + this.cachedApi.clearHesitationsByInstanceCache(this.sourceInstance!); + + this.router.navigateByUrl(`/instances/detail/${this.authManager.currentInstanceSnapshot.name}`).then(() => { + this.messageService.createSuccess(message); + }); + } +} diff --git a/src/app/rebuttals/rebuttals.module.ts b/src/app/rebuttals/rebuttals.module.ts new file mode 100644 index 0000000..a058987 --- /dev/null +++ b/src/app/rebuttals/rebuttals.module.ts @@ -0,0 +1,33 @@ +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {RouterModule, Routes} from "@angular/router"; +import {SharedModule} from "../shared/shared.module"; +import {ReactiveFormsModule} from "@angular/forms"; +import {CreateEditRebuttalComponent} from './pages/create-edit-rebuttal/create-edit-rebuttal.component'; +import {Guards} from "../guards/guards"; + +const routes: Routes = [ + { + path: 'create/:sourceInstance', + component: CreateEditRebuttalComponent, + canActivate: [Guards.isLoggedIn()], + }, + { + path: 'edit/:sourceInstance', + component: CreateEditRebuttalComponent, + canActivate: [Guards.isLoggedIn()], + } +]; + +@NgModule({ + declarations: [ + CreateEditRebuttalComponent + ], + imports: [ + CommonModule, + RouterModule.forChild(routes), + SharedModule, + ReactiveFormsModule, + ] +}) +export class RebuttalsModule { } diff --git a/src/app/response/instance-detail.response.ts b/src/app/response/instance-detail.response.ts index a9ffe6c..1cd71dd 100644 --- a/src/app/response/instance-detail.response.ts +++ b/src/app/response/instance-detail.response.ts @@ -16,6 +16,7 @@ export interface InstanceDetailResponse { endorsements: int; guarantor?: string | null; censure_reasons?: string[] | null; + rebuttal: string[] | null; sysadmins: int | null; moderators: int | null; censure_evidence?: string[]; diff --git a/src/app/response/normalized-instance-detail.response.ts b/src/app/response/normalized-instance-detail.response.ts index a0f784d..d82ffd7 100644 --- a/src/app/response/normalized-instance-detail.response.ts +++ b/src/app/response/normalized-instance-detail.response.ts @@ -23,6 +23,7 @@ export class NormalizedInstanceDetailResponse { public sysadmins: int | null, public moderators: int | null, public state: InstanceStatus, + public rebuttal: string | null, public guarantor?: string | null, ) { } @@ -65,6 +66,7 @@ export class NormalizedInstanceDetailResponse { detail.sysadmins, detail.moderators, detail.state, + detail.rebuttal === null || !detail.rebuttal.length ? null : detail.rebuttal.join(', '), detail.guarantor, ); } diff --git a/src/app/services/cache/cache.ts b/src/app/services/cache/cache.ts index c0e3415..0f69d28 100644 --- a/src/app/services/cache/cache.ts +++ b/src/app/services/cache/cache.ts @@ -9,6 +9,8 @@ export interface Cache { getItem(key: string): CacheItem; save(item: CacheItem): void; remove(item: CacheItem): void; + removeByKey(key: string): void; clear(): void; clearByPrefix(prefix: string): void; + getKeysByPrefix(prefix: string): string[]; } diff --git a/src/app/services/cache/permanent-cache.service.ts b/src/app/services/cache/permanent-cache.service.ts index 19c4944..f5fed05 100644 --- a/src/app/services/cache/permanent-cache.service.ts +++ b/src/app/services/cache/permanent-cache.service.ts @@ -64,7 +64,14 @@ export class PermanentCacheService implements Cache { if (typeof localStorage === 'undefined') { return; } - localStorage.removeItem(this.getKey(item.key)); + this.removeByKey(item.key) + } + + public removeByKey(key: string) { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.removeItem(this.getKey(key)); } public clear(): void { @@ -77,12 +84,21 @@ export class PermanentCacheService implements Cache { } public clearByPrefix(prefix: string): void { + for (const key of this.getKeysByPrefix(prefix)) { + localStorage.removeItem(key); + } + } + + public getKeysByPrefix(prefix: string): string[] { if (typeof localStorage === 'undefined') { - return; + return []; } + const result: string[] = []; for (const key of Object.keys(localStorage).filter(key => key.startsWith(`${this.prefix}.${prefix}`))) { - localStorage.removeItem(key); + result.push(key); } + + return result; } private getKey(key: string): string { diff --git a/src/app/services/cache/runtime-cache.service.ts b/src/app/services/cache/runtime-cache.service.ts index 85593df..9ea1897 100644 --- a/src/app/services/cache/runtime-cache.service.ts +++ b/src/app/services/cache/runtime-cache.service.ts @@ -39,7 +39,11 @@ export class RuntimeCacheService implements Cache { } public remove(item: CacheItem): void { - delete this.cache[item.key]; + this.removeByKey(item.key); + } + + public removeByKey(key: string): void { + delete this.cache[key]; } public clear(): void { @@ -47,10 +51,12 @@ export class RuntimeCacheService implements Cache { } public clearByPrefix(prefix: string): void { - for (const key of Object.keys(this.cache)) { - if (key.startsWith(prefix)) { - delete this.cache[key]; - } + for (const key of this.getKeysByPrefix(prefix)) { + delete this.cache[key]; } } + + public getKeysByPrefix(prefix: string): string[] { + return Object.keys(this.cache).filter(key => key.startsWith(prefix)); + } } diff --git a/src/app/services/cached-fediseer-api.service.ts b/src/app/services/cached-fediseer-api.service.ts index 4f1bee4..a3aa7d0 100644 --- a/src/app/services/cached-fediseer-api.service.ts +++ b/src/app/services/cached-fediseer-api.service.ts @@ -129,6 +129,30 @@ export class CachedFediseerApiService { this.permanentCache.clearByPrefix('api.safelist'); } + public clearCensuresByInstanceCache(instance: string): void { + const caches: Cache[] = [this.runtimeCache, this.permanentCache]; + for (const cache of caches) { + const keys = cache.getKeysByPrefix('app_cache.api.censures_by_instances'); + for (const key of keys) { + if (key.includes(instance)) { + cache.removeByKey(key); + } + } + } + } + + public clearHesitationsByInstanceCache(instance: string): void { + const caches: Cache[] = [this.runtimeCache, this.permanentCache]; + for (const cache of caches) { + const keys = cache.getKeysByPrefix('app_cache.api.hesitations_by_instances'); + for (const key of keys) { + if (key.includes(instance)) { + cache.removeByKey(key); + } + } + } + } + public getHesitationsByInstances(instances: string[], page: int = 1, cacheConfig: CacheConfiguration = {}): Observable>> { cacheConfig.type ??= CacheType.Permanent; cacheConfig.ttl ??= 60; @@ -334,6 +358,35 @@ export class CachedFediseerApiService { ); } + public getCensuresForInstance(instance: string, cacheConfig: CacheConfiguration = {}): Observable>> { + cacheConfig.type ??= CacheType.Permanent; + cacheConfig.ttl ??= 300; + + const cacheKey = `api.censures_for_instance${cacheConfig.ttl}.${instance}`; + const item = this.getCacheItem>(cacheKey, cacheConfig)!; + if (item.isHit && !cacheConfig.clear) { + return this.getSuccessResponse(item); + } + + return this.api.getCensuresForInstance(instance).pipe( + tap(this.storeResponse(item, cacheConfig)), + ); + } + + public getHesitationsForInstance(instance: string, cacheConfig: CacheConfiguration = {}): Observable>> { + cacheConfig.type ??= CacheType.Permanent; + cacheConfig.ttl ??= 300; + + const cacheKey = `api.hesitations_for_instance${cacheConfig.ttl}.${instance}`; + const item = this.getCacheItem>(cacheKey, cacheConfig)!; + if (item.isHit && !cacheConfig.clear) { + return this.getSuccessResponse(item); + } + + return this.api.getHesitationsForInstance(instance).pipe( + tap(this.storeResponse(item, cacheConfig)), + ); + } public clearCache(): void { this.runtimeCache.clear(); diff --git a/src/app/services/fediseer-api.service.ts b/src/app/services/fediseer-api.service.ts index 5615daa..a3f8e7b 100644 --- a/src/app/services/fediseer-api.service.ts +++ b/src/app/services/fediseer-api.service.ts @@ -502,6 +502,22 @@ export class FediseerApiService { return this.sendRequest(HttpMethod.Get, `config`); } + public createRebuttal(sourceInstance: string, rebuttal: string): Observable> { + return this.sendRequest(HttpMethod.Put, `rebuttals/${sourceInstance}`, { + rebuttal: rebuttal, + }); + } + + public updateRebuttal(sourceInstance: string, rebuttal: string): Observable> { + return this.sendRequest(HttpMethod.Patch, `rebuttals/${sourceInstance}`, { + rebuttal: rebuttal, + }); + } + + public removeRebuttal(sourceInstance: string): Observable> { + return this.sendRequest(HttpMethod.Delete, `rebuttals/${sourceInstance}`); + } + private sendRequest( method: HttpMethod, endpoint: string, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index abe3b39..efdf726 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -175,5 +175,25 @@ "error.censures.moving_failed": "Failed moving the instance {{instance}}. Please reload the page to see whether it was removed from your censures or not.", "app.language": "Language", "app.search": "Search", - "app.search.not_found": "Nothing was found" + "app.search.not_found": "Nothing was found", + "app.rebuttal": "Rebuttal", + "app.rebuttal.create": "Create rebuttal", + "app.rebuttal.edit": "Edit rebuttal", + "app.rebuttal.remove": "Remove rebuttal", + "app.rebuttal.create_or_edit": "Create or edit a rebuttal", + "app.error.rebuttal.no_censure_hesitation": "There is no censure a hesitation to create a rebuttal against.", + "app.rebuttal.title": "Rebuttal to {{instanceName}}", + "app.rebuttal.hesitation_by": "Hesitation by [b]{{instance}}[/b]", + "app.rebuttal.hesitations_by.reasons": "[b]{{instance}}[/b] has given the following reasons for their hesitation against you:", + "app.rebuttal.hesitations_by.evidence": "[b]{{instance}}[/b] has provided the following evidence for their hesitation:", + "app.rebuttal.censure_by": "Censure by [b]{{instance}}[/b]", + "app.rebuttal.censures_by.reasons": "[b]{{instance}}[/b] has given the following reasons for their censure against you:", + "app.rebuttal.censures_by.evidence": "[b]{{instance}}[/b] has provided the following evidence for their censure:", + "app.rebuttal.your_rebuttal": "Your rebuttal", + "app.rebuttal.no_rebuttal_yet": "No rebuttal has been created yet", + "app.button.save": "Save", + "app.warning.empty_rebuttal": "Cannot create an empty rebuttal", + "app.rebuttal.created": "Rebuttal has been successfully created", + "app.rebuttal.deleted": "Rebuttal has been successfully deleted", + "app.rebuttal.updated": "Rebuttal has been successfully updated" }