From e3d6c6434f570f77c0532f86c82f78bcafb399ec Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 27 Jul 2022 13:44:40 +0200 Subject: [PATCH] Add bulk action on following/followers --- .../followers-list.component.html | 26 ++++- .../followers-list.component.ts | 105 +++++++++++++----- .../following-list.component.html | 24 +++- .../following-list.component.ts | 46 ++++++-- .../instance-follow.service.ts | 69 +++++++++--- .../shared/shared-main/video/video.service.ts | 3 +- .../shared-moderation/blocklist.service.ts | 3 +- .../shared-moderation/video-block.service.ts | 3 +- .../shared/shared-users/user-admin.service.ts | 7 +- server/models/actor/actor-follow.ts | 2 +- shared/core-utils/common/array.ts | 10 +- 11 files changed, 226 insertions(+), 72 deletions(-) diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html index 4f11f261d..8fe0d2348 100644 --- a/client/src/app/+admin/follows/followers-list/followers-list.component.html +++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html @@ -9,9 +9,18 @@ [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [showCurrentPageReport]="true" i18n-currentPageReportTemplate currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers" + [(selection)]="selectedFollows" >
+
+ + +
+
@@ -20,6 +29,9 @@ + + + Actions Follower State @@ -30,15 +42,19 @@ + + + + - - + + - + - {{ follow.follower.name + '@' + follow.follower.host }} + {{ buildFollowerName(follow) }} @@ -56,7 +72,7 @@ - +
No follower found matching current filters. Your instance doesn't have any follower. diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts index d09e74fef..b2d333e83 100644 --- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts +++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts @@ -1,8 +1,10 @@ import { SortMeta } from 'primeng/api' import { Component, OnInit } from '@angular/core' import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' +import { prepareIcu } from '@app/helpers' import { AdvancedInputFilter } from '@app/shared/shared-forms' import { InstanceFollowService } from '@app/shared/shared-instance' +import { DropdownAction } from '@app/shared/shared-main' import { ActorFollow } from '@shared/models' @Component({ @@ -16,7 +18,10 @@ export class FollowersListComponent extends RestTable implements OnInit { sort: SortMeta = { field: 'createdAt', order: -1 } pagination: RestPagination = { count: this.rowsPerPage, start: 0 } - searchFilters: AdvancedInputFilter[] + searchFilters: AdvancedInputFilter[] = [] + + selectedFollows: ActorFollow[] = [] + bulkFollowsActions: DropdownAction[] = [] constructor ( private confirmService: ConfirmService, @@ -24,66 +29,104 @@ export class FollowersListComponent extends RestTable implements OnInit { private followService: InstanceFollowService ) { super() - - this.searchFilters = this.followService.buildFollowsListFilters() } ngOnInit () { this.initialize() + + this.searchFilters = this.followService.buildFollowsListFilters() + + this.bulkFollowsActions = [ + { + label: $localize`Reject`, + handler: follows => this.rejectFollower(follows), + isDisplayed: follows => follows.every(f => f.state !== 'rejected') + }, + { + label: $localize`Accept`, + handler: follows => this.acceptFollower(follows), + isDisplayed: follows => follows.every(f => f.state !== 'accepted') + }, + { + label: $localize`Delete`, + handler: follows => this.deleteFollowers(follows), + isDisplayed: follows => follows.every(f => f.state === 'rejected') + } + ] } getIdentifier () { return 'FollowersListComponent' } - acceptFollower (follow: ActorFollow) { - follow.state = 'accepted' - - this.followService.acceptFollower(follow) + acceptFollower (follows: ActorFollow[]) { + this.followService.acceptFollower(follows) .subscribe({ next: () => { - const handle = follow.follower.name + '@' + follow.follower.host - this.notifier.success($localize`${handle} accepted in instance followers`) + // eslint-disable-next-line max-len + const message = prepareIcu($localize`Accepted {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( + { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, + $localize`Follow requests accepted` + ) + this.notifier.success(message) + + this.reloadData() }, - error: err => { - follow.state = 'pending' - this.notifier.error(err.message) - } + error: err => this.notifier.error(err.message) }) } - async rejectFollower (follow: ActorFollow) { - const message = $localize`Do you really want to reject this follower?` + async rejectFollower (follows: ActorFollow[]) { + // eslint-disable-next-line max-len + const message = prepareIcu($localize`Do you really want to reject {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( + { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, + $localize`Do you really want to reject these follow requests?` + ) + const res = await this.confirmService.confirm(message, $localize`Reject`) if (res === false) return - this.followService.rejectFollower(follow) + this.followService.rejectFollower(follows) .subscribe({ next: () => { - const handle = follow.follower.name + '@' + follow.follower.host - this.notifier.success($localize`${handle} rejected from instance followers`) + // eslint-disable-next-line max-len + const message = prepareIcu($localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( + { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, + $localize`Follow requests rejected` + ) + this.notifier.success(message) this.reloadData() }, - error: err => { - follow.state = 'pending' - this.notifier.error(err.message) - } + error: err => this.notifier.error(err.message) }) } - async deleteFollower (follow: ActorFollow) { - const message = $localize`Do you really want to delete this follower? It will be able to send again another follow request.` + async deleteFollowers (follows: ActorFollow[]) { + let message = $localize`Deleted followers will be able to send again a follow request.` + message += '

' + + // eslint-disable-next-line max-len + message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( + { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, + $localize`Do you really want to delete these follow requests?` + ) + const res = await this.confirmService.confirm(message, $localize`Delete`) if (res === false) return - this.followService.removeFollower(follow) + this.followService.removeFollower(follows) .subscribe({ next: () => { - const handle = follow.follower.name + '@' + follow.follower.host - this.notifier.success($localize`${handle} removed from instance followers`) + // eslint-disable-next-line max-len + const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( + { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, + $localize`Follow requests removed` + ) + + this.notifier.success(message) this.reloadData() }, @@ -92,6 +135,14 @@ export class FollowersListComponent extends RestTable implements OnInit { }) } + buildFollowerName (follow: ActorFollow) { + return follow.follower.name + '@' + follow.follower.host + } + + isInSelectionMode () { + return this.selectedFollows.length !== 0 + } + protected reloadData () { this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search }) .subscribe({ diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html index 856c4a31f..4554bf151 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.html +++ b/client/src/app/+admin/follows/following-list/following-list.component.html @@ -9,11 +9,18 @@ [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [showCurrentPageReport]="true" i18n-currentPageReportTemplate currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts" + [(selection)]="selectedFollows" >
- @@ -27,6 +34,9 @@ + + + Action Following State @@ -35,14 +45,18 @@ - + + + + + - + - {{ follow.following.name + '@' + follow.following.host }} + {{ buildFollowingName(follow) }} @@ -65,7 +79,7 @@ - +
No host found matching current filters. Your instance is not following anyone. diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts index 7a854be81..e3a56651a 100644 --- a/client/src/app/+admin/follows/following-list/following-list.component.ts +++ b/client/src/app/+admin/follows/following-list/following-list.component.ts @@ -5,6 +5,8 @@ import { AdvancedInputFilter } from '@app/shared/shared-forms' import { InstanceFollowService } from '@app/shared/shared-instance' import { ActorFollow } from '@shared/models' import { FollowModalComponent } from './follow-modal.component' +import { DropdownAction } from '@app/shared/shared-main' +import { prepareIcu } from '@app/helpers' @Component({ templateUrl: './following-list.component.html', @@ -18,7 +20,10 @@ export class FollowingListComponent extends RestTable implements OnInit { sort: SortMeta = { field: 'createdAt', order: -1 } pagination: RestPagination = { count: this.rowsPerPage, start: 0 } - searchFilters: AdvancedInputFilter[] + searchFilters: AdvancedInputFilter[] = [] + + selectedFollows: ActorFollow[] = [] + bulkFollowsActions: DropdownAction[] = [] constructor ( private notifier: Notifier, @@ -26,12 +31,19 @@ export class FollowingListComponent extends RestTable implements OnInit { private followService: InstanceFollowService ) { super() - - this.searchFilters = this.followService.buildFollowsListFilters() } ngOnInit () { this.initialize() + + this.searchFilters = this.followService.buildFollowsListFilters() + + this.bulkFollowsActions = [ + { + label: $localize`Delete`, + handler: follows => this.removeFollowing(follows) + } + ] } getIdentifier () { @@ -46,17 +58,33 @@ export class FollowingListComponent extends RestTable implements OnInit { return follow.following.name === 'peertube' } - async removeFollowing (follow: ActorFollow) { - const res = await this.confirmService.confirm( - $localize`Do you really want to unfollow ${follow.following.host}?`, - $localize`Unfollow` + isInSelectionMode () { + return this.selectedFollows.length !== 0 + } + + buildFollowingName (follow: ActorFollow) { + return follow.following.name + '@' + follow.following.host + } + + async removeFollowing (follows: ActorFollow[]) { + const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)( + { count: follows.length, entryName: this.buildFollowingName(follows[0]) }, + $localize`Do you really want to unfollow these entries?` ) + + const res = await this.confirmService.confirm(message, $localize`Unfollow`) if (res === false) return - this.followService.unfollow(follow) + this.followService.unfollow(follows) .subscribe({ next: () => { - this.notifier.success($localize`You are not following ${follow.following.host} anymore.`) + // eslint-disable-next-line max-len + const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)( + { count: follows.length, entryName: this.buildFollowingName(follows[0]) }, + $localize`You are not following them anymore.` + ) + + this.notifier.success(message) this.reloadData() }, diff --git a/client/src/app/shared/shared-instance/instance-follow.service.ts b/client/src/app/shared/shared-instance/instance-follow.service.ts index 06484d938..5366fd068 100644 --- a/client/src/app/shared/shared-instance/instance-follow.service.ts +++ b/client/src/app/shared/shared-instance/instance-follow.service.ts @@ -1,9 +1,10 @@ import { SortMeta } from 'primeng/api' -import { Observable } from 'rxjs' -import { catchError, map } from 'rxjs/operators' +import { from, Observable } from 'rxjs' +import { catchError, concatMap, map, toArray } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' +import { arrayify } from '@shared/core-utils' import { ActivityPubActorType, ActorFollow, FollowState, ResultList, ServerFollowCreate } from '@shared/models' import { environment } from '../../../environments/environment' import { AdvancedInputFilter } from '../shared-forms' @@ -81,32 +82,64 @@ export class InstanceFollowService { .pipe(catchError(res => this.restExtractor.handleError(res))) } - unfollow (follow: ActorFollow) { - const handle = follow.following.name + '@' + follow.following.host + unfollow (followsArg: ActorFollow[] | ActorFollow) { + const follows = arrayify(followsArg) - return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + handle) - .pipe(catchError(res => this.restExtractor.handleError(res))) + return from(follows) + .pipe( + concatMap(follow => { + const handle = follow.following.name + '@' + follow.following.host + + return this.authHttp.delete(InstanceFollowService.BASE_APPLICATION_URL + '/following/' + handle) + }), + toArray(), + catchError(err => this.restExtractor.handleError(err)) + ) } - acceptFollower (follow: ActorFollow) { - const handle = follow.follower.name + '@' + follow.follower.host + acceptFollower (followsArg: ActorFollow[] | ActorFollow) { + const follows = arrayify(followsArg) - return this.authHttp.post(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}/accept`, {}) - .pipe(catchError(res => this.restExtractor.handleError(res))) + return from(follows) + .pipe( + concatMap(follow => { + const handle = follow.follower.name + '@' + follow.follower.host + + return this.authHttp.post(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}/accept`, {}) + }), + toArray(), + catchError(err => this.restExtractor.handleError(err)) + ) } - rejectFollower (follow: ActorFollow) { - const handle = follow.follower.name + '@' + follow.follower.host + rejectFollower (followsArg: ActorFollow[] | ActorFollow) { + const follows = arrayify(followsArg) - return this.authHttp.post(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}/reject`, {}) - .pipe(catchError(res => this.restExtractor.handleError(res))) + return from(follows) + .pipe( + concatMap(follow => { + const handle = follow.follower.name + '@' + follow.follower.host + + return this.authHttp.post(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}/reject`, {}) + }), + toArray(), + catchError(err => this.restExtractor.handleError(err)) + ) } - removeFollower (follow: ActorFollow) { - const handle = follow.follower.name + '@' + follow.follower.host + removeFollower (followsArg: ActorFollow[] | ActorFollow) { + const follows = arrayify(followsArg) - return this.authHttp.delete(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}`) - .pipe(catchError(res => this.restExtractor.handleError(res))) + return from(follows) + .pipe( + concatMap(follow => { + const handle = follow.follower.name + '@' + follow.follower.host + + return this.authHttp.delete(`${InstanceFollowService.BASE_APPLICATION_URL}/followers/${handle}`) + }), + toArray(), + catchError(err => this.restExtractor.handleError(err)) + ) } buildFollowsListFilters (): AdvancedInputFilter[] { diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 4fbc4f7f6..f2bf02695 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts @@ -5,6 +5,7 @@ import { HttpClient, HttpParams, HttpRequest } from '@angular/common/http' import { Injectable } from '@angular/core' import { AuthService, ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' import { objectToFormData } from '@app/helpers' +import { arrayify } from '@shared/core-utils' import { BooleanBothQuery, FeedFormat, @@ -285,7 +286,7 @@ export class VideoService { } removeVideo (idArg: number | number[]) { - const ids = Array.isArray(idArg) ? idArg : [ idArg ] + const ids = arrayify(idArg) return from(ids) .pipe( diff --git a/client/src/app/shared/shared-moderation/blocklist.service.ts b/client/src/app/shared/shared-moderation/blocklist.service.ts index 3e92c2831..1169bf757 100644 --- a/client/src/app/shared/shared-moderation/blocklist.service.ts +++ b/client/src/app/shared/shared-moderation/blocklist.service.ts @@ -4,6 +4,7 @@ import { catchError, concatMap, map, toArray } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' +import { arrayify } from '@shared/core-utils' import { AccountBlock as AccountBlockServer, BlockStatus, ResultList, ServerBlock } from '@shared/models' import { environment } from '../../../environments/environment' import { Account } from '../shared-main' @@ -122,7 +123,7 @@ export class BlocklistService { } blockAccountByInstance (accountsArg: Pick | Pick[]) { - const accounts = Array.isArray(accountsArg) ? accountsArg : [ accountsArg ] + const accounts = arrayify(accountsArg) return from(accounts) .pipe( diff --git a/client/src/app/shared/shared-moderation/video-block.service.ts b/client/src/app/shared/shared-moderation/video-block.service.ts index 5dfb0d7d4..6272b672f 100644 --- a/client/src/app/shared/shared-moderation/video-block.service.ts +++ b/client/src/app/shared/shared-moderation/video-block.service.ts @@ -4,6 +4,7 @@ import { catchError, concatMap, map, toArray } from 'rxjs/operators' import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService } from '@app/core' +import { arrayify } from '@shared/core-utils' import { ResultList, VideoBlacklist, VideoBlacklistType } from '@shared/models' import { environment } from '../../../environments/environment' @@ -53,7 +54,7 @@ export class VideoBlockService { } unblockVideo (videoIdArgs: number | number[]) { - const videoIds = Array.isArray(videoIdArgs) ? videoIdArgs : [ videoIdArgs ] + const videoIds = arrayify(videoIdArgs) return observableFrom(videoIds) .pipe( diff --git a/client/src/app/shared/shared-users/user-admin.service.ts b/client/src/app/shared/shared-users/user-admin.service.ts index 3db271c4a..422221d62 100644 --- a/client/src/app/shared/shared-users/user-admin.service.ts +++ b/client/src/app/shared/shared-users/user-admin.service.ts @@ -5,6 +5,7 @@ import { HttpClient, HttpParams } from '@angular/common/http' import { Injectable } from '@angular/core' import { RestExtractor, RestPagination, RestService, UserService } from '@app/core' import { getBytes } from '@root-helpers/bytes' +import { arrayify } from '@shared/core-utils' import { ResultList, User as UserServerModel, UserCreate, UserRole, UserUpdate } from '@shared/models' @Injectable() @@ -65,7 +66,7 @@ export class UserAdminService { } removeUser (usersArg: UserServerModel | UserServerModel[]) { - const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] + const users = arrayify(usersArg) return from(users) .pipe( @@ -77,7 +78,7 @@ export class UserAdminService { banUsers (usersArg: UserServerModel | UserServerModel[], reason?: string) { const body = reason ? { reason } : {} - const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] + const users = arrayify(usersArg) return from(users) .pipe( @@ -88,7 +89,7 @@ export class UserAdminService { } unbanUsers (usersArg: UserServerModel | UserServerModel[]) { - const users = Array.isArray(usersArg) ? usersArg : [ usersArg ] + const users = arrayify(usersArg) return from(users) .pipe( diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts index 566bb5f31..127b29ad7 100644 --- a/server/models/actor/actor-follow.ts +++ b/server/models/actor/actor-follow.ts @@ -276,7 +276,7 @@ export class ActorFollowModel extends Model> = { actorId} + const where: WhereAttributeHash> = { actorId } if (state) where.state = state const query: FindOptions> = { diff --git a/shared/core-utils/common/array.ts b/shared/core-utils/common/array.ts index 9e326a5aa..95393c731 100644 --- a/shared/core-utils/common/array.ts +++ b/shared/core-utils/common/array.ts @@ -8,6 +8,14 @@ function findCommonElement (array1: T[], array2: T[]) { return null } +// Avoid conflict with other toArray() functions +function arrayify (element: T | T[]) { + if (Array.isArray(element)) return element + + return [ element ] +} + export { - findCommonElement + findCommonElement, + arrayify } -- 2.41.0