Verified Commit e3d6c643 authored by Chocobozzz's avatar Chocobozzz
Browse files

Add bulk action on following/followers

parent 073deef8
......@@ -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"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="left-buttons">
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
[actions]="bulkFollowsActions" [entry]="selectedFollows"
>
</my-action-dropdown>
</div>
<div class="ms-auto">
<my-advanced-input-filter [filters]="searchFilters" (search)="onSearch($event)"></my-advanced-input-filter>
</div>
......@@ -20,6 +29,9 @@
<ng-template pTemplate="header">
<tr>
<th style="width: 40px">
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th>
<th style="width: 150px;" i18n>Actions</th>
<th i18n>Follower</th>
<th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
......@@ -30,15 +42,19 @@
<ng-template pTemplate="body" let-follow>
<tr>
<td class="checkbox-cell">
<p-tableCheckbox [value]="follow" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
</td>
<td class="action-cell">
<my-button *ngIf="follow.state !== 'accepted'" i18n-title title="Accept" icon="tick" (click)="acceptFollower(follow)"></my-button>
<my-button *ngIf="follow.state !== 'rejected'" i18n-title title="Refuse" icon="cross" (click)="rejectFollower(follow)"></my-button>
<my-button *ngIf="follow.state !== 'accepted'" i18n-title title="Accept" icon="tick" (click)="acceptFollower([ follow ])"></my-button>
<my-button *ngIf="follow.state !== 'rejected'" i18n-title title="Reject" icon="cross" (click)="rejectFollower([ follow ])"></my-button>
<my-delete-button *ngIf="follow.state === 'rejected'" (click)="deleteFollower(follow)"></my-delete-button>
<my-delete-button *ngIf="follow.state === 'rejected'" (click)="deleteFollowers([ follow ])"></my-delete-button>
</td>
<td>
<a [href]="follow.follower.url" i18n-title title="Open actor page in a new tab" target="_blank" rel="noopener noreferrer">
{{ follow.follower.name + '@' + follow.follower.host }}
{{ buildFollowerName(follow) }}
<my-global-icon iconName="external-link"></my-global-icon>
</a>
</td>
......@@ -56,7 +72,7 @@
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="5">
<td colspan="6">
<div class="no-results">
<ng-container *ngIf="search" i18n>No follower found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>Your instance doesn't have any follower.</ng-container>
......
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<ActorFollow[]>[] = []
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 += '<br /><br />'
// 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({
......
......@@ -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"
>
<ng-template pTemplate="caption">
<div class="caption">
<div class="left-buttons">
<a class="follow-button" (click)="openFollowModal()" (key.enter)="openFollowModal()">
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
[actions]="bulkFollowsActions" [entry]="selectedFollows"
>
</my-action-dropdown>
<a *ngIf="!isInSelectionMode()" class="follow-button" (click)="openFollowModal()" (key.enter)="openFollowModal()">
<my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
<ng-container i18n>Follow</ng-container>
</a>
......@@ -27,6 +34,9 @@
<ng-template pTemplate="header">
<tr>
<th style="width: 40px">
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th>
<th style="width: 150px;" i18n>Action</th>
<th i18n>Following</th>
<th style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
......@@ -35,14 +45,18 @@
</tr>
</ng-template>
<ng-template pTemplate="body" let-follow>
<ng-template pSelectableRow="follow" pTemplate="body" let-follow>
<tr>
<td class="checkbox-cell">
<p-tableCheckbox [value]="follow" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
</td>
<td class="action-cell">
<my-delete-button label (click)="removeFollowing(follow)"></my-delete-button>
<my-delete-button label (click)="removeFollowing([ follow ])"></my-delete-button>
</td>
<td>
<a [href]="follow.following.url" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
{{ follow.following.name + '@' + follow.following.host }}
{{ buildFollowingName(follow) }}
<my-global-icon iconName="external-link"></my-global-icon>
</a>
</td>
......@@ -65,7 +79,7 @@
<ng-template pTemplate="emptymessage">
<tr>
<td colspan="5">
<td colspan="6">
<div class="no-results">
<ng-container *ngIf="search" i18n>No host found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>Your instance is not following anyone.</ng-container>
......
......@@ -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<ActorFollow[]>[] = []
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()
},
......
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[] {
......
......@@ -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(
......
......@@ -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<Account, 'nameWithHost'> | Pick<Account, 'nameWithHost'>[]) {
const accounts = Array.isArray(accountsArg) ? accountsArg : [ accountsArg ]
const accounts = arrayify(accountsArg)
return from(accounts)
.pipe(
......
......@@ -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(
......
......@@ -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(
......
......@@ -276,7 +276,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
})
}
const where: WhereAttributeHash<Attributes<ActorFollowModel>> = { actorId}
const where: WhereAttributeHash<Attributes<ActorFollowModel>> = { actorId }
if (state) where.state = state
const query: FindOptions<Attributes<ActorFollowModel>> = {
......
......@@ -8,6 +8,14 @@ function findCommonElement <T> (array1: T[], array2: T[]) {
return null
}
// Avoid conflict with other toArray() functions
function arrayify <T> (element: T | T[]) {
if (Array.isArray(element)) return element