aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+about/about-follows/about-follows.component.ts4
-rw-r--r--client/src/app/+accounts/accounts.component.scss2
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html2
-rw-r--r--client/src/app/+admin/plugins/shared/plugin-api.service.ts4
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.html7
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss4
-rw-r--r--client/src/app/+my-library/my-follows/my-followers.component.html31
-rw-r--r--client/src/app/+my-library/my-follows/my-followers.component.scss26
-rw-r--r--client/src/app/+my-library/my-follows/my-followers.component.ts76
-rw-r--r--client/src/app/+my-library/my-follows/my-subscriptions.component.html (renamed from client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html)14
-rw-r--r--client/src/app/+my-library/my-follows/my-subscriptions.component.scss16
-rw-r--r--client/src/app/+my-library/my-follows/my-subscriptions.component.ts (renamed from client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts)0
-rw-r--r--client/src/app/+my-library/my-library-routing.module.ts12
-rw-r--r--client/src/app/+my-library/my-library.component.ts15
-rw-r--r--client/src/app/+my-library/my-library.module.ts6
-rw-r--r--client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss84
-rw-r--r--client/src/app/+video-channels/video-channels.component.scss2
-rw-r--r--client/src/app/core/rest/rest.service.ts16
-rw-r--r--client/src/app/shared/shared-custom-markup/custom-markup-help.component.html2
-rw-r--r--client/src/app/shared/shared-forms/advanced-input-filter.component.ts2
-rw-r--r--client/src/app/shared/shared-main/users/user-history.service.ts2
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.service.ts2
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html2
-rw-r--r--client/src/app/shared/shared-main/video-channel/video-channel.service.ts2
-rw-r--r--client/src/app/shared/shared-main/video/video.service.ts4
-rw-r--r--client/src/app/shared/shared-search/search.service.ts6
-rw-r--r--client/src/app/shared/shared-user-subscription/user-subscription.service.ts41
-rw-r--r--client/src/app/shared/shared-video-comment/video-comment.service.ts2
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist.service.ts6
-rw-r--r--client/src/sass/include/_account-channel-page.scss88
-rw-r--r--client/src/sass/include/_actor.scss104
-rw-r--r--client/src/sass/include/_mixins.scss9
-rw-r--r--server/controllers/api/accounts.ts33
-rw-r--r--server/controllers/api/server/follows.ts4
-rw-r--r--server/controllers/api/users/my-subscriptions.ts2
-rw-r--r--server/controllers/api/video-channel.ts55
-rw-r--r--server/initializers/constants.ts3
-rw-r--r--server/middlewares/validators/sort.ts5
-rw-r--r--server/middlewares/validators/users.ts22
-rw-r--r--server/middlewares/validators/videos/video-channels.ts16
-rw-r--r--server/models/actor/actor-follow.ts22
-rw-r--r--server/models/video/video-channel.ts24
-rw-r--r--server/tests/api/check-params/users.ts28
-rw-r--r--server/tests/api/check-params/video-channels.ts28
-rw-r--r--server/tests/api/users/user-subscriptions.ts156
-rw-r--r--shared/extra-utils/users/accounts-command.ts24
-rw-r--r--shared/extra-utils/videos/channels-command.ts26
47 files changed, 796 insertions, 245 deletions
diff --git a/client/src/app/+about/about-follows/about-follows.component.ts b/client/src/app/+about/about-follows/about-follows.component.ts
index a35272681..84b47e967 100644
--- a/client/src/app/+about/about-follows/about-follows.component.ts
+++ b/client/src/app/+about/about-follows/about-follows.component.ts
@@ -88,7 +88,7 @@ export class AboutFollowsComponent implements OnInit {
88 } 88 }
89 89
90 private loadMoreFollowers (reset = false) { 90 private loadMoreFollowers (reset = false) {
91 const pagination = this.restService.componentPaginationToRestPagination(this.followersPagination) 91 const pagination = this.restService.componentToRestPagination(this.followersPagination)
92 92
93 this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' }) 93 this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' })
94 .subscribe({ 94 .subscribe({
@@ -106,7 +106,7 @@ export class AboutFollowsComponent implements OnInit {
106 } 106 }
107 107
108 private loadMoreFollowings (reset = false) { 108 private loadMoreFollowings (reset = false) {
109 const pagination = this.restService.componentPaginationToRestPagination(this.followingsPagination) 109 const pagination = this.restService.componentToRestPagination(this.followingsPagination)
110 110
111 this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' }) 111 this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' })
112 .subscribe({ 112 .subscribe({
diff --git a/client/src/app/+accounts/accounts.component.scss b/client/src/app/+accounts/accounts.component.scss
index c4e2159d1..cdd00487b 100644
--- a/client/src/app/+accounts/accounts.component.scss
+++ b/client/src/app/+accounts/accounts.component.scss
@@ -1,6 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3@use '_actor' as *; 3@use '_account-channel-page' as *;
4@use '_miniature' as *; 4@use '_miniature' as *;
5 5
6.root { 6.root {
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
index 1f542e458..537e06d4d 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
@@ -416,7 +416,7 @@
416 <p i18n>⚠️ This functionality requires a lot of attention and extra moderation.</p> 416 <p i18n>⚠️ This functionality requires a lot of attention and extra moderation.</p>
417 417
418 <span i18n> 418 <span i18n>
419 See <a href="https://docs.joinpeertube.org/admin-following-instances?id=automatically-follow-other-instances" rel="noopener noreferer" target="_blank">the documentation</a> for more information about the expected URL 419 See <a href="https://docs.joinpeertube.org/admin-following-instances?id=automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL
420 </span> 420 </span>
421 </ng-container> 421 </ng-container>
422 422
diff --git a/client/src/app/+admin/plugins/shared/plugin-api.service.ts b/client/src/app/+admin/plugins/shared/plugin-api.service.ts
index c4f480cae..b95ee0c9d 100644
--- a/client/src/app/+admin/plugins/shared/plugin-api.service.ts
+++ b/client/src/app/+admin/plugins/shared/plugin-api.service.ts
@@ -51,7 +51,7 @@ export class PluginApiService {
51 componentPagination: ComponentPagination, 51 componentPagination: ComponentPagination,
52 sort: string 52 sort: string
53 ) { 53 ) {
54 const pagination = this.restService.componentPaginationToRestPagination(componentPagination) 54 const pagination = this.restService.componentToRestPagination(componentPagination)
55 55
56 let params = new HttpParams() 56 let params = new HttpParams()
57 params = this.restService.addRestGetParams(params, pagination, sort) 57 params = this.restService.addRestGetParams(params, pagination, sort)
@@ -67,7 +67,7 @@ export class PluginApiService {
67 sort: string, 67 sort: string,
68 search?: string 68 search?: string
69 ) { 69 ) {
70 const pagination = this.restService.componentPaginationToRestPagination(componentPagination) 70 const pagination = this.restService.componentToRestPagination(componentPagination)
71 71
72 let params = new HttpParams() 72 let params = new HttpParams()
73 params = this.restService.addRestGetParams(params, pagination, sort) 73 params = this.restService.addRestGetParams(params, pagination, sort)
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
index 4c5b46d5b..bbe583971 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.html
@@ -27,7 +27,12 @@
27 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> 27 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
28 </a> 28 </a>
29 29
30 <div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div> 30 <a
31 i18n class="video-channel-followers"
32 [routerLink]="[ '/my-library', 'followers' ]" [queryParams]="{ search: 'channel:' + videoChannel.name }"
33 >
34 {videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}
35 </a>
31 36
32 <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div> 37 <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>
33 38
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss
index 9ef5513b6..998e46cb2 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.scss
@@ -54,6 +54,10 @@ my-edit-button {
54 color: $grey-actor-name; 54 color: $grey-actor-name;
55} 55}
56 56
57.video-channel-followers {
58 color: pvar(--mainForegroundColor);
59}
60
57.video-channel-buttons { 61.video-channel-buttons {
58 margin-top: 10px; 62 margin-top: 10px;
59 min-width: 190px; 63 min-width: 190px;
diff --git a/client/src/app/+my-library/my-follows/my-followers.component.html b/client/src/app/+my-library/my-follows/my-followers.component.html
new file mode 100644
index 000000000..d2b2dccb6
--- /dev/null
+++ b/client/src/app/+my-library/my-follows/my-followers.component.html
@@ -0,0 +1,31 @@
1<h1>
2 <span>
3 <my-global-icon iconName="follower" aria-hidden="true"></my-global-icon>
4 <ng-container i18n>My followers</ng-container>
5 <span class="badge badge-secondary"> {{ pagination.totalItems }}</span>
6 </span>
7</h1>
8
9<div class="followers-header">
10 <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
11</div>
12
13<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No follower found.</div>
14
15<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
16 <div *ngFor="let follow of follows" class="actor">
17 <my-actor-avatar [account]="follow.follower" [href]="follow.follower.url"></my-actor-avatar>
18
19 <div class="actor-info">
20 <a [href]="follow.follower.url" class="actor-names" rel="noopener noreferrer" target="_blank" i18n-title title="Follower page">
21 <div class="actor-display-name">{{ follow.follower.name + '@' + follow.follower.host }}</div>
22 <span class="glyphicon glyphicon-new-window"></span>
23 </a>
24
25 <div class="text-muted">
26 <ng-container *ngIf="isFollowingAccount(follow)" i18n>Is following all your channels</ng-container>
27 <ng-container *ngIf="!isFollowingAccount(follow)" i18n>Is following your channel {{ follow.following.name }}</ng-container>
28 </div>
29 </div>
30 </div>
31</div>
diff --git a/client/src/app/+my-library/my-follows/my-followers.component.scss b/client/src/app/+my-library/my-follows/my-followers.component.scss
new file mode 100644
index 000000000..15b51c419
--- /dev/null
+++ b/client/src/app/+my-library/my-follows/my-followers.component.scss
@@ -0,0 +1,26 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3@use '_actor' as *;
4
5.followers-header {
6 margin-bottom: 30px;
7 display: flex;
8}
9
10input[type=text] {
11 @include peertube-input-text(300px);
12}
13
14.actor {
15 @include actor-row($avatar-size: 40px, $min-height: auto, $separator: true);
16
17 .actor-display-name {
18 font-size: 16px;
19
20 + .glyphicon {
21 @include margin-left(5px);
22
23 font-size: 12px;
24 }
25 }
26}
diff --git a/client/src/app/+my-library/my-follows/my-followers.component.ts b/client/src/app/+my-library/my-follows/my-followers.component.ts
new file mode 100644
index 000000000..a7bbe6d99
--- /dev/null
+++ b/client/src/app/+my-library/my-follows/my-followers.component.ts
@@ -0,0 +1,76 @@
1import { Subject } from 'rxjs'
2import { Component, OnInit } from '@angular/core'
3import { ActivatedRoute } from '@angular/router'
4import { AuthService, ComponentPagination, Notifier } from '@app/core'
5import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
6import { ActorFollow } from '@shared/models'
7
8@Component({
9 templateUrl: './my-followers.component.html',
10 styleUrls: [ './my-followers.component.scss' ]
11})
12export class MyFollowersComponent implements OnInit {
13 follows: ActorFollow[] = []
14
15 pagination: ComponentPagination = {
16 currentPage: 1,
17 itemsPerPage: 10,
18 totalItems: null
19 }
20
21 onDataSubject = new Subject<any[]>()
22 search: string
23
24 constructor (
25 private route: ActivatedRoute,
26 private auth: AuthService,
27 private userSubscriptionService: UserSubscriptionService,
28 private notifier: Notifier
29 ) {}
30
31 ngOnInit () {
32 if (this.route.snapshot.queryParams['search']) {
33 this.search = this.route.snapshot.queryParams['search']
34 }
35 }
36
37 onNearOfBottom () {
38 // Last page
39 if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
40
41 this.pagination.currentPage += 1
42 this.loadFollowers()
43 }
44
45 onSearch (search: string) {
46 this.search = search
47 this.loadFollowers(false)
48 }
49
50 isFollowingAccount (follow: ActorFollow) {
51 return follow.following.name === this.getUsername()
52 }
53
54 private loadFollowers (more = true) {
55 this.userSubscriptionService.listFollowers({
56 pagination: this.pagination,
57 nameWithHost: this.getUsername(),
58 search: this.search
59 }).subscribe({
60 next: res => {
61 this.follows = more
62 ? this.follows.concat(res.data)
63 : res.data
64 this.pagination.totalItems = res.total
65
66 this.onDataSubject.next(res.data)
67 },
68
69 error: err => this.notifier.error(err.message)
70 })
71 }
72
73 private getUsername () {
74 return this.auth.getUser().username
75 }
76}
diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html b/client/src/app/+my-library/my-follows/my-subscriptions.component.html
index ca5ad794a..775f0e783 100644
--- a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.html
+++ b/client/src/app/+my-library/my-follows/my-subscriptions.component.html
@@ -12,17 +12,17 @@
12 12
13<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div> 13<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div>
14 14
15<div class="video-channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> 15<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
16 <div *ngFor="let videoChannel of videoChannels" class="video-channel"> 16 <div *ngFor="let videoChannel of videoChannels" class="actor">
17 <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar> 17 <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
18 18
19 <div class="video-channel-info"> 19 <div class="actor-info">
20 <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page"> 20 <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="actor-names" i18n-title title="Channel page">
21 <div class="video-channel-display-name">{{ videoChannel.displayName }}</div> 21 <div class="actor-display-name">{{ videoChannel.displayName }}</div>
22 <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> 22 <div class="actor-name">{{ videoChannel.nameWithHost }}</div>
23 </a> 23 </a>
24 24
25 <div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div> 25 <div i18n class="actor-followers">{{ videoChannel.followersCount }} subscribers</div>
26 26
27 <a [routerLink]="[ '/a', videoChannel.ownerBy ]" i18n-title title="Owner account page" class="actor-owner"> 27 <a [routerLink]="[ '/a', videoChannel.ownerBy ]" i18n-title title="Owner account page" class="actor-owner">
28 <span i18n>Created by {{ videoChannel.ownerBy }}</span> 28 <span i18n>Created by {{ videoChannel.ownerBy }}</span>
diff --git a/client/src/app/+my-library/my-follows/my-subscriptions.component.scss b/client/src/app/+my-library/my-follows/my-subscriptions.component.scss
new file mode 100644
index 000000000..310e11cb0
--- /dev/null
+++ b/client/src/app/+my-library/my-follows/my-subscriptions.component.scss
@@ -0,0 +1,16 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3@use '_actor' as *;
4
5.video-subscriptions-header {
6 margin-bottom: 30px;
7 display: flex;
8}
9
10input[type=text] {
11 @include peertube-input-text(300px);
12}
13
14.actor {
15 @include actor-row;
16}
diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts b/client/src/app/+my-library/my-follows/my-subscriptions.component.ts
index f676aa014..f676aa014 100644
--- a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.ts
+++ b/client/src/app/+my-library/my-follows/my-subscriptions.component.ts
diff --git a/client/src/app/+my-library/my-library-routing.module.ts b/client/src/app/+my-library/my-library-routing.module.ts
index 76894bed8..73858fb82 100644
--- a/client/src/app/+my-library/my-library-routing.module.ts
+++ b/client/src/app/+my-library/my-library-routing.module.ts
@@ -1,10 +1,11 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { RouterModule, Routes } from '@angular/router' 2import { RouterModule, Routes } from '@angular/router'
3import { LoginGuard } from '../core' 3import { LoginGuard } from '../core'
4import { MyFollowersComponent } from './my-follows/my-followers.component'
5import { MySubscriptionsComponent } from './my-follows/my-subscriptions.component'
4import { MyHistoryComponent } from './my-history/my-history.component' 6import { MyHistoryComponent } from './my-history/my-history.component'
5import { MyLibraryComponent } from './my-library.component' 7import { MyLibraryComponent } from './my-library.component'
6import { MyOwnershipComponent } from './my-ownership/my-ownership.component' 8import { MyOwnershipComponent } from './my-ownership/my-ownership.component'
7import { MySubscriptionsComponent } from './my-subscriptions/my-subscriptions.component'
8import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component' 9import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component'
9import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component' 10import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component'
10import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component' 11import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component'
@@ -100,6 +101,15 @@ const myLibraryRoutes: Routes = [
100 } 101 }
101 }, 102 },
102 { 103 {
104 path: 'followers',
105 component: MyFollowersComponent,
106 data: {
107 meta: {
108 title: $localize`My followers`
109 }
110 }
111 },
112 {
103 path: 'ownership', 113 path: 'ownership',
104 component: MyOwnershipComponent, 114 component: MyOwnershipComponent,
105 data: { 115 data: {
diff --git a/client/src/app/+my-library/my-library.component.ts b/client/src/app/+my-library/my-library.component.ts
index 16a7f63e3..ff901952f 100644
--- a/client/src/app/+my-library/my-library.component.ts
+++ b/client/src/app/+my-library/my-library.component.ts
@@ -61,8 +61,19 @@ export class MyLibraryComponent implements OnInit {
61 }, 61 },
62 62
63 { 63 {
64 label: $localize`Subscriptions`, 64 label: $localize`Follows`,
65 routerLink: '/my-library/subscriptions' 65 children: [
66 {
67 label: $localize`Subscriptions`,
68 iconName: 'subscriptions',
69 routerLink: '/my-library/subscriptions'
70 },
71 {
72 label: $localize`Followers`,
73 iconName: 'follower',
74 routerLink: '/my-library/followers'
75 }
76 ]
66 }, 77 },
67 78
68 { 79 {
diff --git a/client/src/app/+my-library/my-library.module.ts b/client/src/app/+my-library/my-library.module.ts
index 264ad03f7..360c53589 100644
--- a/client/src/app/+my-library/my-library.module.ts
+++ b/client/src/app/+my-library/my-library.module.ts
@@ -13,12 +13,13 @@ import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscripti
13import { SharedVideoLiveModule } from '@app/shared/shared-video-live' 13import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
14import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' 14import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
15import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module' 15import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module'
16import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
17import { MySubscriptionsComponent } from './my-follows/my-subscriptions.component'
16import { MyHistoryComponent } from './my-history/my-history.component' 18import { MyHistoryComponent } from './my-history/my-history.component'
17import { MyLibraryRoutingModule } from './my-library-routing.module' 19import { MyLibraryRoutingModule } from './my-library-routing.module'
18import { MyLibraryComponent } from './my-library.component' 20import { MyLibraryComponent } from './my-library.component'
19import { MyAcceptOwnershipComponent } from './my-ownership/my-accept-ownership/my-accept-ownership.component' 21import { MyAcceptOwnershipComponent } from './my-ownership/my-accept-ownership/my-accept-ownership.component'
20import { MyOwnershipComponent } from './my-ownership/my-ownership.component' 22import { MyOwnershipComponent } from './my-ownership/my-ownership.component'
21import { MySubscriptionsComponent } from './my-subscriptions/my-subscriptions.component'
22import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component' 23import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component'
23import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component' 24import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component'
24import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component' 25import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component'
@@ -26,7 +27,7 @@ import { MyVideoPlaylistUpdateComponent } from './my-video-playlists/my-video-pl
26import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component' 27import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component'
27import { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.component' 28import { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.component'
28import { MyVideosComponent } from './my-videos/my-videos.component' 29import { MyVideosComponent } from './my-videos/my-videos.component'
29import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' 30import { MyFollowersComponent } from './my-follows/my-followers.component'
30 31
31@NgModule({ 32@NgModule({
32 imports: [ 33 imports: [
@@ -61,6 +62,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto
61 MyAcceptOwnershipComponent, 62 MyAcceptOwnershipComponent,
62 MyVideoImportsComponent, 63 MyVideoImportsComponent,
63 MySubscriptionsComponent, 64 MySubscriptionsComponent,
65 MyFollowersComponent,
64 MyHistoryComponent, 66 MyHistoryComponent,
65 67
66 MyVideoPlaylistCreateComponent, 68 MyVideoPlaylistCreateComponent,
diff --git a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss b/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss
deleted file mode 100644
index edca06a66..000000000
--- a/client/src/app/+my-library/my-subscriptions/my-subscriptions.component.scss
+++ /dev/null
@@ -1,84 +0,0 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4input[type=text] {
5 @include peertube-input-text(300px);
6}
7
8.video-channel {
9 @include row-blocks;
10
11 > my-actor-avatar {
12 @include actor-avatar-size(80px);
13
14 @include margin-right(10px);
15 }
16}
17
18.video-channel-info {
19 flex-grow: 1;
20
21 a.video-channel-names {
22 @include disable-default-a-behaviour;
23
24 width: fit-content;
25 display: flex;
26 align-items: baseline;
27 color: pvar(--mainForegroundColor);
28
29 .video-channel-display-name {
30 font-weight: $font-semibold;
31 font-size: 18px;
32 }
33
34 .video-channel-name {
35 @include margin-left(5px);
36
37 font-size: 14px;
38 color: $grey-actor-name;
39 }
40 }
41}
42
43.actor-owner {
44 @include disable-default-a-behaviour;
45
46 font-size: 13px;
47 color: pvar(--mainForegroundColor);
48
49 span:hover {
50 opacity: 0.8;
51 }
52
53 my-actor-avatar {
54 @include margin-left(7px);
55 display: inline-block;
56 vertical-align: top;
57 }
58}
59
60.video-subscriptions-header {
61 margin-bottom: 30px;
62 display: flex;
63}
64
65@media screen and (max-width: $small-view) {
66 .video-subscriptions-header input[type=text] {
67 width: 100% !important;
68 }
69
70 .video-channel-info {
71 padding-bottom: 10px;
72 text-align: center;
73
74 .video-channel-names {
75 flex-direction: column;
76 align-items: center !important;
77 margin: auto;
78 }
79 }
80
81 img {
82 @include margin-right(0);
83 }
84}
diff --git a/client/src/app/+video-channels/video-channels.component.scss b/client/src/app/+video-channels/video-channels.component.scss
index d174dcd62..72ee2d7bb 100644
--- a/client/src/app/+video-channels/video-channels.component.scss
+++ b/client/src/app/+video-channels/video-channels.component.scss
@@ -1,6 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3@use '_actor' as *; 3@use '_account-channel-page' as *;
4@use '_miniature' as *; 4@use '_miniature' as *;
5 5
6.root { 6.root {
diff --git a/client/src/app/core/rest/rest.service.ts b/client/src/app/core/rest/rest.service.ts
index 98e45ffc0..93b5f56b2 100644
--- a/client/src/app/core/rest/rest.service.ts
+++ b/client/src/app/core/rest/rest.service.ts
@@ -13,9 +13,8 @@ interface QueryStringFilterPrefixes {
13 } 13 }
14} 14}
15 15
16type ParseQueryStringFilterResult = { 16type ParseQueryStringFilters <K extends keyof any> = Partial<Record<K, string | number | boolean | (string | number | boolean)[]>>
17 [key: string]: string | number | boolean | (string | number | boolean)[] 17type ParseQueryStringFiltersResult <K extends keyof any> = ParseQueryStringFilters<K> & { search?: string }
18}
19 18
20@Injectable() 19@Injectable()
21export class RestService { 20export class RestService {
@@ -67,14 +66,17 @@ export class RestService {
67 return params 66 return params
68 } 67 }
69 68
70 componentPaginationToRestPagination (componentPagination: ComponentPaginationLight): RestPagination { 69 componentToRestPagination (componentPagination: ComponentPaginationLight): RestPagination {
71 const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage 70 const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
72 const count: number = componentPagination.itemsPerPage 71 const count: number = componentPagination.itemsPerPage
73 72
74 return { start, count } 73 return { start, count }
75 } 74 }
76 75
77 parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult { 76 /*
77 * Returns an object containing the filters and the remaining search
78 */
79 parseQueryStringFilter <T extends QueryStringFilterPrefixes> (q: string, prefixes: T): ParseQueryStringFiltersResult<keyof T> {
78 if (!q) return {} 80 if (!q) return {}
79 81
80 // Tokenize the strings using spaces that are not in quotes 82 // Tokenize the strings using spaces that are not in quotes
@@ -90,9 +92,9 @@ export class RestService {
90 return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false) 92 return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false)
91 }) 93 })
92 94
93 const additionalFilters: ParseQueryStringFilterResult = {} 95 const additionalFilters: ParseQueryStringFilters<keyof T> = {}
94 96
95 for (const prefixKey of Object.keys(prefixes)) { 97 for (const prefixKey of Object.keys(prefixes) as (keyof T)[]) {
96 const prefixObj = prefixes[prefixKey] 98 const prefixObj = prefixes[prefixKey]
97 const prefix = prefixObj.prefix 99 const prefix = prefixObj.prefix
98 100
diff --git a/client/src/app/shared/shared-custom-markup/custom-markup-help.component.html b/client/src/app/shared/shared-custom-markup/custom-markup-help.component.html
index dd7a56d7d..0ca84ff78 100644
--- a/client/src/app/shared/shared-custom-markup/custom-markup-help.component.html
+++ b/client/src/app/shared/shared-custom-markup/custom-markup-help.component.html
@@ -1,3 +1,3 @@
1<ng-container i18n> 1<ng-container i18n>
2 <a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noreferer noopener">Markdown compatible</a> that also supports <a href="https://docs.joinpeertube.org/api-custom-client-markup" target="_blank" rel="noreferer noopener">custom PeerTube HTML tags</a> 2 <a href="https://en.wikipedia.org/wiki/Markdown#Example" target="_blank" rel="noreferrer noopener">Markdown compatible</a> that also supports <a href="https://docs.joinpeertube.org/api-custom-client-markup" target="_blank" rel="noreferrer noopener">custom PeerTube HTML tags</a>
3</ng-container> 3</ng-container>
diff --git a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts
index 72cd6d460..113219f48 100644
--- a/client/src/app/shared/shared-forms/advanced-input-filter.component.ts
+++ b/client/src/app/shared/shared-forms/advanced-input-filter.component.ts
@@ -77,6 +77,8 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
77 77
78 logger('On route search change "%s".', search) 78 logger('On route search change "%s".', search)
79 79
80 if (this.searchValue === search) return
81
80 this.searchValue = search 82 this.searchValue = search
81 this.emitSearch() 83 this.emitSearch()
82 }) 84 })
diff --git a/client/src/app/shared/shared-main/users/user-history.service.ts b/client/src/app/shared/shared-main/users/user-history.service.ts
index 91268af8c..a4841897d 100644
--- a/client/src/app/shared/shared-main/users/user-history.service.ts
+++ b/client/src/app/shared/shared-main/users/user-history.service.ts
@@ -19,7 +19,7 @@ export class UserHistoryService {
19 ) {} 19 ) {}
20 20
21 getUserVideosHistory (historyPagination: ComponentPaginationLight, search?: string) { 21 getUserVideosHistory (historyPagination: ComponentPaginationLight, search?: string) {
22 const pagination = this.restService.componentPaginationToRestPagination(historyPagination) 22 const pagination = this.restService.componentToRestPagination(historyPagination)
23 23
24 let params = new HttpParams() 24 let params = new HttpParams()
25 params = this.restService.addRestGetParams(params, pagination) 25 params = this.restService.addRestGetParams(params, pagination)
diff --git a/client/src/app/shared/shared-main/users/user-notification.service.ts b/client/src/app/shared/shared-main/users/user-notification.service.ts
index 09fee87a3..e27dab21a 100644
--- a/client/src/app/shared/shared-main/users/user-notification.service.ts
+++ b/client/src/app/shared/shared-main/users/user-notification.service.ts
@@ -29,7 +29,7 @@ export class UserNotificationService {
29 const { pagination, ignoreLoadingBar, unread, sort } = parameters 29 const { pagination, ignoreLoadingBar, unread, sort } = parameters
30 30
31 let params = new HttpParams() 31 let params = new HttpParams()
32 params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination), sort) 32 params = this.restService.addRestGetParams(params, this.restService.componentToRestPagination(pagination), sort)
33 33
34 if (unread) params = params.append('unread', `${unread}`) 34 if (unread) params = params.append('unread', `${unread}`)
35 35
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html
index ee8df864a..9af6da784 100644
--- a/client/src/app/shared/shared-main/users/user-notifications.component.html
+++ b/client/src/app/shared/shared-main/users/user-notifications.component.html
@@ -203,7 +203,7 @@
203 <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon> 203 <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
204 204
205 <div class="message" i18n> 205 <div class="message" i18n>
206 <a (click)="markAsRead(notification)" [href]="notification.peertubeVersionLink" target="_blank" rel="noopener noreferer">A new version of PeerTube</a> is available: {{ notification.peertube.latestVersion }} 206 <a (click)="markAsRead(notification)" [href]="notification.peertubeVersionLink" target="_blank" rel="noopener noreferrer">A new version of PeerTube</a> is available: {{ notification.peertube.latestVersion }}
207 </div> 207 </div>
208 </ng-container> 208 </ng-container>
209 209
diff --git a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts
index 7560a35a8..dc00fabdc 100644
--- a/client/src/app/shared/shared-main/video-channel/video-channel.service.ts
+++ b/client/src/app/shared/shared-main/video-channel/video-channel.service.ts
@@ -50,7 +50,7 @@ export class VideoChannelService {
50 const { account, componentPagination, withStats = false, sort, search } = options 50 const { account, componentPagination, withStats = false, sort, search } = options
51 51
52 const pagination = componentPagination 52 const pagination = componentPagination
53 ? this.restService.componentPaginationToRestPagination(componentPagination) 53 ? this.restService.componentToRestPagination(componentPagination)
54 : { start: 0, count: 20 } 54 : { start: 0, count: 20 }
55 55
56 let params = new HttpParams() 56 let params = new HttpParams()
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 3481b116f..2f43f1b9d 100644
--- a/client/src/app/shared/shared-main/video/video.service.ts
+++ b/client/src/app/shared/shared-main/video/video.service.ts
@@ -123,7 +123,7 @@ export class VideoService {
123 } 123 }
124 124
125 getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> { 125 getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> {
126 const pagination = this.restService.componentPaginationToRestPagination(videoPagination) 126 const pagination = this.restService.componentToRestPagination(videoPagination)
127 127
128 let params = new HttpParams() 128 let params = new HttpParams()
129 params = this.restService.addRestGetParams(params, pagination, sort) 129 params = this.restService.addRestGetParams(params, pagination, sort)
@@ -377,7 +377,7 @@ export class VideoService {
377 private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) { 377 private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
378 const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options 378 const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options
379 379
380 const pagination = this.restService.componentPaginationToRestPagination(videoPagination) 380 const pagination = this.restService.componentToRestPagination(videoPagination)
381 let newParams = this.restService.addRestGetParams(params, pagination, sort) 381 let newParams = this.restService.addRestGetParams(params, pagination, sort)
382 382
383 if (filter) newParams = newParams.set('filter', filter) 383 if (filter) newParams = newParams.set('filter', filter)
diff --git a/client/src/app/shared/shared-search/search.service.ts b/client/src/app/shared/shared-search/search.service.ts
index fdfab0e0e..71350c733 100644
--- a/client/src/app/shared/shared-search/search.service.ts
+++ b/client/src/app/shared/shared-search/search.service.ts
@@ -43,7 +43,7 @@ export class SearchService {
43 let pagination: RestPagination 43 let pagination: RestPagination
44 44
45 if (componentPagination) { 45 if (componentPagination) {
46 pagination = this.restService.componentPaginationToRestPagination(componentPagination) 46 pagination = this.restService.componentToRestPagination(componentPagination)
47 } 47 }
48 48
49 let params = new HttpParams() 49 let params = new HttpParams()
@@ -77,7 +77,7 @@ export class SearchService {
77 77
78 let pagination: RestPagination 78 let pagination: RestPagination
79 if (componentPagination) { 79 if (componentPagination) {
80 pagination = this.restService.componentPaginationToRestPagination(componentPagination) 80 pagination = this.restService.componentToRestPagination(componentPagination)
81 } 81 }
82 82
83 let params = new HttpParams() 83 let params = new HttpParams()
@@ -111,7 +111,7 @@ export class SearchService {
111 111
112 let pagination: RestPagination 112 let pagination: RestPagination
113 if (componentPagination) { 113 if (componentPagination) {
114 pagination = this.restService.componentPaginationToRestPagination(componentPagination) 114 pagination = this.restService.componentToRestPagination(componentPagination)
115 } 115 }
116 116
117 let params = new HttpParams() 117 let params = new HttpParams()
diff --git a/client/src/app/shared/shared-user-subscription/user-subscription.service.ts b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts
index f289fb6cf..ede65ff39 100644
--- a/client/src/app/shared/shared-user-subscription/user-subscription.service.ts
+++ b/client/src/app/shared/shared-user-subscription/user-subscription.service.ts
@@ -6,7 +6,7 @@ import { Injectable } from '@angular/core'
6import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' 6import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
7import { buildBulkObservable } from '@app/helpers' 7import { buildBulkObservable } from '@app/helpers'
8import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' 8import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
9import { ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models' 9import { ActorFollow, ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models'
10import { environment } from '../../../environments/environment' 10import { environment } from '../../../environments/environment'
11 11
12const logger = debug('peertube:subscriptions:UserSubscriptionService') 12const logger = debug('peertube:subscriptions:UserSubscriptionService')
@@ -17,6 +17,8 @@ type SubscriptionExistResultObservable = { [ uri: string ]: Observable<boolean>
17@Injectable() 17@Injectable()
18export class UserSubscriptionService { 18export class UserSubscriptionService {
19 static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions' 19 static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions'
20 static BASE_VIDEO_CHANNELS_URL = environment.apiUrl + '/api/v1/video-channels'
21 static BASE_ACCOUNTS_URL = environment.apiUrl + '/api/v1/accounts'
20 22
21 // Use a replay subject because we "next" a value before subscribing 23 // Use a replay subject because we "next" a value before subscribing
22 private existsSubject = new ReplaySubject<string>(1) 24 private existsSubject = new ReplaySubject<string>(1)
@@ -43,13 +45,46 @@ export class UserSubscriptionService {
43 ) 45 )
44 } 46 }
45 47
48 listFollowers (parameters: {
49 pagination: ComponentPaginationLight
50 nameWithHost: string
51 search?: string
52 }) {
53 const { pagination, nameWithHost, search } = parameters
54
55 let url = `${UserSubscriptionService.BASE_ACCOUNTS_URL}/${nameWithHost}/followers`
56
57 let params = new HttpParams()
58 params = this.restService.addRestGetParams(params, this.restService.componentToRestPagination(pagination), '-createdAt')
59
60 if (search) {
61 const filters = this.restService.parseQueryStringFilter(search, {
62 channel: {
63 prefix: 'channel:'
64 }
65 })
66
67 if (filters.channel) {
68 url = `${UserSubscriptionService.BASE_VIDEO_CHANNELS_URL}/${filters.channel}/followers`
69 }
70
71 params = this.restService.addObjectParams(params, { search: filters.search })
72 }
73
74 return this.authHttp
75 .get<ResultList<ActorFollow>>(url, { params })
76 .pipe(
77 catchError(err => this.restExtractor.handleError(err))
78 )
79 }
80
46 getUserSubscriptionVideos (parameters: { 81 getUserSubscriptionVideos (parameters: {
47 videoPagination: ComponentPaginationLight 82 videoPagination: ComponentPaginationLight
48 sort: VideoSortField 83 sort: VideoSortField
49 skipCount?: boolean 84 skipCount?: boolean
50 }): Observable<ResultList<Video>> { 85 }): Observable<ResultList<Video>> {
51 const { videoPagination, sort, skipCount } = parameters 86 const { videoPagination, sort, skipCount } = parameters
52 const pagination = this.restService.componentPaginationToRestPagination(videoPagination) 87 const pagination = this.restService.componentToRestPagination(videoPagination)
53 88
54 let params = new HttpParams() 89 let params = new HttpParams()
55 params = this.restService.addRestGetParams(params, pagination, sort) 90 params = this.restService.addRestGetParams(params, pagination, sort)
@@ -106,7 +141,7 @@ export class UserSubscriptionService {
106 const { pagination, search } = parameters 141 const { pagination, search } = parameters
107 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL 142 const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
108 143
109 const restPagination = this.restService.componentPaginationToRestPagination(pagination) 144 const restPagination = this.restService.componentToRestPagination(pagination)
110 145
111 let params = new HttpParams() 146 let params = new HttpParams()
112 params = this.restService.addRestGetParams(params, restPagination) 147 params = this.restService.addRestGetParams(params, restPagination)
diff --git a/client/src/app/shared/shared-video-comment/video-comment.service.ts b/client/src/app/shared/shared-video-comment/video-comment.service.ts
index 5550c96e4..fd1cae7f8 100644
--- a/client/src/app/shared/shared-video-comment/video-comment.service.ts
+++ b/client/src/app/shared/shared-video-comment/video-comment.service.ts
@@ -81,7 +81,7 @@ export class VideoCommentService {
81 }): Observable<ThreadsResultList<VideoComment>> { 81 }): Observable<ThreadsResultList<VideoComment>> {
82 const { videoId, componentPagination, sort } = parameters 82 const { videoId, componentPagination, sort } = parameters
83 83
84 const pagination = this.restService.componentPaginationToRestPagination(componentPagination) 84 const pagination = this.restService.componentToRestPagination(componentPagination)
85 85
86 let params = new HttpParams() 86 let params = new HttpParams()
87 params = this.restService.addRestGetParams(params, pagination, sort) 87 params = this.restService.addRestGetParams(params, pagination, sort)
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
index fc291329a..3faf81d11 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
@@ -62,7 +62,7 @@ export class VideoPlaylistService {
62 62
63 listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> { 63 listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
64 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' 64 const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
65 const pagination = this.restService.componentPaginationToRestPagination(componentPagination) 65 const pagination = this.restService.componentToRestPagination(componentPagination)
66 66
67 let params = new HttpParams() 67 let params = new HttpParams()
68 params = this.restService.addRestGetParams(params, pagination) 68 params = this.restService.addRestGetParams(params, pagination)
@@ -103,7 +103,7 @@ export class VideoPlaylistService {
103 ): Observable<ResultList<VideoPlaylist>> { 103 ): Observable<ResultList<VideoPlaylist>> {
104 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' 104 const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
105 const pagination = componentPagination 105 const pagination = componentPagination
106 ? this.restService.componentPaginationToRestPagination(componentPagination) 106 ? this.restService.componentToRestPagination(componentPagination)
107 : undefined 107 : undefined
108 108
109 let params = new HttpParams() 109 let params = new HttpParams()
@@ -259,7 +259,7 @@ export class VideoPlaylistService {
259 componentPagination: ComponentPaginationLight 259 componentPagination: ComponentPaginationLight
260 }): Observable<ResultList<VideoPlaylistElement>> { 260 }): Observable<ResultList<VideoPlaylistElement>> {
261 const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + options.videoPlaylistId + '/videos' 261 const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + options.videoPlaylistId + '/videos'
262 const pagination = this.restService.componentPaginationToRestPagination(options.componentPagination) 262 const pagination = this.restService.componentToRestPagination(options.componentPagination)
263 263
264 let params = new HttpParams() 264 let params = new HttpParams()
265 params = this.restService.addRestGetParams(params, pagination) 265 params = this.restService.addRestGetParams(params, pagination)
diff --git a/client/src/sass/include/_account-channel-page.scss b/client/src/sass/include/_account-channel-page.scss
new file mode 100644
index 000000000..b135bbb6d
--- /dev/null
+++ b/client/src/sass/include/_account-channel-page.scss
@@ -0,0 +1,88 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4@mixin section-label-responsive {
5 color: pvar(--mainColor);
6 font-size: 12px;
7 margin-bottom: 15px;
8 font-weight: $font-bold;
9 letter-spacing: 2.5px;
10
11 @media screen and (max-width: $mobile-view) {
12 font-size: 10px;
13 letter-spacing: 2.1px;
14 margin-bottom: 5px;
15 }
16}
17
18@mixin show-more-description {
19 color: pvar(--mainColor);
20 cursor: pointer;
21 margin: 10px auto 45px;
22}
23
24@mixin avatar-row-responsive ($img-margin, $grey-font-size) {
25 display: flex;
26 grid-column: 1;
27 margin-bottom: 30px;
28
29 .main-avatar {
30 @include actor-avatar-size(120px);
31 }
32
33 > div {
34 @include margin-left($img-margin);
35
36 min-width: 1px;
37 }
38
39 .actor-info {
40 display: flex;
41
42 > div:first-child {
43 flex-grow: 1;
44 min-width: 1px;
45 }
46 }
47
48 .actor-display-name {
49 @include peertube-word-wrap;
50
51 display: flex;
52 flex-wrap: wrap;
53 }
54
55 h1 {
56 font-size: 28px;
57 font-weight: $font-bold;
58 margin: 0;
59 }
60
61 .actor-handle {
62 @include ellipsis;
63 }
64
65 .actor-handle,
66 .actor-counters {
67 color: pvar(--greyForegroundColor);
68 font-size: $grey-font-size;
69 }
70
71 .actor-counters > *:not(:last-child)::after {
72 content: '•';
73 margin: 0 10px;
74 color: pvar(--mainColor);
75 }
76
77 @media screen and (max-width: $mobile-view) {
78 margin-bottom: 15px;
79
80 h1 {
81 font-size: 22px;
82 }
83
84 .main-avatar {
85 @include actor-avatar-size(80px);
86 }
87 }
88}
diff --git a/client/src/sass/include/_actor.scss b/client/src/sass/include/_actor.scss
index b135bbb6d..f9e44b8ad 100644
--- a/client/src/sass/include/_actor.scss
+++ b/client/src/sass/include/_actor.scss
@@ -1,88 +1,68 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3 3
4@mixin section-label-responsive { 4@mixin actor-row ($avatar-size: 80px, $avatar-margin-right: 10px, $min-height: 130px, $separator: true) {
5 color: pvar(--mainColor); 5 @include row-blocks($min-height: $min-height, $separator: $separator);
6 font-size: 12px;
7 margin-bottom: 15px;
8 font-weight: $font-bold;
9 letter-spacing: 2.5px;
10
11 @media screen and (max-width: $mobile-view) {
12 font-size: 10px;
13 letter-spacing: 2.1px;
14 margin-bottom: 5px;
15 }
16}
17
18@mixin show-more-description {
19 color: pvar(--mainColor);
20 cursor: pointer;
21 margin: 10px auto 45px;
22}
23
24@mixin avatar-row-responsive ($img-margin, $grey-font-size) {
25 display: flex;
26 grid-column: 1;
27 margin-bottom: 30px;
28 6
29 .main-avatar { 7 > my-actor-avatar {
30 @include actor-avatar-size(120px); 8 @include actor-avatar-size($avatar-size);
31 }
32
33 > div {
34 @include margin-left($img-margin);
35 9
36 min-width: 1px; 10 @include margin-right($avatar-margin-right);
37 } 11 }
38 12
39 .actor-info { 13 .actor-info {
40 display: flex; 14 flex-grow: 1;
41
42 > div:first-child {
43 flex-grow: 1;
44 min-width: 1px;
45 }
46 } 15 }
47 16
48 .actor-display-name { 17 .actor-names {
49 @include peertube-word-wrap; 18 @include disable-default-a-behaviour;
50 19
20 width: fit-content;
51 display: flex; 21 display: flex;
52 flex-wrap: wrap; 22 align-items: baseline;
23 color: pvar(--mainForegroundColor);
53 } 24 }
54 25
55 h1 { 26 .actor-display-name {
56 font-size: 28px; 27 font-weight: $font-semibold;
57 font-weight: $font-bold; 28 font-size: 18px;
58 margin: 0;
59 } 29 }
60 30
61 .actor-handle { 31 .actor-name {
62 @include ellipsis; 32 @include margin-left(5px);
63 }
64 33
65 .actor-handle, 34 font-size: 14px;
66 .actor-counters { 35 color: $grey-actor-name;
67 color: pvar(--greyForegroundColor);
68 font-size: $grey-font-size;
69 } 36 }
70 37
71 .actor-counters > *:not(:last-child)::after { 38 .actor-owner {
72 content: '•'; 39 @include disable-default-a-behaviour;
73 margin: 0 10px;
74 color: pvar(--mainColor);
75 }
76 40
77 @media screen and (max-width: $mobile-view) { 41 font-size: 13px;
78 margin-bottom: 15px; 42 color: pvar(--mainForegroundColor);
79 43
80 h1 { 44 span:hover {
81 font-size: 22px; 45 opacity: 0.8;
82 } 46 }
83 47
84 .main-avatar { 48 my-actor-avatar {
85 @include actor-avatar-size(80px); 49 @include margin-left(7px);
50
51 display: inline-block;
52 vertical-align: top;
53 }
54 }
55
56 @media screen and (max-width: $small-view) {
57 .actor-info {
58 padding-bottom: 10px;
59 text-align: center;
60
61 .actor-names {
62 flex-direction: column;
63 align-items: center !important;
64 margin: auto;
65 }
86 } 66 }
87 } 67 }
88} 68}
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index 9e510771e..2f436d787 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -653,12 +653,15 @@
653 @include button-with-icon(20px, 5px, -1px); 653 @include button-with-icon(20px, 5px, -1px);
654} 654}
655 655
656@mixin row-blocks ($column-responsive: true) { 656@mixin row-blocks ($column-responsive: true, $min-height: 130px, $separator: true) {
657 display: flex; 657 display: flex;
658 min-height: 130px; 658 min-height: $min-height;
659 padding-bottom: 20px; 659 padding-bottom: 20px;
660 margin-bottom: 20px; 660 margin-bottom: 20px;
661 border-bottom: 1px solid #C6C6C6; 661
662 @if $separator {
663 border-bottom: 1px solid #C6C6C6;
664 }
662 665
663 @media screen and (max-width: $small-view) { 666 @media screen and (max-width: $small-view) {
664 @if $column-responsive { 667 @if $column-responsive {
diff --git a/server/controllers/api/accounts.ts b/server/controllers/api/accounts.ts
index 75679b0f4..77edfa7c2 100644
--- a/server/controllers/api/accounts.ts
+++ b/server/controllers/api/accounts.ts
@@ -1,5 +1,6 @@
1import express from 'express' 1import express from 'express'
2import { pickCommonVideoQuery } from '@server/helpers/query' 2import { pickCommonVideoQuery } from '@server/helpers/query'
3import { ActorFollowModel } from '@server/models/actor/actor-follow'
3import { getServerActor } from '@server/models/application/application' 4import { getServerActor } from '@server/models/application/application'
4import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' 5import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
5import { getFormattedObjects } from '../../helpers/utils' 6import { getFormattedObjects } from '../../helpers/utils'
@@ -20,6 +21,7 @@ import {
20} from '../../middlewares' 21} from '../../middlewares'
21import { 22import {
22 accountNameWithHostGetValidator, 23 accountNameWithHostGetValidator,
24 accountsFollowersSortValidator,
23 accountsSortValidator, 25 accountsSortValidator,
24 ensureAuthUserOwnsAccountValidator, 26 ensureAuthUserOwnsAccountValidator,
25 videoChannelsSortValidator, 27 videoChannelsSortValidator,
@@ -93,6 +95,17 @@ accountsRouter.get('/:accountName/ratings',
93 asyncMiddleware(listAccountRatings) 95 asyncMiddleware(listAccountRatings)
94) 96)
95 97
98accountsRouter.get('/:accountName/followers',
99 authenticate,
100 asyncMiddleware(accountNameWithHostGetValidator),
101 ensureAuthUserOwnsAccountValidator,
102 paginationValidator,
103 accountsFollowersSortValidator,
104 setDefaultSort,
105 setDefaultPagination,
106 asyncMiddleware(listAccountFollowers)
107)
108
96// --------------------------------------------------------------------------- 109// ---------------------------------------------------------------------------
97 110
98export { 111export {
@@ -127,7 +140,7 @@ async function listAccountChannels (req: express.Request, res: express.Response)
127 search: req.query.search 140 search: req.query.search
128 } 141 }
129 142
130 const resultList = await VideoChannelModel.listByAccount(options) 143 const resultList = await VideoChannelModel.listByAccountForAPI(options)
131 144
132 return res.json(getFormattedObjects(resultList.data, resultList.total)) 145 return res.json(getFormattedObjects(resultList.data, resultList.total))
133} 146}
@@ -195,3 +208,21 @@ async function listAccountRatings (req: express.Request, res: express.Response)
195 }) 208 })
196 return res.json(getFormattedObjects(resultList.rows, resultList.count)) 209 return res.json(getFormattedObjects(resultList.rows, resultList.count))
197} 210}
211
212async function listAccountFollowers (req: express.Request, res: express.Response) {
213 const account = res.locals.account
214
215 const channels = await VideoChannelModel.listAllByAccount(account.id)
216 const actorIds = [ account.actorId ].concat(channels.map(c => c.actorId))
217
218 const resultList = await ActorFollowModel.listFollowersForApi({
219 actorIds,
220 start: req.query.start,
221 count: req.query.count,
222 sort: req.query.sort,
223 search: req.query.search,
224 state: 'accepted',
225 })
226
227 return res.json(getFormattedObjects(resultList.data, resultList.total))
228}
diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts
index 76ed75186..c613386b2 100644
--- a/server/controllers/api/server/follows.ts
+++ b/server/controllers/api/server/follows.ts
@@ -98,7 +98,7 @@ export {
98 98
99async function listFollowing (req: express.Request, res: express.Response) { 99async function listFollowing (req: express.Request, res: express.Response) {
100 const serverActor = await getServerActor() 100 const serverActor = await getServerActor()
101 const resultList = await ActorFollowModel.listFollowingForApi({ 101 const resultList = await ActorFollowModel.listInstanceFollowingForApi({
102 id: serverActor.id, 102 id: serverActor.id,
103 start: req.query.start, 103 start: req.query.start,
104 count: req.query.count, 104 count: req.query.count,
@@ -114,7 +114,7 @@ async function listFollowing (req: express.Request, res: express.Response) {
114async function listFollowers (req: express.Request, res: express.Response) { 114async function listFollowers (req: express.Request, res: express.Response) {
115 const serverActor = await getServerActor() 115 const serverActor = await getServerActor()
116 const resultList = await ActorFollowModel.listFollowersForApi({ 116 const resultList = await ActorFollowModel.listFollowersForApi({
117 actorId: serverActor.id, 117 actorIds: [ serverActor.id ],
118 start: req.query.start, 118 start: req.query.start,
119 count: req.query.count, 119 count: req.query.count,
120 sort: req.query.sort, 120 sort: req.query.sort,
diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts
index e3c0cf089..b2b441673 100644
--- a/server/controllers/api/users/my-subscriptions.ts
+++ b/server/controllers/api/users/my-subscriptions.ts
@@ -95,7 +95,7 @@ async function areSubscriptionsExist (req: express.Request, res: express.Respons
95 return { name, host, uri: u } 95 return { name, host, uri: u }
96 }) 96 })
97 97
98 const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles) 98 const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, handles)
99 99
100 const existObject: { [id: string ]: boolean } = {} 100 const existObject: { [id: string ]: boolean } = {}
101 for (const handle of handles) { 101 for (const handle of handles) {
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index b79dc5933..f370c7004 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -1,6 +1,7 @@
1import express from 'express' 1import express from 'express'
2import { pickCommonVideoQuery } from '@server/helpers/query' 2import { pickCommonVideoQuery } from '@server/helpers/query'
3import { Hooks } from '@server/lib/plugins/hooks' 3import { Hooks } from '@server/lib/plugins/hooks'
4import { ActorFollowModel } from '@server/models/actor/actor-follow'
4import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
5import { MChannelBannerAccountDefault } from '@server/types/models' 6import { MChannelBannerAccountDefault } from '@server/types/models'
6import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared' 7import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared'
@@ -33,7 +34,13 @@ import {
33 videoChannelsUpdateValidator, 34 videoChannelsUpdateValidator,
34 videoPlaylistsSortValidator 35 videoPlaylistsSortValidator
35} from '../../middlewares' 36} from '../../middlewares'
36import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' 37import {
38 ensureAuthUserOwnsChannelValidator,
39 videoChannelsFollowersSortValidator,
40 videoChannelsListValidator,
41 videoChannelsNameWithHostValidator,
42 videosSortValidator
43} from '../../middlewares/validators'
37import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' 44import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
38import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' 45import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
39import { AccountModel } from '../../models/account/account' 46import { AccountModel } from '../../models/account/account'
@@ -65,8 +72,8 @@ videoChannelRouter.post('/',
65videoChannelRouter.post('/:nameWithHost/avatar/pick', 72videoChannelRouter.post('/:nameWithHost/avatar/pick',
66 authenticate, 73 authenticate,
67 reqAvatarFile, 74 reqAvatarFile,
68 // Check the rights 75 asyncMiddleware(videoChannelsNameWithHostValidator),
69 asyncMiddleware(videoChannelsUpdateValidator), 76 ensureAuthUserOwnsChannelValidator,
70 updateAvatarValidator, 77 updateAvatarValidator,
71 asyncMiddleware(updateVideoChannelAvatar) 78 asyncMiddleware(updateVideoChannelAvatar)
72) 79)
@@ -74,29 +81,31 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick',
74videoChannelRouter.post('/:nameWithHost/banner/pick', 81videoChannelRouter.post('/:nameWithHost/banner/pick',
75 authenticate, 82 authenticate,
76 reqBannerFile, 83 reqBannerFile,
77 // Check the rights 84 asyncMiddleware(videoChannelsNameWithHostValidator),
78 asyncMiddleware(videoChannelsUpdateValidator), 85 ensureAuthUserOwnsChannelValidator,
79 updateBannerValidator, 86 updateBannerValidator,
80 asyncMiddleware(updateVideoChannelBanner) 87 asyncMiddleware(updateVideoChannelBanner)
81) 88)
82 89
83videoChannelRouter.delete('/:nameWithHost/avatar', 90videoChannelRouter.delete('/:nameWithHost/avatar',
84 authenticate, 91 authenticate,
85 // Check the rights 92 asyncMiddleware(videoChannelsNameWithHostValidator),
86 asyncMiddleware(videoChannelsUpdateValidator), 93 ensureAuthUserOwnsChannelValidator,
87 asyncMiddleware(deleteVideoChannelAvatar) 94 asyncMiddleware(deleteVideoChannelAvatar)
88) 95)
89 96
90videoChannelRouter.delete('/:nameWithHost/banner', 97videoChannelRouter.delete('/:nameWithHost/banner',
91 authenticate, 98 authenticate,
92 // Check the rights 99 asyncMiddleware(videoChannelsNameWithHostValidator),
93 asyncMiddleware(videoChannelsUpdateValidator), 100 ensureAuthUserOwnsChannelValidator,
94 asyncMiddleware(deleteVideoChannelBanner) 101 asyncMiddleware(deleteVideoChannelBanner)
95) 102)
96 103
97videoChannelRouter.put('/:nameWithHost', 104videoChannelRouter.put('/:nameWithHost',
98 authenticate, 105 authenticate,
99 asyncMiddleware(videoChannelsUpdateValidator), 106 asyncMiddleware(videoChannelsNameWithHostValidator),
107 ensureAuthUserOwnsChannelValidator,
108 videoChannelsUpdateValidator,
100 asyncRetryTransactionMiddleware(updateVideoChannel) 109 asyncRetryTransactionMiddleware(updateVideoChannel)
101) 110)
102 111
@@ -132,6 +141,17 @@ videoChannelRouter.get('/:nameWithHost/videos',
132 asyncMiddleware(listVideoChannelVideos) 141 asyncMiddleware(listVideoChannelVideos)
133) 142)
134 143
144videoChannelRouter.get('/:nameWithHost/followers',
145 authenticate,
146 asyncMiddleware(videoChannelsNameWithHostValidator),
147 ensureAuthUserOwnsChannelValidator,
148 paginationValidator,
149 videoChannelsFollowersSortValidator,
150 setDefaultSort,
151 setDefaultPagination,
152 asyncMiddleware(listVideoChannelFollowers)
153)
154
135// --------------------------------------------------------------------------- 155// ---------------------------------------------------------------------------
136 156
137export { 157export {
@@ -332,3 +352,18 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon
332 352
333 return res.json(getFormattedObjects(resultList.data, resultList.total)) 353 return res.json(getFormattedObjects(resultList.data, resultList.total))
334} 354}
355
356async function listVideoChannelFollowers (req: express.Request, res: express.Response) {
357 const channel = res.locals.videoChannel
358
359 const resultList = await ActorFollowModel.listFollowersForApi({
360 actorIds: [ channel.actorId ],
361 start: req.query.start,
362 count: req.query.count,
363 sort: req.query.sort,
364 search: req.query.search,
365 state: 'accepted',
366 })
367
368 return res.json(getFormattedObjects(resultList.data, resultList.total))
369}
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 029984559..dcbad9264 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -69,8 +69,11 @@ const SORTABLE_COLUMNS = {
69 69
70 VIDEO_RATES: [ 'createdAt' ], 70 VIDEO_RATES: [ 'createdAt' ],
71 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], 71 BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
72
72 INSTANCE_FOLLOWERS: [ 'createdAt', 'state', 'score' ], 73 INSTANCE_FOLLOWERS: [ 'createdAt', 'state', 'score' ],
73 INSTANCE_FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ], 74 INSTANCE_FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ],
75 ACCOUNT_FOLLOWERS: [ 'createdAt' ],
76 CHANNEL_FOLLOWERS: [ 'createdAt' ],
74 77
75 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ], 78 VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ],
76 79
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index ce8df8fee..3ba668460 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -53,6 +53,9 @@ const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
53const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) 53const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
54const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) 54const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
55 55
56const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
57const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
58
56// --------------------------------------------------------------------------- 59// ---------------------------------------------------------------------------
57 60
58export { 61export {
@@ -79,5 +82,7 @@ export {
79 videoPlaylistsSortValidator, 82 videoPlaylistsSortValidator,
80 videoRedundanciesSortValidator, 83 videoRedundanciesSortValidator,
81 videoPlaylistsSearchSortValidator, 84 videoPlaylistsSearchSortValidator,
85 accountsFollowersSortValidator,
86 videoChannelsFollowersSortValidator,
82 pluginsSortValidator 87 pluginsSortValidator
83} 88}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index c06b85862..c6eeeaf18 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -3,9 +3,7 @@ import { body, param, query } from 'express-validator'
3import { omit } from 'lodash' 3import { omit } from 'lodash'
4import { Hooks } from '@server/lib/plugins/hooks' 4import { Hooks } from '@server/lib/plugins/hooks'
5import { MUserDefault } from '@server/types/models' 5import { MUserDefault } from '@server/types/models'
6import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' 6import { HttpStatusCode, UserRegister, UserRole } from '@shared/models'
7import { UserRole } from '../../../shared/models/users'
8import { UserRegister } from '../../../shared/models/users/user-register.model'
9import { toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 7import { toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
10import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 8import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
11import { 9import {
@@ -462,7 +460,22 @@ const ensureAuthUserOwnsAccountValidator = [
462 if (res.locals.account.id !== user.Account.id) { 460 if (res.locals.account.id !== user.Account.id) {
463 return res.fail({ 461 return res.fail({
464 status: HttpStatusCode.FORBIDDEN_403, 462 status: HttpStatusCode.FORBIDDEN_403,
465 message: 'Only owner can access ratings list.' 463 message: 'Only owner of this account can access this ressource.'
464 })
465 }
466
467 return next()
468 }
469]
470
471const ensureAuthUserOwnsChannelValidator = [
472 (req: express.Request, res: express.Response, next: express.NextFunction) => {
473 const user = res.locals.oauth.token.User
474
475 if (res.locals.videoChannel.Account.userId !== user.id) {
476 return res.fail({
477 status: HttpStatusCode.FORBIDDEN_403,
478 message: 'Only owner of this video channel can access this ressource'
466 }) 479 })
467 } 480 }
468 481
@@ -506,6 +519,7 @@ export {
506 usersVerifyEmailValidator, 519 usersVerifyEmailValidator,
507 userAutocompleteValidator, 520 userAutocompleteValidator,
508 ensureAuthUserOwnsAccountValidator, 521 ensureAuthUserOwnsAccountValidator,
522 ensureAuthUserOwnsChannelValidator,
509 ensureCanManageUser 523 ensureCanManageUser
510} 524}
511 525
diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts
index fc717abf6..ec107fa51 100644
--- a/server/middlewares/validators/videos/video-channels.ts
+++ b/server/middlewares/validators/videos/video-channels.ts
@@ -65,22 +65,6 @@ const videoChannelsUpdateValidator = [
65 logger.debug('Checking videoChannelsUpdate parameters', { parameters: req.body }) 65 logger.debug('Checking videoChannelsUpdate parameters', { parameters: req.body })
66 66
67 if (areValidationErrors(req, res)) return 67 if (areValidationErrors(req, res)) return
68 if (!await doesVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return
69
70 // We need to make additional checks
71 if (res.locals.videoChannel.Actor.isOwned() === false) {
72 return res.fail({
73 status: HttpStatusCode.FORBIDDEN_403,
74 message: 'Cannot update video channel of another server'
75 })
76 }
77
78 if (res.locals.videoChannel.Account.userId !== res.locals.oauth.token.User.id) {
79 return res.fail({
80 status: HttpStatusCode.FORBIDDEN_403,
81 message: 'Cannot update video channel of another user'
82 })
83 }
84 68
85 return next() 69 return next()
86 } 70 }
diff --git a/server/models/actor/actor-follow.ts b/server/models/actor/actor-follow.ts
index fc1cc7499..d6a2387a5 100644
--- a/server/models/actor/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -143,7 +143,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
143 * @deprecated Use `findOrCreateCustom` instead 143 * @deprecated Use `findOrCreateCustom` instead
144 */ 144 */
145 static findOrCreate (): any { 145 static findOrCreate (): any {
146 throw new Error('Should not be called') 146 throw new Error('Must not be called')
147 } 147 }
148 148
149 // findOrCreate has issues with actor follow hooks 149 // findOrCreate has issues with actor follow hooks
@@ -288,7 +288,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
288 return ActorFollowModel.findOne(query) 288 return ActorFollowModel.findOne(query)
289 } 289 }
290 290
291 static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> { 291 static listSubscriptionsOf (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> {
292 const whereTab = targets 292 const whereTab = targets
293 .map(t => { 293 .map(t => {
294 if (t.host) { 294 if (t.host) {
@@ -348,7 +348,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
348 return ActorFollowModel.findAll(query) 348 return ActorFollowModel.findAll(query)
349 } 349 }
350 350
351 static listFollowingForApi (options: { 351 static listInstanceFollowingForApi (options: {
352 id: number 352 id: number
353 start: number 353 start: number
354 count: number 354 count: number
@@ -415,7 +415,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
415 } 415 }
416 416
417 static listFollowersForApi (options: { 417 static listFollowersForApi (options: {
418 actorId: number 418 actorIds: number[]
419 start: number 419 start: number
420 count: number 420 count: number
421 sort: string 421 sort: string
@@ -423,7 +423,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
423 actorType?: ActivityPubActorType 423 actorType?: ActivityPubActorType
424 search?: string 424 search?: string
425 }) { 425 }) {
426 const { actorId, start, count, sort, search, state, actorType } = options 426 const { actorIds, start, count, sort, search, state, actorType } = options
427 427
428 const followWhere = state ? { state } : {} 428 const followWhere = state ? { state } : {}
429 const followerWhere: WhereOptions = {} 429 const followerWhere: WhereOptions = {}
@@ -452,20 +452,16 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
452 model: ActorModel, 452 model: ActorModel,
453 required: true, 453 required: true,
454 as: 'ActorFollower', 454 as: 'ActorFollower',
455 where: followerWhere, 455 where: followerWhere
456 include: [
457 {
458 model: ServerModel,
459 required: true
460 }
461 ]
462 }, 456 },
463 { 457 {
464 model: ActorModel, 458 model: ActorModel,
465 as: 'ActorFollowing', 459 as: 'ActorFollowing',
466 required: true, 460 required: true,
467 where: { 461 where: {
468 id: actorId 462 id: {
463 [Op.in]: actorIds
464 }
469 } 465 }
470 } 466 }
471 ] 467 ]
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 8ccb818b3..a151ad61c 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -26,7 +26,7 @@ import {
26 isVideoChannelDisplayNameValid, 26 isVideoChannelDisplayNameValid,
27 isVideoChannelSupportValid 27 isVideoChannelSupportValid
28} from '../../helpers/custom-validators/video-channels' 28} from '../../helpers/custom-validators/video-channels'
29import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' 29import { CONSTRAINTS_FIELDS, VIDEO_CHANNELS, WEBSERVER } from '../../initializers/constants'
30import { sendDeleteActor } from '../../lib/activitypub/send' 30import { sendDeleteActor } from '../../lib/activitypub/send'
31import { 31import {
32 MChannelActor, 32 MChannelActor,
@@ -527,7 +527,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
527 }) 527 })
528 } 528 }
529 529
530 static listByAccount (options: { 530 static listByAccountForAPI (options: {
531 accountId: number 531 accountId: number
532 start: number 532 start: number
533 count: number 533 count: number
@@ -582,6 +582,26 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
582 }) 582 })
583 } 583 }
584 584
585
586 static listAllByAccount (accountId: number) {
587 const query = {
588 limit: VIDEO_CHANNELS.MAX_PER_USER,
589 include: [
590 {
591 attributes: [],
592 model: AccountModel,
593 where: {
594 id: accountId
595 },
596 required: true
597 }
598 ]
599 }
600
601 return VideoChannelModel.findAll(query)
602 }
603
604
585 static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> { 605 static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
586 return VideoChannelModel.unscoped() 606 return VideoChannelModel.unscoped()
587 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ]) 607 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index 58b360f92..517e2f423 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -840,6 +840,34 @@ describe('Test users API validators', function () {
840 }) 840 })
841 }) 841 })
842 842
843 describe('When getting my global followers', function () {
844 const path = '/api/v1/accounts/user1/followers'
845
846 it('Should fail with a bad start pagination', async function () {
847 await checkBadStartPagination(server.url, path, userToken)
848 })
849
850 it('Should fail with a bad count pagination', async function () {
851 await checkBadCountPagination(server.url, path, userToken)
852 })
853
854 it('Should fail with an incorrect sort', async function () {
855 await checkBadSortPagination(server.url, path, userToken)
856 })
857
858 it('Should fail with a unauthenticated user', async function () {
859 await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
860 })
861
862 it('Should fail with a another user', async function () {
863 await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
864 })
865
866 it('Should succeed with the correct params', async function () {
867 await makeGetRequest({ url: server.url, path, token: userToken, expectedStatus: HttpStatusCode.OK_200 })
868 })
869 })
870
843 describe('When blocking/unblocking/removing user', function () { 871 describe('When blocking/unblocking/removing user', function () {
844 872
845 it('Should fail with an incorrect id', async function () { 873 it('Should fail with an incorrect id', async function () {
diff --git a/server/tests/api/check-params/video-channels.ts b/server/tests/api/check-params/video-channels.ts
index 2e63916d4..e86c315fa 100644
--- a/server/tests/api/check-params/video-channels.ts
+++ b/server/tests/api/check-params/video-channels.ts
@@ -321,6 +321,34 @@ describe('Test video channels API validator', function () {
321 }) 321 })
322 }) 322 })
323 323
324 describe('When getting channel followers', function () {
325 const path = '/api/v1/video-channels/super_channel/followers'
326
327 it('Should fail with a bad start pagination', async function () {
328 await checkBadStartPagination(server.url, path, server.accessToken)
329 })
330
331 it('Should fail with a bad count pagination', async function () {
332 await checkBadCountPagination(server.url, path, server.accessToken)
333 })
334
335 it('Should fail with an incorrect sort', async function () {
336 await checkBadSortPagination(server.url, path, server.accessToken)
337 })
338
339 it('Should fail with a unauthenticated user', async function () {
340 await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
341 })
342
343 it('Should fail with a another user', async function () {
344 await makeGetRequest({ url: server.url, path, token: accessTokenUser, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
345 })
346
347 it('Should succeed with the correct params', async function () {
348 await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
349 })
350 })
351
324 describe('When deleting a video channel', function () { 352 describe('When deleting a video channel', function () {
325 it('Should fail with a non authenticated user', async function () { 353 it('Should fail with a non authenticated user', async function () {
326 await command.delete({ token: 'coucou', channelName: 'super_channel', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) 354 await command.delete({ token: 'coucou', channelName: 'super_channel', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts
index 441f70d07..b49367be6 100644
--- a/server/tests/api/users/user-subscriptions.ts
+++ b/server/tests/api/users/user-subscriptions.ts
@@ -368,6 +368,162 @@ describe('Test users subscriptions', function () {
368 } 368 }
369 }) 369 })
370 370
371 it('Should follow user channels of server 3 by root of server 3', async function () {
372 this.timeout(60000)
373
374 await servers[2].channels.create({ token: users[2].accessToken, attributes: { name: 'user3_channel2' } })
375
376 await servers[2].subscriptions.add({ token: servers[2].accessToken, targetUri: 'user3_channel@localhost:' + servers[2].port })
377 await servers[2].subscriptions.add({ token: servers[2].accessToken, targetUri: 'user3_channel2@localhost:' + servers[2].port })
378
379 await waitJobs(servers)
380 })
381
382 it('Should list user 3 followers', async function () {
383 {
384 const { total, data } = await servers[2].accounts.listFollowers({
385 token: users[2].accessToken,
386 accountName: 'user3',
387 start: 0,
388 count: 5,
389 sort: 'createdAt'
390 })
391
392 expect(total).to.equal(3)
393 expect(data[0].following.host).to.equal(servers[2].host)
394 expect(data[0].following.name).to.equal('user3_channel')
395 expect(data[0].follower.host).to.equal(servers[0].host)
396 expect(data[0].follower.name).to.equal('user1')
397
398 expect(data[1].following.host).to.equal(servers[2].host)
399 expect(data[1].following.name).to.equal('user3_channel')
400 expect(data[1].follower.host).to.equal(servers[2].host)
401 expect(data[1].follower.name).to.equal('root')
402
403 expect(data[2].following.host).to.equal(servers[2].host)
404 expect(data[2].following.name).to.equal('user3_channel2')
405 expect(data[2].follower.host).to.equal(servers[2].host)
406 expect(data[2].follower.name).to.equal('root')
407 }
408
409 {
410 const { total, data } = await servers[2].accounts.listFollowers({
411 token: users[2].accessToken,
412 accountName: 'user3',
413 start: 0,
414 count: 1,
415 sort: '-createdAt'
416 })
417
418 expect(total).to.equal(3)
419 expect(data[0].following.host).to.equal(servers[2].host)
420 expect(data[0].following.name).to.equal('user3_channel2')
421 expect(data[0].follower.host).to.equal(servers[2].host)
422 expect(data[0].follower.name).to.equal('root')
423 }
424
425 {
426 const { total, data } = await servers[2].accounts.listFollowers({
427 token: users[2].accessToken,
428 accountName: 'user3',
429 start: 1,
430 count: 1,
431 sort: '-createdAt'
432 })
433
434 expect(total).to.equal(3)
435 expect(data[0].following.host).to.equal(servers[2].host)
436 expect(data[0].following.name).to.equal('user3_channel')
437 expect(data[0].follower.host).to.equal(servers[2].host)
438 expect(data[0].follower.name).to.equal('root')
439 }
440
441 {
442 const { total, data } = await servers[2].accounts.listFollowers({
443 token: users[2].accessToken,
444 accountName: 'user3',
445 search: 'user1',
446 sort: '-createdAt'
447 })
448
449 expect(total).to.equal(1)
450 expect(data[0].following.host).to.equal(servers[2].host)
451 expect(data[0].following.name).to.equal('user3_channel')
452 expect(data[0].follower.host).to.equal(servers[0].host)
453 expect(data[0].follower.name).to.equal('user1')
454 }
455 })
456
457 it('Should list user3_channel followers', async function () {
458 {
459 const { total, data } = await servers[2].channels.listFollowers({
460 token: users[2].accessToken,
461 channelName: 'user3_channel',
462 start: 0,
463 count: 5,
464 sort: 'createdAt'
465 })
466
467 expect(total).to.equal(2)
468 expect(data[0].following.host).to.equal(servers[2].host)
469 expect(data[0].following.name).to.equal('user3_channel')
470 expect(data[0].follower.host).to.equal(servers[0].host)
471 expect(data[0].follower.name).to.equal('user1')
472
473 expect(data[1].following.host).to.equal(servers[2].host)
474 expect(data[1].following.name).to.equal('user3_channel')
475 expect(data[1].follower.host).to.equal(servers[2].host)
476 expect(data[1].follower.name).to.equal('root')
477 }
478
479 {
480 const { total, data } = await servers[2].channels.listFollowers({
481 token: users[2].accessToken,
482 channelName: 'user3_channel',
483 start: 0,
484 count: 1,
485 sort: '-createdAt'
486 })
487
488 expect(total).to.equal(2)
489 expect(data[0].following.host).to.equal(servers[2].host)
490 expect(data[0].following.name).to.equal('user3_channel')
491 expect(data[0].follower.host).to.equal(servers[2].host)
492 expect(data[0].follower.name).to.equal('root')
493 }
494
495 {
496 const { total, data } = await servers[2].channels.listFollowers({
497 token: users[2].accessToken,
498 channelName: 'user3_channel',
499 start: 1,
500 count: 1,
501 sort: '-createdAt'
502 })
503
504 expect(total).to.equal(2)
505 expect(data[0].following.host).to.equal(servers[2].host)
506 expect(data[0].following.name).to.equal('user3_channel')
507 expect(data[0].follower.host).to.equal(servers[0].host)
508 expect(data[0].follower.name).to.equal('root')
509 }
510
511 {
512 const { total, data } = await servers[2].channels.listFollowers({
513 token: users[2].accessToken,
514 channelName: 'user3_channel',
515 search: 'user1',
516 sort: '-createdAt'
517 })
518
519 expect(total).to.equal(1)
520 expect(data[0].following.host).to.equal(servers[2].host)
521 expect(data[0].following.name).to.equal('user3_channel')
522 expect(data[0].follower.host).to.equal(servers[0].host)
523 expect(data[0].follower.name).to.equal('user1')
524 }
525 })
526
371 after(async function () { 527 after(async function () {
372 await cleanupTests(servers) 528 await cleanupTests(servers)
373 }) 529 })
diff --git a/shared/extra-utils/users/accounts-command.ts b/shared/extra-utils/users/accounts-command.ts
index 2f586104e..98d9d5927 100644
--- a/shared/extra-utils/users/accounts-command.ts
+++ b/shared/extra-utils/users/accounts-command.ts
@@ -1,5 +1,5 @@
1import { HttpStatusCode, ResultList } from '@shared/models' 1import { HttpStatusCode, ResultList } from '@shared/models'
2import { Account } from '../../models/actors' 2import { Account, ActorFollow } from '../../models/actors'
3import { AccountVideoRate, VideoRateType } from '../../models/videos' 3import { AccountVideoRate, VideoRateType } from '../../models/videos'
4import { AbstractCommand, OverrideCommandOptions } from '../shared' 4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5 5
@@ -53,4 +53,26 @@ export class AccountsCommand extends AbstractCommand {
53 defaultExpectedStatus: HttpStatusCode.OK_200 53 defaultExpectedStatus: HttpStatusCode.OK_200
54 }) 54 })
55 } 55 }
56
57 listFollowers (options: OverrideCommandOptions & {
58 accountName: string
59 start?: number
60 count?: number
61 sort?: string
62 search?: string
63 }) {
64 const { accountName, start, count, sort, search } = options
65 const path = '/api/v1/accounts/' + accountName + '/followers'
66
67 const query = { start, count, sort, search }
68
69 return this.getRequestBody<ResultList<ActorFollow>>({
70 ...options,
71
72 path,
73 query,
74 implicitToken: true,
75 defaultExpectedStatus: HttpStatusCode.OK_200
76 })
77 }
56} 78}
diff --git a/shared/extra-utils/videos/channels-command.ts b/shared/extra-utils/videos/channels-command.ts
index 255e1d62d..e406e570b 100644
--- a/shared/extra-utils/videos/channels-command.ts
+++ b/shared/extra-utils/videos/channels-command.ts
@@ -1,5 +1,5 @@
1import { pick } from '@shared/core-utils' 1import { pick } from '@shared/core-utils'
2import { HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models' 2import { ActorFollow, HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models'
3import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model' 3import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model'
4import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model' 4import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model'
5import { unwrapBody } from '../requests' 5import { unwrapBody } from '../requests'
@@ -47,7 +47,7 @@ export class ChannelsCommand extends AbstractCommand {
47 } 47 }
48 48
49 async create (options: OverrideCommandOptions & { 49 async create (options: OverrideCommandOptions & {
50 attributes: VideoChannelCreate 50 attributes: Partial<VideoChannelCreate>
51 }) { 51 }) {
52 const path = '/api/v1/video-channels/' 52 const path = '/api/v1/video-channels/'
53 53
@@ -153,4 +153,26 @@ export class ChannelsCommand extends AbstractCommand {
153 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 153 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
154 }) 154 })
155 } 155 }
156
157 listFollowers (options: OverrideCommandOptions & {
158 channelName: string
159 start?: number
160 count?: number
161 sort?: string
162 search?: string
163 }) {
164 const { channelName, start, count, sort, search } = options
165 const path = '/api/v1/video-channels/' + channelName + '/followers'
166
167 const query = { start, count, sort, search }
168
169 return this.getRequestBody<ResultList<ActorFollow>>({
170 ...options,
171
172 path,
173 query,
174 implicitToken: true,
175 defaultExpectedStatus: HttpStatusCode.OK_200
176 })
177 }
156} 178}