1
0
Fork 0

Add ability to view my followers

This commit is contained in:
Chocobozzz 2021-10-19 09:44:43 +02:00
parent 9593a78ae1
commit 4beda9e12a
No known key found for this signature in database
GPG key ID: 583A612D890159BE
47 changed files with 799 additions and 248 deletions

View file

@ -88,7 +88,7 @@ export class AboutFollowsComponent implements OnInit {
} }
private loadMoreFollowers (reset = false) { private loadMoreFollowers (reset = false) {
const pagination = this.restService.componentPaginationToRestPagination(this.followersPagination) const pagination = this.restService.componentToRestPagination(this.followersPagination)
this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' }) this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({ .subscribe({
@ -106,7 +106,7 @@ export class AboutFollowsComponent implements OnInit {
} }
private loadMoreFollowings (reset = false) { private loadMoreFollowings (reset = false) {
const pagination = this.restService.componentPaginationToRestPagination(this.followingsPagination) const pagination = this.restService.componentToRestPagination(this.followingsPagination)
this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' }) this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({ .subscribe({

View file

@ -1,6 +1,6 @@
@use '_variables' as *; @use '_variables' as *;
@use '_mixins' as *; @use '_mixins' as *;
@use '_actor' as *; @use '_account-channel-page' as *;
@use '_miniature' as *; @use '_miniature' as *;
.root { .root {

View file

@ -416,7 +416,7 @@
<p i18n>⚠️ This functionality requires a lot of attention and extra moderation.</p> <p i18n>⚠️ This functionality requires a lot of attention and extra moderation.</p>
<span i18n> <span i18n>
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 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
</span> </span>
</ng-container> </ng-container>

View file

@ -51,7 +51,7 @@ export class PluginApiService {
componentPagination: ComponentPagination, componentPagination: ComponentPagination,
sort: string sort: string
) { ) {
const pagination = this.restService.componentPaginationToRestPagination(componentPagination) const pagination = this.restService.componentToRestPagination(componentPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
@ -67,7 +67,7 @@ export class PluginApiService {
sort: string, sort: string,
search?: string search?: string
) { ) {
const pagination = this.restService.componentPaginationToRestPagination(componentPagination) const pagination = this.restService.componentToRestPagination(componentPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)

View file

@ -27,7 +27,12 @@
<div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> <div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
</a> </a>
<div i18n class="video-channel-followers">{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div> <a
i18n class="video-channel-followers"
[routerLink]="[ '/my-library', 'followers' ]" [queryParams]="{ search: 'channel:' + videoChannel.name }"
>
{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}
</a>
<div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div> <div i18n class="video-channel-videos">{videoChannel.videosCount, plural, =0 {No videos} =1 {1 video} other {{{ videoChannel.videosCount }} videos}}</div>

View file

@ -54,6 +54,10 @@ my-edit-button {
color: $grey-actor-name; color: $grey-actor-name;
} }
.video-channel-followers {
color: pvar(--mainForegroundColor);
}
.video-channel-buttons { .video-channel-buttons {
margin-top: 10px; margin-top: 10px;
min-width: 190px; min-width: 190px;

View file

@ -0,0 +1,31 @@
<h1>
<span>
<my-global-icon iconName="follower" aria-hidden="true"></my-global-icon>
<ng-container i18n>My followers</ng-container>
<span class="badge badge-secondary"> {{ pagination.totalItems }}</span>
</span>
</h1>
<div class="followers-header">
<my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
</div>
<div class="no-results" i18n *ngIf="pagination.totalItems === 0">No follower found.</div>
<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let follow of follows" class="actor">
<my-actor-avatar [account]="follow.follower" [href]="follow.follower.url"></my-actor-avatar>
<div class="actor-info">
<a [href]="follow.follower.url" class="actor-names" rel="noopener noreferrer" target="_blank" i18n-title title="Follower page">
<div class="actor-display-name">{{ follow.follower.name + '@' + follow.follower.host }}</div>
<span class="glyphicon glyphicon-new-window"></span>
</a>
<div class="text-muted">
<ng-container *ngIf="isFollowingAccount(follow)" i18n>Is following all your channels</ng-container>
<ng-container *ngIf="!isFollowingAccount(follow)" i18n>Is following your channel {{ follow.following.name }}</ng-container>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,26 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_actor' as *;
.followers-header {
margin-bottom: 30px;
display: flex;
}
input[type=text] {
@include peertube-input-text(300px);
}
.actor {
@include actor-row($avatar-size: 40px, $min-height: auto, $separator: true);
.actor-display-name {
font-size: 16px;
+ .glyphicon {
@include margin-left(5px);
font-size: 12px;
}
}
}

View file

@ -0,0 +1,76 @@
import { Subject } from 'rxjs'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AuthService, ComponentPagination, Notifier } from '@app/core'
import { UserSubscriptionService } from '@app/shared/shared-user-subscription'
import { ActorFollow } from '@shared/models'
@Component({
templateUrl: './my-followers.component.html',
styleUrls: [ './my-followers.component.scss' ]
})
export class MyFollowersComponent implements OnInit {
follows: ActorFollow[] = []
pagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 10,
totalItems: null
}
onDataSubject = new Subject<any[]>()
search: string
constructor (
private route: ActivatedRoute,
private auth: AuthService,
private userSubscriptionService: UserSubscriptionService,
private notifier: Notifier
) {}
ngOnInit () {
if (this.route.snapshot.queryParams['search']) {
this.search = this.route.snapshot.queryParams['search']
}
}
onNearOfBottom () {
// Last page
if (this.pagination.totalItems <= (this.pagination.currentPage * this.pagination.itemsPerPage)) return
this.pagination.currentPage += 1
this.loadFollowers()
}
onSearch (search: string) {
this.search = search
this.loadFollowers(false)
}
isFollowingAccount (follow: ActorFollow) {
return follow.following.name === this.getUsername()
}
private loadFollowers (more = true) {
this.userSubscriptionService.listFollowers({
pagination: this.pagination,
nameWithHost: this.getUsername(),
search: this.search
}).subscribe({
next: res => {
this.follows = more
? this.follows.concat(res.data)
: res.data
this.pagination.totalItems = res.total
this.onDataSubject.next(res.data)
},
error: err => this.notifier.error(err.message)
})
}
private getUsername () {
return this.auth.getUser().username
}
}

View file

@ -12,17 +12,17 @@
<div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div> <div class="no-results" i18n *ngIf="pagination.totalItems === 0">You don't have any subscription yet.</div>
<div class="video-channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> <div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let videoChannel of videoChannels" class="video-channel"> <div *ngFor="let videoChannel of videoChannels" class="actor">
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar> <my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
<div class="video-channel-info"> <div class="actor-info">
<a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page"> <a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="actor-names" i18n-title title="Channel page">
<div class="video-channel-display-name">{{ videoChannel.displayName }}</div> <div class="actor-display-name">{{ videoChannel.displayName }}</div>
<div class="video-channel-name">{{ videoChannel.nameWithHost }}</div> <div class="actor-name">{{ videoChannel.nameWithHost }}</div>
</a> </a>
<div i18n class="video-channel-followers">{{ videoChannel.followersCount }} subscribers</div> <div i18n class="actor-followers">{{ videoChannel.followersCount }} subscribers</div>
<a [routerLink]="[ '/a', videoChannel.ownerBy ]" i18n-title title="Owner account page" class="actor-owner"> <a [routerLink]="[ '/a', videoChannel.ownerBy ]" i18n-title title="Owner account page" class="actor-owner">
<span i18n>Created by {{ videoChannel.ownerBy }}</span> <span i18n>Created by {{ videoChannel.ownerBy }}</span>

View file

@ -0,0 +1,16 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_actor' as *;
.video-subscriptions-header {
margin-bottom: 30px;
display: flex;
}
input[type=text] {
@include peertube-input-text(300px);
}
.actor {
@include actor-row;
}

View file

@ -1,10 +1,11 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router' import { RouterModule, Routes } from '@angular/router'
import { LoginGuard } from '../core' import { LoginGuard } from '../core'
import { MyFollowersComponent } from './my-follows/my-followers.component'
import { MySubscriptionsComponent } from './my-follows/my-subscriptions.component'
import { MyHistoryComponent } from './my-history/my-history.component' import { MyHistoryComponent } from './my-history/my-history.component'
import { MyLibraryComponent } from './my-library.component' import { MyLibraryComponent } from './my-library.component'
import { MyOwnershipComponent } from './my-ownership/my-ownership.component' import { MyOwnershipComponent } from './my-ownership/my-ownership.component'
import { MySubscriptionsComponent } from './my-subscriptions/my-subscriptions.component'
import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component' import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component'
import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component' import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component'
import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component' import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component'
@ -99,6 +100,15 @@ const myLibraryRoutes: Routes = [
} }
} }
}, },
{
path: 'followers',
component: MyFollowersComponent,
data: {
meta: {
title: $localize`My followers`
}
}
},
{ {
path: 'ownership', path: 'ownership',
component: MyOwnershipComponent, component: MyOwnershipComponent,

View file

@ -61,8 +61,19 @@ export class MyLibraryComponent implements OnInit {
}, },
{ {
label: $localize`Subscriptions`, label: $localize`Follows`,
routerLink: '/my-library/subscriptions' children: [
{
label: $localize`Subscriptions`,
iconName: 'subscriptions',
routerLink: '/my-library/subscriptions'
},
{
label: $localize`Followers`,
iconName: 'follower',
routerLink: '/my-library/followers'
}
]
}, },
{ {

View file

@ -13,12 +13,13 @@ import { SharedUserSubscriptionModule } from '@app/shared/shared-user-subscripti
import { SharedVideoLiveModule } from '@app/shared/shared-video-live' import { SharedVideoLiveModule } from '@app/shared/shared-video-live'
import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module' import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist/shared-video-playlist.module'
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
import { MySubscriptionsComponent } from './my-follows/my-subscriptions.component'
import { MyHistoryComponent } from './my-history/my-history.component' import { MyHistoryComponent } from './my-history/my-history.component'
import { MyLibraryRoutingModule } from './my-library-routing.module' import { MyLibraryRoutingModule } from './my-library-routing.module'
import { MyLibraryComponent } from './my-library.component' import { MyLibraryComponent } from './my-library.component'
import { MyAcceptOwnershipComponent } from './my-ownership/my-accept-ownership/my-accept-ownership.component' import { MyAcceptOwnershipComponent } from './my-ownership/my-accept-ownership/my-accept-ownership.component'
import { MyOwnershipComponent } from './my-ownership/my-ownership.component' import { MyOwnershipComponent } from './my-ownership/my-ownership.component'
import { MySubscriptionsComponent } from './my-subscriptions/my-subscriptions.component'
import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component' import { MyVideoImportsComponent } from './my-video-imports/my-video-imports.component'
import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component' import { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.component'
import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component' import { MyVideoPlaylistElementsComponent } from './my-video-playlists/my-video-playlist-elements.component'
@ -26,7 +27,7 @@ import { MyVideoPlaylistUpdateComponent } from './my-video-playlists/my-video-pl
import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component' import { MyVideoPlaylistsComponent } from './my-video-playlists/my-video-playlists.component'
import { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.component' import { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.component'
import { MyVideosComponent } from './my-videos/my-videos.component' import { MyVideosComponent } from './my-videos/my-videos.component'
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' import { MyFollowersComponent } from './my-follows/my-followers.component'
@NgModule({ @NgModule({
imports: [ imports: [
@ -61,6 +62,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto
MyAcceptOwnershipComponent, MyAcceptOwnershipComponent,
MyVideoImportsComponent, MyVideoImportsComponent,
MySubscriptionsComponent, MySubscriptionsComponent,
MyFollowersComponent,
MyHistoryComponent, MyHistoryComponent,
MyVideoPlaylistCreateComponent, MyVideoPlaylistCreateComponent,

View file

@ -1,84 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
input[type=text] {
@include peertube-input-text(300px);
}
.video-channel {
@include row-blocks;
> my-actor-avatar {
@include actor-avatar-size(80px);
@include margin-right(10px);
}
}
.video-channel-info {
flex-grow: 1;
a.video-channel-names {
@include disable-default-a-behaviour;
width: fit-content;
display: flex;
align-items: baseline;
color: pvar(--mainForegroundColor);
.video-channel-display-name {
font-weight: $font-semibold;
font-size: 18px;
}
.video-channel-name {
@include margin-left(5px);
font-size: 14px;
color: $grey-actor-name;
}
}
}
.actor-owner {
@include disable-default-a-behaviour;
font-size: 13px;
color: pvar(--mainForegroundColor);
span:hover {
opacity: 0.8;
}
my-actor-avatar {
@include margin-left(7px);
display: inline-block;
vertical-align: top;
}
}
.video-subscriptions-header {
margin-bottom: 30px;
display: flex;
}
@media screen and (max-width: $small-view) {
.video-subscriptions-header input[type=text] {
width: 100% !important;
}
.video-channel-info {
padding-bottom: 10px;
text-align: center;
.video-channel-names {
flex-direction: column;
align-items: center !important;
margin: auto;
}
}
img {
@include margin-right(0);
}
}

View file

@ -1,6 +1,6 @@
@use '_variables' as *; @use '_variables' as *;
@use '_mixins' as *; @use '_mixins' as *;
@use '_actor' as *; @use '_account-channel-page' as *;
@use '_miniature' as *; @use '_miniature' as *;
.root { .root {

View file

@ -13,9 +13,8 @@ interface QueryStringFilterPrefixes {
} }
} }
type ParseQueryStringFilterResult = { type ParseQueryStringFilters <K extends keyof any> = Partial<Record<K, string | number | boolean | (string | number | boolean)[]>>
[key: string]: string | number | boolean | (string | number | boolean)[] type ParseQueryStringFiltersResult <K extends keyof any> = ParseQueryStringFilters<K> & { search?: string }
}
@Injectable() @Injectable()
export class RestService { export class RestService {
@ -67,14 +66,17 @@ export class RestService {
return params return params
} }
componentPaginationToRestPagination (componentPagination: ComponentPaginationLight): RestPagination { componentToRestPagination (componentPagination: ComponentPaginationLight): RestPagination {
const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
const count: number = componentPagination.itemsPerPage const count: number = componentPagination.itemsPerPage
return { start, count } return { start, count }
} }
parseQueryStringFilter (q: string, prefixes: QueryStringFilterPrefixes): ParseQueryStringFilterResult { /*
* Returns an object containing the filters and the remaining search
*/
parseQueryStringFilter <T extends QueryStringFilterPrefixes> (q: string, prefixes: T): ParseQueryStringFiltersResult<keyof T> {
if (!q) return {} if (!q) return {}
// Tokenize the strings using spaces that are not in quotes // Tokenize the strings using spaces that are not in quotes
@ -90,9 +92,9 @@ export class RestService {
return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false) return prefixeStrings.every(prefixString => t.startsWith(prefixString) === false)
}) })
const additionalFilters: ParseQueryStringFilterResult = {} const additionalFilters: ParseQueryStringFilters<keyof T> = {}
for (const prefixKey of Object.keys(prefixes)) { for (const prefixKey of Object.keys(prefixes) as (keyof T)[]) {
const prefixObj = prefixes[prefixKey] const prefixObj = prefixes[prefixKey]
const prefix = prefixObj.prefix const prefix = prefixObj.prefix

View file

@ -1,3 +1,3 @@
<ng-container i18n> <ng-container i18n>
<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> <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>
</ng-container> </ng-container>

View file

@ -77,6 +77,8 @@ export class AdvancedInputFilterComponent implements OnInit, AfterViewInit {
logger('On route search change "%s".', search) logger('On route search change "%s".', search)
if (this.searchValue === search) return
this.searchValue = search this.searchValue = search
this.emitSearch() this.emitSearch()
}) })

View file

@ -19,7 +19,7 @@ export class UserHistoryService {
) {} ) {}
getUserVideosHistory (historyPagination: ComponentPaginationLight, search?: string) { getUserVideosHistory (historyPagination: ComponentPaginationLight, search?: string) {
const pagination = this.restService.componentPaginationToRestPagination(historyPagination) const pagination = this.restService.componentToRestPagination(historyPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination) params = this.restService.addRestGetParams(params, pagination)

View file

@ -29,7 +29,7 @@ export class UserNotificationService {
const { pagination, ignoreLoadingBar, unread, sort } = parameters const { pagination, ignoreLoadingBar, unread, sort } = parameters
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, this.restService.componentPaginationToRestPagination(pagination), sort) params = this.restService.addRestGetParams(params, this.restService.componentToRestPagination(pagination), sort)
if (unread) params = params.append('unread', `${unread}`) if (unread) params = params.append('unread', `${unread}`)

View file

@ -203,7 +203,7 @@
<my-global-icon iconName="cog" aria-hidden="true"></my-global-icon> <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
<div class="message" i18n> <div class="message" i18n>
<a (click)="markAsRead(notification)" [href]="notification.peertubeVersionLink" target="_blank" rel="noopener noreferer">A new version of PeerTube</a> is available: {{ notification.peertube.latestVersion }} <a (click)="markAsRead(notification)" [href]="notification.peertubeVersionLink" target="_blank" rel="noopener noreferrer">A new version of PeerTube</a> is available: {{ notification.peertube.latestVersion }}
</div> </div>
</ng-container> </ng-container>

View file

@ -50,7 +50,7 @@ export class VideoChannelService {
const { account, componentPagination, withStats = false, sort, search } = options const { account, componentPagination, withStats = false, sort, search } = options
const pagination = componentPagination const pagination = componentPagination
? this.restService.componentPaginationToRestPagination(componentPagination) ? this.restService.componentToRestPagination(componentPagination)
: { start: 0, count: 20 } : { start: 0, count: 20 }
let params = new HttpParams() let params = new HttpParams()

View file

@ -123,7 +123,7 @@ export class VideoService {
} }
getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> { getMyVideos (videoPagination: ComponentPaginationLight, sort: VideoSortField, search?: string): Observable<ResultList<Video>> {
const pagination = this.restService.componentPaginationToRestPagination(videoPagination) const pagination = this.restService.componentToRestPagination(videoPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
@ -377,7 +377,7 @@ export class VideoService {
private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) { private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options
const pagination = this.restService.componentPaginationToRestPagination(videoPagination) const pagination = this.restService.componentToRestPagination(videoPagination)
let newParams = this.restService.addRestGetParams(params, pagination, sort) let newParams = this.restService.addRestGetParams(params, pagination, sort)
if (filter) newParams = newParams.set('filter', filter) if (filter) newParams = newParams.set('filter', filter)

View file

@ -43,7 +43,7 @@ export class SearchService {
let pagination: RestPagination let pagination: RestPagination
if (componentPagination) { if (componentPagination) {
pagination = this.restService.componentPaginationToRestPagination(componentPagination) pagination = this.restService.componentToRestPagination(componentPagination)
} }
let params = new HttpParams() let params = new HttpParams()
@ -77,7 +77,7 @@ export class SearchService {
let pagination: RestPagination let pagination: RestPagination
if (componentPagination) { if (componentPagination) {
pagination = this.restService.componentPaginationToRestPagination(componentPagination) pagination = this.restService.componentToRestPagination(componentPagination)
} }
let params = new HttpParams() let params = new HttpParams()
@ -111,7 +111,7 @@ export class SearchService {
let pagination: RestPagination let pagination: RestPagination
if (componentPagination) { if (componentPagination) {
pagination = this.restService.componentPaginationToRestPagination(componentPagination) pagination = this.restService.componentToRestPagination(componentPagination)
} }
let params = new HttpParams() let params = new HttpParams()

View file

@ -6,7 +6,7 @@ import { Injectable } from '@angular/core'
import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core' import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
import { buildBulkObservable } from '@app/helpers' import { buildBulkObservable } from '@app/helpers'
import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main' import { Video, VideoChannel, VideoChannelService, VideoService } from '@app/shared/shared-main'
import { ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models' import { ActorFollow, ResultList, VideoChannel as VideoChannelServer, VideoSortField } from '@shared/models'
import { environment } from '../../../environments/environment' import { environment } from '../../../environments/environment'
const logger = debug('peertube:subscriptions:UserSubscriptionService') const logger = debug('peertube:subscriptions:UserSubscriptionService')
@ -17,6 +17,8 @@ type SubscriptionExistResultObservable = { [ uri: string ]: Observable<boolean>
@Injectable() @Injectable()
export class UserSubscriptionService { export class UserSubscriptionService {
static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions' static BASE_USER_SUBSCRIPTIONS_URL = environment.apiUrl + '/api/v1/users/me/subscriptions'
static BASE_VIDEO_CHANNELS_URL = environment.apiUrl + '/api/v1/video-channels'
static BASE_ACCOUNTS_URL = environment.apiUrl + '/api/v1/accounts'
// Use a replay subject because we "next" a value before subscribing // Use a replay subject because we "next" a value before subscribing
private existsSubject = new ReplaySubject<string>(1) private existsSubject = new ReplaySubject<string>(1)
@ -43,13 +45,46 @@ export class UserSubscriptionService {
) )
} }
listFollowers (parameters: {
pagination: ComponentPaginationLight
nameWithHost: string
search?: string
}) {
const { pagination, nameWithHost, search } = parameters
let url = `${UserSubscriptionService.BASE_ACCOUNTS_URL}/${nameWithHost}/followers`
let params = new HttpParams()
params = this.restService.addRestGetParams(params, this.restService.componentToRestPagination(pagination), '-createdAt')
if (search) {
const filters = this.restService.parseQueryStringFilter(search, {
channel: {
prefix: 'channel:'
}
})
if (filters.channel) {
url = `${UserSubscriptionService.BASE_VIDEO_CHANNELS_URL}/${filters.channel}/followers`
}
params = this.restService.addObjectParams(params, { search: filters.search })
}
return this.authHttp
.get<ResultList<ActorFollow>>(url, { params })
.pipe(
catchError(err => this.restExtractor.handleError(err))
)
}
getUserSubscriptionVideos (parameters: { getUserSubscriptionVideos (parameters: {
videoPagination: ComponentPaginationLight videoPagination: ComponentPaginationLight
sort: VideoSortField sort: VideoSortField
skipCount?: boolean skipCount?: boolean
}): Observable<ResultList<Video>> { }): Observable<ResultList<Video>> {
const { videoPagination, sort, skipCount } = parameters const { videoPagination, sort, skipCount } = parameters
const pagination = this.restService.componentPaginationToRestPagination(videoPagination) const pagination = this.restService.componentToRestPagination(videoPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)
@ -106,7 +141,7 @@ export class UserSubscriptionService {
const { pagination, search } = parameters const { pagination, search } = parameters
const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
const restPagination = this.restService.componentPaginationToRestPagination(pagination) const restPagination = this.restService.componentToRestPagination(pagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, restPagination) params = this.restService.addRestGetParams(params, restPagination)

View file

@ -81,7 +81,7 @@ export class VideoCommentService {
}): Observable<ThreadsResultList<VideoComment>> { }): Observable<ThreadsResultList<VideoComment>> {
const { videoId, componentPagination, sort } = parameters const { videoId, componentPagination, sort } = parameters
const pagination = this.restService.componentPaginationToRestPagination(componentPagination) const pagination = this.restService.componentToRestPagination(componentPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort) params = this.restService.addRestGetParams(params, pagination, sort)

View file

@ -62,7 +62,7 @@ export class VideoPlaylistService {
listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> { listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists' const url = VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/video-playlists'
const pagination = this.restService.componentPaginationToRestPagination(componentPagination) const pagination = this.restService.componentToRestPagination(componentPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination) params = this.restService.addRestGetParams(params, pagination)
@ -103,7 +103,7 @@ export class VideoPlaylistService {
): Observable<ResultList<VideoPlaylist>> { ): Observable<ResultList<VideoPlaylist>> {
const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists' const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
const pagination = componentPagination const pagination = componentPagination
? this.restService.componentPaginationToRestPagination(componentPagination) ? this.restService.componentToRestPagination(componentPagination)
: undefined : undefined
let params = new HttpParams() let params = new HttpParams()
@ -259,7 +259,7 @@ export class VideoPlaylistService {
componentPagination: ComponentPaginationLight componentPagination: ComponentPaginationLight
}): Observable<ResultList<VideoPlaylistElement>> { }): Observable<ResultList<VideoPlaylistElement>> {
const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + options.videoPlaylistId + '/videos' const path = VideoPlaylistService.BASE_VIDEO_PLAYLIST_URL + options.videoPlaylistId + '/videos'
const pagination = this.restService.componentPaginationToRestPagination(options.componentPagination) const pagination = this.restService.componentToRestPagination(options.componentPagination)
let params = new HttpParams() let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination) params = this.restService.addRestGetParams(params, pagination)

View file

@ -0,0 +1,88 @@
@use '_variables' as *;
@use '_mixins' as *;
@mixin section-label-responsive {
color: pvar(--mainColor);
font-size: 12px;
margin-bottom: 15px;
font-weight: $font-bold;
letter-spacing: 2.5px;
@media screen and (max-width: $mobile-view) {
font-size: 10px;
letter-spacing: 2.1px;
margin-bottom: 5px;
}
}
@mixin show-more-description {
color: pvar(--mainColor);
cursor: pointer;
margin: 10px auto 45px;
}
@mixin avatar-row-responsive ($img-margin, $grey-font-size) {
display: flex;
grid-column: 1;
margin-bottom: 30px;
.main-avatar {
@include actor-avatar-size(120px);
}
> div {
@include margin-left($img-margin);
min-width: 1px;
}
.actor-info {
display: flex;
> div:first-child {
flex-grow: 1;
min-width: 1px;
}
}
.actor-display-name {
@include peertube-word-wrap;
display: flex;
flex-wrap: wrap;
}
h1 {
font-size: 28px;
font-weight: $font-bold;
margin: 0;
}
.actor-handle {
@include ellipsis;
}
.actor-handle,
.actor-counters {
color: pvar(--greyForegroundColor);
font-size: $grey-font-size;
}
.actor-counters > *:not(:last-child)::after {
content: '';
margin: 0 10px;
color: pvar(--mainColor);
}
@media screen and (max-width: $mobile-view) {
margin-bottom: 15px;
h1 {
font-size: 22px;
}
.main-avatar {
@include actor-avatar-size(80px);
}
}
}

View file

@ -1,88 +1,68 @@
@use '_variables' as *; @use '_variables' as *;
@use '_mixins' as *; @use '_mixins' as *;
@mixin section-label-responsive { @mixin actor-row ($avatar-size: 80px, $avatar-margin-right: 10px, $min-height: 130px, $separator: true) {
color: pvar(--mainColor); @include row-blocks($min-height: $min-height, $separator: $separator);
font-size: 12px;
margin-bottom: 15px;
font-weight: $font-bold;
letter-spacing: 2.5px;
@media screen and (max-width: $mobile-view) { > my-actor-avatar {
font-size: 10px; @include actor-avatar-size($avatar-size);
letter-spacing: 2.1px;
margin-bottom: 5px;
}
}
@mixin show-more-description { @include margin-right($avatar-margin-right);
color: pvar(--mainColor);
cursor: pointer;
margin: 10px auto 45px;
}
@mixin avatar-row-responsive ($img-margin, $grey-font-size) {
display: flex;
grid-column: 1;
margin-bottom: 30px;
.main-avatar {
@include actor-avatar-size(120px);
}
> div {
@include margin-left($img-margin);
min-width: 1px;
} }
.actor-info { .actor-info {
display: flex; flex-grow: 1;
}
> div:first-child { .actor-names {
flex-grow: 1; @include disable-default-a-behaviour;
min-width: 1px;
} width: fit-content;
display: flex;
align-items: baseline;
color: pvar(--mainForegroundColor);
} }
.actor-display-name { .actor-display-name {
@include peertube-word-wrap; font-weight: $font-semibold;
font-size: 18px;
display: flex;
flex-wrap: wrap;
} }
h1 { .actor-name {
font-size: 28px; @include margin-left(5px);
font-weight: $font-bold;
margin: 0; font-size: 14px;
color: $grey-actor-name;
} }
.actor-handle { .actor-owner {
@include ellipsis; @include disable-default-a-behaviour;
}
.actor-handle, font-size: 13px;
.actor-counters { color: pvar(--mainForegroundColor);
color: pvar(--greyForegroundColor);
font-size: $grey-font-size;
}
.actor-counters > *:not(:last-child)::after { span:hover {
content: ''; opacity: 0.8;
margin: 0 10px;
color: pvar(--mainColor);
}
@media screen and (max-width: $mobile-view) {
margin-bottom: 15px;
h1 {
font-size: 22px;
} }
.main-avatar { my-actor-avatar {
@include actor-avatar-size(80px); @include margin-left(7px);
display: inline-block;
vertical-align: top;
}
}
@media screen and (max-width: $small-view) {
.actor-info {
padding-bottom: 10px;
text-align: center;
.actor-names {
flex-direction: column;
align-items: center !important;
margin: auto;
}
} }
} }
} }

View file

@ -653,12 +653,15 @@
@include button-with-icon(20px, 5px, -1px); @include button-with-icon(20px, 5px, -1px);
} }
@mixin row-blocks ($column-responsive: true) { @mixin row-blocks ($column-responsive: true, $min-height: 130px, $separator: true) {
display: flex; display: flex;
min-height: 130px; min-height: $min-height;
padding-bottom: 20px; padding-bottom: 20px;
margin-bottom: 20px; margin-bottom: 20px;
border-bottom: 1px solid #C6C6C6;
@if $separator {
border-bottom: 1px solid #C6C6C6;
}
@media screen and (max-width: $small-view) { @media screen and (max-width: $small-view) {
@if $column-responsive { @if $column-responsive {

View file

@ -1,5 +1,6 @@
import express from 'express' import express from 'express'
import { pickCommonVideoQuery } from '@server/helpers/query' import { pickCommonVideoQuery } from '@server/helpers/query'
import { ActorFollowModel } from '@server/models/actor/actor-follow'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils' import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
import { getFormattedObjects } from '../../helpers/utils' import { getFormattedObjects } from '../../helpers/utils'
@ -20,6 +21,7 @@ import {
} from '../../middlewares' } from '../../middlewares'
import { import {
accountNameWithHostGetValidator, accountNameWithHostGetValidator,
accountsFollowersSortValidator,
accountsSortValidator, accountsSortValidator,
ensureAuthUserOwnsAccountValidator, ensureAuthUserOwnsAccountValidator,
videoChannelsSortValidator, videoChannelsSortValidator,
@ -93,6 +95,17 @@ accountsRouter.get('/:accountName/ratings',
asyncMiddleware(listAccountRatings) asyncMiddleware(listAccountRatings)
) )
accountsRouter.get('/:accountName/followers',
authenticate,
asyncMiddleware(accountNameWithHostGetValidator),
ensureAuthUserOwnsAccountValidator,
paginationValidator,
accountsFollowersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listAccountFollowers)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -127,7 +140,7 @@ async function listAccountChannels (req: express.Request, res: express.Response)
search: req.query.search search: req.query.search
} }
const resultList = await VideoChannelModel.listByAccount(options) const resultList = await VideoChannelModel.listByAccountForAPI(options)
return res.json(getFormattedObjects(resultList.data, resultList.total)) return res.json(getFormattedObjects(resultList.data, resultList.total))
} }
@ -195,3 +208,21 @@ async function listAccountRatings (req: express.Request, res: express.Response)
}) })
return res.json(getFormattedObjects(resultList.rows, resultList.count)) return res.json(getFormattedObjects(resultList.rows, resultList.count))
} }
async function listAccountFollowers (req: express.Request, res: express.Response) {
const account = res.locals.account
const channels = await VideoChannelModel.listAllByAccount(account.id)
const actorIds = [ account.actorId ].concat(channels.map(c => c.actorId))
const resultList = await ActorFollowModel.listFollowersForApi({
actorIds,
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
state: 'accepted',
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}

View file

@ -98,7 +98,7 @@ export {
async function listFollowing (req: express.Request, res: express.Response) { async function listFollowing (req: express.Request, res: express.Response) {
const serverActor = await getServerActor() const serverActor = await getServerActor()
const resultList = await ActorFollowModel.listFollowingForApi({ const resultList = await ActorFollowModel.listInstanceFollowingForApi({
id: serverActor.id, id: serverActor.id,
start: req.query.start, start: req.query.start,
count: req.query.count, count: req.query.count,
@ -114,7 +114,7 @@ async function listFollowing (req: express.Request, res: express.Response) {
async function listFollowers (req: express.Request, res: express.Response) { async function listFollowers (req: express.Request, res: express.Response) {
const serverActor = await getServerActor() const serverActor = await getServerActor()
const resultList = await ActorFollowModel.listFollowersForApi({ const resultList = await ActorFollowModel.listFollowersForApi({
actorId: serverActor.id, actorIds: [ serverActor.id ],
start: req.query.start, start: req.query.start,
count: req.query.count, count: req.query.count,
sort: req.query.sort, sort: req.query.sort,

View file

@ -95,7 +95,7 @@ async function areSubscriptionsExist (req: express.Request, res: express.Respons
return { name, host, uri: u } return { name, host, uri: u }
}) })
const results = await ActorFollowModel.listSubscribedIn(user.Account.Actor.id, handles) const results = await ActorFollowModel.listSubscriptionsOf(user.Account.Actor.id, handles)
const existObject: { [id: string ]: boolean } = {} const existObject: { [id: string ]: boolean } = {}
for (const handle of handles) { for (const handle of handles) {

View file

@ -1,6 +1,7 @@
import express from 'express' import express from 'express'
import { pickCommonVideoQuery } from '@server/helpers/query' import { pickCommonVideoQuery } from '@server/helpers/query'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
import { ActorFollowModel } from '@server/models/actor/actor-follow'
import { getServerActor } from '@server/models/application/application' import { getServerActor } from '@server/models/application/application'
import { MChannelBannerAccountDefault } from '@server/types/models' import { MChannelBannerAccountDefault } from '@server/types/models'
import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared' import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared'
@ -33,7 +34,13 @@ import {
videoChannelsUpdateValidator, videoChannelsUpdateValidator,
videoPlaylistsSortValidator videoPlaylistsSortValidator
} from '../../middlewares' } from '../../middlewares'
import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators' import {
ensureAuthUserOwnsChannelValidator,
videoChannelsFollowersSortValidator,
videoChannelsListValidator,
videoChannelsNameWithHostValidator,
videosSortValidator
} from '../../middlewares/validators'
import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
import { AccountModel } from '../../models/account/account' import { AccountModel } from '../../models/account/account'
@ -65,8 +72,8 @@ videoChannelRouter.post('/',
videoChannelRouter.post('/:nameWithHost/avatar/pick', videoChannelRouter.post('/:nameWithHost/avatar/pick',
authenticate, authenticate,
reqAvatarFile, reqAvatarFile,
// Check the rights asyncMiddleware(videoChannelsNameWithHostValidator),
asyncMiddleware(videoChannelsUpdateValidator), ensureAuthUserOwnsChannelValidator,
updateAvatarValidator, updateAvatarValidator,
asyncMiddleware(updateVideoChannelAvatar) asyncMiddleware(updateVideoChannelAvatar)
) )
@ -74,29 +81,31 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick',
videoChannelRouter.post('/:nameWithHost/banner/pick', videoChannelRouter.post('/:nameWithHost/banner/pick',
authenticate, authenticate,
reqBannerFile, reqBannerFile,
// Check the rights asyncMiddleware(videoChannelsNameWithHostValidator),
asyncMiddleware(videoChannelsUpdateValidator), ensureAuthUserOwnsChannelValidator,
updateBannerValidator, updateBannerValidator,
asyncMiddleware(updateVideoChannelBanner) asyncMiddleware(updateVideoChannelBanner)
) )
videoChannelRouter.delete('/:nameWithHost/avatar', videoChannelRouter.delete('/:nameWithHost/avatar',
authenticate, authenticate,
// Check the rights asyncMiddleware(videoChannelsNameWithHostValidator),
asyncMiddleware(videoChannelsUpdateValidator), ensureAuthUserOwnsChannelValidator,
asyncMiddleware(deleteVideoChannelAvatar) asyncMiddleware(deleteVideoChannelAvatar)
) )
videoChannelRouter.delete('/:nameWithHost/banner', videoChannelRouter.delete('/:nameWithHost/banner',
authenticate, authenticate,
// Check the rights asyncMiddleware(videoChannelsNameWithHostValidator),
asyncMiddleware(videoChannelsUpdateValidator), ensureAuthUserOwnsChannelValidator,
asyncMiddleware(deleteVideoChannelBanner) asyncMiddleware(deleteVideoChannelBanner)
) )
videoChannelRouter.put('/:nameWithHost', videoChannelRouter.put('/:nameWithHost',
authenticate, authenticate,
asyncMiddleware(videoChannelsUpdateValidator), asyncMiddleware(videoChannelsNameWithHostValidator),
ensureAuthUserOwnsChannelValidator,
videoChannelsUpdateValidator,
asyncRetryTransactionMiddleware(updateVideoChannel) asyncRetryTransactionMiddleware(updateVideoChannel)
) )
@ -132,6 +141,17 @@ videoChannelRouter.get('/:nameWithHost/videos',
asyncMiddleware(listVideoChannelVideos) asyncMiddleware(listVideoChannelVideos)
) )
videoChannelRouter.get('/:nameWithHost/followers',
authenticate,
asyncMiddleware(videoChannelsNameWithHostValidator),
ensureAuthUserOwnsChannelValidator,
paginationValidator,
videoChannelsFollowersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoChannelFollowers)
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -332,3 +352,18 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon
return res.json(getFormattedObjects(resultList.data, resultList.total)) return res.json(getFormattedObjects(resultList.data, resultList.total))
} }
async function listVideoChannelFollowers (req: express.Request, res: express.Response) {
const channel = res.locals.videoChannel
const resultList = await ActorFollowModel.listFollowersForApi({
actorIds: [ channel.actorId ],
start: req.query.start,
count: req.query.count,
sort: req.query.sort,
search: req.query.search,
state: 'accepted',
})
return res.json(getFormattedObjects(resultList.data, resultList.total))
}

View file

@ -69,8 +69,11 @@ const SORTABLE_COLUMNS = {
VIDEO_RATES: [ 'createdAt' ], VIDEO_RATES: [ 'createdAt' ],
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ], BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
INSTANCE_FOLLOWERS: [ 'createdAt', 'state', 'score' ], INSTANCE_FOLLOWERS: [ 'createdAt', 'state', 'score' ],
INSTANCE_FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ], INSTANCE_FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ],
ACCOUNT_FOLLOWERS: [ 'createdAt' ],
CHANNEL_FOLLOWERS: [ 'createdAt' ],
VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ], VIDEOS: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'trending', 'hot', 'best' ],

View file

@ -53,6 +53,9 @@ const pluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.PLUGINS)
const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS) const availablePluginsSortValidator = checkSortFactory(SORTABLE_COLUMNS.AVAILABLE_PLUGINS)
const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES) const videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export { export {
@ -79,5 +82,7 @@ export {
videoPlaylistsSortValidator, videoPlaylistsSortValidator,
videoRedundanciesSortValidator, videoRedundanciesSortValidator,
videoPlaylistsSearchSortValidator, videoPlaylistsSearchSortValidator,
accountsFollowersSortValidator,
videoChannelsFollowersSortValidator,
pluginsSortValidator pluginsSortValidator
} }

View file

@ -3,9 +3,7 @@ import { body, param, query } from 'express-validator'
import { omit } from 'lodash' import { omit } from 'lodash'
import { Hooks } from '@server/lib/plugins/hooks' import { Hooks } from '@server/lib/plugins/hooks'
import { MUserDefault } from '@server/types/models' import { MUserDefault } from '@server/types/models'
import { HttpStatusCode } from '../../../shared/models/http/http-error-codes' import { HttpStatusCode, UserRegister, UserRole } from '@shared/models'
import { UserRole } from '../../../shared/models/users'
import { UserRegister } from '../../../shared/models/users/user-register.model'
import { toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' import { toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins' import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
import { import {
@ -462,7 +460,22 @@ const ensureAuthUserOwnsAccountValidator = [
if (res.locals.account.id !== user.Account.id) { if (res.locals.account.id !== user.Account.id) {
return res.fail({ return res.fail({
status: HttpStatusCode.FORBIDDEN_403, status: HttpStatusCode.FORBIDDEN_403,
message: 'Only owner can access ratings list.' message: 'Only owner of this account can access this ressource.'
})
}
return next()
}
]
const ensureAuthUserOwnsChannelValidator = [
(req: express.Request, res: express.Response, next: express.NextFunction) => {
const user = res.locals.oauth.token.User
if (res.locals.videoChannel.Account.userId !== user.id) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Only owner of this video channel can access this ressource'
}) })
} }
@ -506,6 +519,7 @@ export {
usersVerifyEmailValidator, usersVerifyEmailValidator,
userAutocompleteValidator, userAutocompleteValidator,
ensureAuthUserOwnsAccountValidator, ensureAuthUserOwnsAccountValidator,
ensureAuthUserOwnsChannelValidator,
ensureCanManageUser ensureCanManageUser
} }

View file

@ -65,22 +65,6 @@ const videoChannelsUpdateValidator = [
logger.debug('Checking videoChannelsUpdate parameters', { parameters: req.body }) logger.debug('Checking videoChannelsUpdate parameters', { parameters: req.body })
if (areValidationErrors(req, res)) return if (areValidationErrors(req, res)) return
if (!await doesVideoChannelNameWithHostExist(req.params.nameWithHost, res)) return
// We need to make additional checks
if (res.locals.videoChannel.Actor.isOwned() === false) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot update video channel of another server'
})
}
if (res.locals.videoChannel.Account.userId !== res.locals.oauth.token.User.id) {
return res.fail({
status: HttpStatusCode.FORBIDDEN_403,
message: 'Cannot update video channel of another user'
})
}
return next() return next()
} }

View file

@ -143,7 +143,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
* @deprecated Use `findOrCreateCustom` instead * @deprecated Use `findOrCreateCustom` instead
*/ */
static findOrCreate (): any { static findOrCreate (): any {
throw new Error('Should not be called') throw new Error('Must not be called')
} }
// findOrCreate has issues with actor follow hooks // findOrCreate has issues with actor follow hooks
@ -288,7 +288,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
return ActorFollowModel.findOne(query) return ActorFollowModel.findOne(query)
} }
static listSubscribedIn (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> { static listSubscriptionsOf (actorId: number, targets: { name: string, host?: string }[]): Promise<MActorFollowFollowingHost[]> {
const whereTab = targets const whereTab = targets
.map(t => { .map(t => {
if (t.host) { if (t.host) {
@ -348,7 +348,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
return ActorFollowModel.findAll(query) return ActorFollowModel.findAll(query)
} }
static listFollowingForApi (options: { static listInstanceFollowingForApi (options: {
id: number id: number
start: number start: number
count: number count: number
@ -415,7 +415,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
} }
static listFollowersForApi (options: { static listFollowersForApi (options: {
actorId: number actorIds: number[]
start: number start: number
count: number count: number
sort: string sort: string
@ -423,7 +423,7 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
actorType?: ActivityPubActorType actorType?: ActivityPubActorType
search?: string search?: string
}) { }) {
const { actorId, start, count, sort, search, state, actorType } = options const { actorIds, start, count, sort, search, state, actorType } = options
const followWhere = state ? { state } : {} const followWhere = state ? { state } : {}
const followerWhere: WhereOptions = {} const followerWhere: WhereOptions = {}
@ -452,20 +452,16 @@ export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowMo
model: ActorModel, model: ActorModel,
required: true, required: true,
as: 'ActorFollower', as: 'ActorFollower',
where: followerWhere, where: followerWhere
include: [
{
model: ServerModel,
required: true
}
]
}, },
{ {
model: ActorModel, model: ActorModel,
as: 'ActorFollowing', as: 'ActorFollowing',
required: true, required: true,
where: { where: {
id: actorId id: {
[Op.in]: actorIds
}
} }
} }
] ]

View file

@ -26,7 +26,7 @@ import {
isVideoChannelDisplayNameValid, isVideoChannelDisplayNameValid,
isVideoChannelSupportValid isVideoChannelSupportValid
} from '../../helpers/custom-validators/video-channels' } from '../../helpers/custom-validators/video-channels'
import { CONSTRAINTS_FIELDS, WEBSERVER } from '../../initializers/constants' import { CONSTRAINTS_FIELDS, VIDEO_CHANNELS, WEBSERVER } from '../../initializers/constants'
import { sendDeleteActor } from '../../lib/activitypub/send' import { sendDeleteActor } from '../../lib/activitypub/send'
import { import {
MChannelActor, MChannelActor,
@ -527,7 +527,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
}) })
} }
static listByAccount (options: { static listByAccountForAPI (options: {
accountId: number accountId: number
start: number start: number
count: number count: number
@ -582,6 +582,26 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
}) })
} }
static listAllByAccount (accountId: number) {
const query = {
limit: VIDEO_CHANNELS.MAX_PER_USER,
include: [
{
attributes: [],
model: AccountModel,
where: {
id: accountId
},
required: true
}
]
}
return VideoChannelModel.findAll(query)
}
static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> { static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
return VideoChannelModel.unscoped() return VideoChannelModel.unscoped()
.scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ]) .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])

View file

@ -840,6 +840,34 @@ describe('Test users API validators', function () {
}) })
}) })
describe('When getting my global followers', function () {
const path = '/api/v1/accounts/user1/followers'
it('Should fail with a bad start pagination', async function () {
await checkBadStartPagination(server.url, path, userToken)
})
it('Should fail with a bad count pagination', async function () {
await checkBadCountPagination(server.url, path, userToken)
})
it('Should fail with an incorrect sort', async function () {
await checkBadSortPagination(server.url, path, userToken)
})
it('Should fail with a unauthenticated user', async function () {
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail with a another user', async function () {
await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should succeed with the correct params', async function () {
await makeGetRequest({ url: server.url, path, token: userToken, expectedStatus: HttpStatusCode.OK_200 })
})
})
describe('When blocking/unblocking/removing user', function () { describe('When blocking/unblocking/removing user', function () {
it('Should fail with an incorrect id', async function () { it('Should fail with an incorrect id', async function () {

View file

@ -321,6 +321,34 @@ describe('Test video channels API validator', function () {
}) })
}) })
describe('When getting channel followers', function () {
const path = '/api/v1/video-channels/super_channel/followers'
it('Should fail with a bad start pagination', async function () {
await checkBadStartPagination(server.url, path, server.accessToken)
})
it('Should fail with a bad count pagination', async function () {
await checkBadCountPagination(server.url, path, server.accessToken)
})
it('Should fail with an incorrect sort', async function () {
await checkBadSortPagination(server.url, path, server.accessToken)
})
it('Should fail with a unauthenticated user', async function () {
await makeGetRequest({ url: server.url, path, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
})
it('Should fail with a another user', async function () {
await makeGetRequest({ url: server.url, path, token: accessTokenUser, expectedStatus: HttpStatusCode.FORBIDDEN_403 })
})
it('Should succeed with the correct params', async function () {
await makeGetRequest({ url: server.url, path, token: server.accessToken, expectedStatus: HttpStatusCode.OK_200 })
})
})
describe('When deleting a video channel', function () { describe('When deleting a video channel', function () {
it('Should fail with a non authenticated user', async function () { it('Should fail with a non authenticated user', async function () {
await command.delete({ token: 'coucou', channelName: 'super_channel', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) await command.delete({ token: 'coucou', channelName: 'super_channel', expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })

View file

@ -368,6 +368,162 @@ describe('Test users subscriptions', function () {
} }
}) })
it('Should follow user channels of server 3 by root of server 3', async function () {
this.timeout(60000)
await servers[2].channels.create({ token: users[2].accessToken, attributes: { name: 'user3_channel2' } })
await servers[2].subscriptions.add({ token: servers[2].accessToken, targetUri: 'user3_channel@localhost:' + servers[2].port })
await servers[2].subscriptions.add({ token: servers[2].accessToken, targetUri: 'user3_channel2@localhost:' + servers[2].port })
await waitJobs(servers)
})
it('Should list user 3 followers', async function () {
{
const { total, data } = await servers[2].accounts.listFollowers({
token: users[2].accessToken,
accountName: 'user3',
start: 0,
count: 5,
sort: 'createdAt'
})
expect(total).to.equal(3)
expect(data[0].following.host).to.equal(servers[2].host)
expect(data[0].following.name).to.equal('user3_channel')
expect(data[0].follower.host).to.equal(servers[0].host)
expect(data[0].follower.name).to.equal('user1')
expect(data[1].following.host).to.equal(servers[2].host)
expect(data[1].following.name).to.equal('user3_channel')
expect(data[1].follower.host).to.equal(servers[2].host)
expect(data[1].follower.name).to.equal('root')
expect(data[2].following.host).to.equal(servers[2].host)
expect(data[2].following.name).to.equal('user3_channel2')
expect(data[2].follower.host).to.equal(servers[2].host)
expect(data[2].follower.name).to.equal('root')
}
{
const { total, data } = await servers[2].accounts.listFollowers({
token: users[2].accessToken,
accountName: 'user3',
start: 0,
count: 1,
sort: '-createdAt'
})
expect(total).to.equal(3)
expect(data[0].following.host).to.equal(servers[2].host)
expect(data[0].following.name).to.equal('user3_channel2')
expect(data[0].follower.host).to.equal(servers[2].host)
expect(data[0].follower.name).to.equal('root')
}
{
const { total, data } = await servers[2].accounts.listFollowers({
token: users[2].accessToken,
accountName: 'user3',
start: 1,
count: 1,
sort: '-createdAt'
})
expect(total).to.equal(3)
expect(data[0].following.host).to.equal(servers[2].host)
expect(data[0].following.name).to.equal('user3_channel')
expect(data[0].follower.host).to.equal(servers[2].host)
expect(data[0].follower.name).to.equal('root')
}
{
const { total, data } = await servers[2].accounts.listFollowers({
token: users[2].accessToken,
accountName: 'user3',
search: 'user1',
sort: '-createdAt'
})
expect(total).to.equal(1)
expect(data[0].following.host).to.equal(servers[2].host)
expect(data[0].following.name).to.equal('user3_channel')
expect(data[0].follower.host).to.equal(servers[0].host)
expect(data[0].follower.name).to.equal('user1')
}
})
it('Should list user3_channel followers', async function () {
{
const { total, data } = await servers[2].channels.listFollowers({
token: users[2].accessToken,
channelName: 'user3_channel',
start: 0,
count: 5,
sort: 'createdAt'
})
expect(total).to.equal(2)
expect(data[0].following.host).to.equal(servers[2].host)
expect(data[0].following.name).to.equal('user3_channel')
expect(data[0].follower.host).to.equal(servers[0].host)
expect(data[0].follower.name).to.equal('user1')
expect(data[1].following.host).to.equal(servers[2].host)
expect(data[1].following.name).to.equal('user3_channel')
expect(data[1].follower.host).to.equal(servers[2].host)
expect(data[1].follower.name).to.equal('root')
}
{
const { total, data } = await servers[2].channels.listFollowers({
token: users[2].accessToken,
channelName: 'user3_channel',
start: 0,
count: 1,
sort: '-createdAt'
})
expect(total).to.equal(2)
expect(data[0].following.host).to.equal(servers[2].host)
expect(data[0].following.name).to.equal('user3_channel')
expect(data[0].follower.host).to.equal(servers[2].host)
expect(data[0].follower.name).to.equal('root')
}
{
const { total, data } = await servers[2].channels.listFollowers({
token: users[2].accessToken,
channelName: 'user3_channel',
start: 1,
count: 1,
sort: '-createdAt'
})
expect(total).to.equal(2)
expect(data[0].following.host).to.equal(servers[2].host)
expect(data[0].following.name).to.equal('user3_channel')
expect(data[0].follower.host).to.equal(servers[0].host)
expect(data[0].follower.name).to.equal('root')
}
{
const { total, data } = await servers[2].channels.listFollowers({
token: users[2].accessToken,
channelName: 'user3_channel',
search: 'user1',
sort: '-createdAt'
})
expect(total).to.equal(1)
expect(data[0].following.host).to.equal(servers[2].host)
expect(data[0].following.name).to.equal('user3_channel')
expect(data[0].follower.host).to.equal(servers[0].host)
expect(data[0].follower.name).to.equal('user1')
}
})
after(async function () { after(async function () {
await cleanupTests(servers) await cleanupTests(servers)
}) })

View file

@ -1,5 +1,5 @@
import { HttpStatusCode, ResultList } from '@shared/models' import { HttpStatusCode, ResultList } from '@shared/models'
import { Account } from '../../models/actors' import { Account, ActorFollow } from '../../models/actors'
import { AccountVideoRate, VideoRateType } from '../../models/videos' import { AccountVideoRate, VideoRateType } from '../../models/videos'
import { AbstractCommand, OverrideCommandOptions } from '../shared' import { AbstractCommand, OverrideCommandOptions } from '../shared'
@ -53,4 +53,26 @@ export class AccountsCommand extends AbstractCommand {
defaultExpectedStatus: HttpStatusCode.OK_200 defaultExpectedStatus: HttpStatusCode.OK_200
}) })
} }
listFollowers (options: OverrideCommandOptions & {
accountName: string
start?: number
count?: number
sort?: string
search?: string
}) {
const { accountName, start, count, sort, search } = options
const path = '/api/v1/accounts/' + accountName + '/followers'
const query = { start, count, sort, search }
return this.getRequestBody<ResultList<ActorFollow>>({
...options,
path,
query,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
} }

View file

@ -1,5 +1,5 @@
import { pick } from '@shared/core-utils' import { pick } from '@shared/core-utils'
import { HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models' import { ActorFollow, HttpStatusCode, ResultList, VideoChannel, VideoChannelCreateResult } from '@shared/models'
import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model' import { VideoChannelCreate } from '../../models/videos/channel/video-channel-create.model'
import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model' import { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model'
import { unwrapBody } from '../requests' import { unwrapBody } from '../requests'
@ -47,7 +47,7 @@ export class ChannelsCommand extends AbstractCommand {
} }
async create (options: OverrideCommandOptions & { async create (options: OverrideCommandOptions & {
attributes: VideoChannelCreate attributes: Partial<VideoChannelCreate>
}) { }) {
const path = '/api/v1/video-channels/' const path = '/api/v1/video-channels/'
@ -153,4 +153,26 @@ export class ChannelsCommand extends AbstractCommand {
defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
}) })
} }
listFollowers (options: OverrideCommandOptions & {
channelName: string
start?: number
count?: number
sort?: string
search?: string
}) {
const { channelName, start, count, sort, search } = options
const path = '/api/v1/video-channels/' + channelName + '/followers'
const query = { start, count, sort, search }
return this.getRequestBody<ResultList<ActorFollow>>({
...options,
path,
query,
implicitToken: true,
defaultExpectedStatus: HttpStatusCode.OK_200
})
}
} }