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) {
const pagination = this.restService.componentPaginationToRestPagination(this.followersPagination)
const pagination = this.restService.componentToRestPagination(this.followersPagination)
this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({
@ -106,7 +106,7 @@ export class AboutFollowsComponent implements OnInit {
}
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' })
.subscribe({

View File

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

View File

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

View File

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

View File

@ -27,7 +27,12 @@
<div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
</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>

View File

@ -54,6 +54,10 @@ my-edit-button {
color: $grey-actor-name;
}
.video-channel-followers {
color: pvar(--mainForegroundColor);
}
.video-channel-buttons {
margin-top: 10px;
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="video-channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let videoChannel of videoChannels" class="video-channel">
<div class="actors" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()">
<div *ngFor="let videoChannel of videoChannels" class="actor">
<my-actor-avatar [channel]="videoChannel" [internalHref]="[ '/c', videoChannel.nameWithHost ]"></my-actor-avatar>
<div class="video-channel-info">
<a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="video-channel-names" i18n-title title="Channel page">
<div class="video-channel-display-name">{{ videoChannel.displayName }}</div>
<div class="video-channel-name">{{ videoChannel.nameWithHost }}</div>
<div class="actor-info">
<a [routerLink]="[ '/c', videoChannel.nameWithHost ]" class="actor-names" i18n-title title="Channel page">
<div class="actor-display-name">{{ videoChannel.displayName }}</div>
<div class="actor-name">{{ videoChannel.nameWithHost }}</div>
</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">
<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 { RouterModule, Routes } from '@angular/router'
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 { MyLibraryComponent } from './my-library.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 { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.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',
component: MyOwnershipComponent,

View File

@ -61,8 +61,19 @@ export class MyLibraryComponent implements OnInit {
},
{
label: $localize`Subscriptions`,
routerLink: '/my-library/subscriptions'
label: $localize`Follows`,
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 { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
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 { MyLibraryRoutingModule } from './my-library-routing.module'
import { MyLibraryComponent } from './my-library.component'
import { MyAcceptOwnershipComponent } from './my-ownership/my-accept-ownership/my-accept-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 { MyVideoPlaylistCreateComponent } from './my-video-playlists/my-video-playlist-create.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 { VideoChangeOwnershipComponent } from './my-videos/modals/video-change-ownership.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({
imports: [
@ -61,6 +62,7 @@ import { SharedActorImageModule } from '../shared/shared-actor-image/shared-acto
MyAcceptOwnershipComponent,
MyVideoImportsComponent,
MySubscriptionsComponent,
MyFollowersComponent,
MyHistoryComponent,
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 '_mixins' as *;
@use '_actor' as *;
@use '_account-channel-page' as *;
@use '_miniature' as *;
.root {

View File

@ -13,9 +13,8 @@ interface QueryStringFilterPrefixes {
}
}
type ParseQueryStringFilterResult = {
[key: string]: string | number | boolean | (string | number | boolean)[]
}
type ParseQueryStringFilters <K extends keyof any> = Partial<Record<K, string | number | boolean | (string | number | boolean)[]>>
type ParseQueryStringFiltersResult <K extends keyof any> = ParseQueryStringFilters<K> & { search?: string }
@Injectable()
export class RestService {
@ -67,14 +66,17 @@ export class RestService {
return params
}
componentPaginationToRestPagination (componentPagination: ComponentPaginationLight): RestPagination {
componentToRestPagination (componentPagination: ComponentPaginationLight): RestPagination {
const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
const count: number = componentPagination.itemsPerPage
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 {}
// 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)
})
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 prefix = prefixObj.prefix

View File

@ -1,3 +1,3 @@
<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>

View File

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

View File

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

View File

@ -29,7 +29,7 @@ export class UserNotificationService {
const { pagination, ignoreLoadingBar, unread, sort } = parameters
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}`)

View File

@ -203,7 +203,7 @@
<my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
<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>
</ng-container>

View File

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

View File

@ -123,7 +123,7 @@ export class VideoService {
}
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()
params = this.restService.addRestGetParams(params, pagination, sort)
@ -377,7 +377,7 @@ export class VideoService {
private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) {
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)
if (filter) newParams = newParams.set('filter', filter)

View File

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

View File

@ -6,7 +6,7 @@ import { Injectable } from '@angular/core'
import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
import { buildBulkObservable } from '@app/helpers'
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'
const logger = debug('peertube:subscriptions:UserSubscriptionService')
@ -17,6 +17,8 @@ type SubscriptionExistResultObservable = { [ uri: string ]: Observable<boolean>
@Injectable()
export class UserSubscriptionService {
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
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: {
videoPagination: ComponentPaginationLight
sort: VideoSortField
skipCount?: boolean
}): Observable<ResultList<Video>> {
const { videoPagination, sort, skipCount } = parameters
const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
const pagination = this.restService.componentToRestPagination(videoPagination)
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
@ -106,7 +141,7 @@ export class UserSubscriptionService {
const { pagination, search } = parameters
const url = UserSubscriptionService.BASE_USER_SUBSCRIPTIONS_URL
const restPagination = this.restService.componentPaginationToRestPagination(pagination)
const restPagination = this.restService.componentToRestPagination(pagination)
let params = new HttpParams()
params = this.restService.addRestGetParams(params, restPagination)

View File

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

View File

@ -62,7 +62,7 @@ export class VideoPlaylistService {
listChannelPlaylists (videoChannel: VideoChannel, componentPagination: ComponentPaginationLight): Observable<ResultList<VideoPlaylist>> {
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()
params = this.restService.addRestGetParams(params, pagination)
@ -103,7 +103,7 @@ export class VideoPlaylistService {
): Observable<ResultList<VideoPlaylist>> {
const url = AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/video-playlists'
const pagination = componentPagination
? this.restService.componentPaginationToRestPagination(componentPagination)
? this.restService.componentToRestPagination(componentPagination)
: undefined
let params = new HttpParams()
@ -259,7 +259,7 @@ export class VideoPlaylistService {
componentPagination: ComponentPaginationLight
}): Observable<ResultList<VideoPlaylistElement>> {
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()
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 '_mixins' as *;
@mixin section-label-responsive {
color: pvar(--mainColor);
font-size: 12px;
margin-bottom: 15px;
font-weight: $font-bold;
letter-spacing: 2.5px;
@mixin actor-row ($avatar-size: 80px, $avatar-margin-right: 10px, $min-height: 130px, $separator: true) {
@include row-blocks($min-height: $min-height, $separator: $separator);
@media screen and (max-width: $mobile-view) {
font-size: 10px;
letter-spacing: 2.1px;
margin-bottom: 5px;
}
}
> my-actor-avatar {
@include actor-avatar-size($avatar-size);
@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;
@include margin-right($avatar-margin-right);
}
.actor-info {
display: flex;
flex-grow: 1;
}
> div:first-child {
flex-grow: 1;
min-width: 1px;
}
.actor-names {
@include disable-default-a-behaviour;
width: fit-content;
display: flex;
align-items: baseline;
color: pvar(--mainForegroundColor);
}
.actor-display-name {
@include peertube-word-wrap;
display: flex;
flex-wrap: wrap;
font-weight: $font-semibold;
font-size: 18px;
}
h1 {
font-size: 28px;
font-weight: $font-bold;
margin: 0;
.actor-name {
@include margin-left(5px);
font-size: 14px;
color: $grey-actor-name;
}
.actor-handle {
@include ellipsis;
}
.actor-owner {
@include disable-default-a-behaviour;
.actor-handle,
.actor-counters {
color: pvar(--greyForegroundColor);
font-size: $grey-font-size;
}
font-size: 13px;
color: pvar(--mainForegroundColor);
.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;
span:hover {
opacity: 0.8;
}
.main-avatar {
@include actor-avatar-size(80px);
my-actor-avatar {
@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);
}
@mixin row-blocks ($column-responsive: true) {
@mixin row-blocks ($column-responsive: true, $min-height: 130px, $separator: true) {
display: flex;
min-height: 130px;
min-height: $min-height;
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 1px solid #C6C6C6;
@if $separator {
border-bottom: 1px solid #C6C6C6;
}
@media screen and (max-width: $small-view) {
@if $column-responsive {

View File

@ -1,5 +1,6 @@
import express from 'express'
import { pickCommonVideoQuery } from '@server/helpers/query'
import { ActorFollowModel } from '@server/models/actor/actor-follow'
import { getServerActor } from '@server/models/application/application'
import { buildNSFWFilter, getCountVideos, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
import { getFormattedObjects } from '../../helpers/utils'
@ -20,6 +21,7 @@ import {
} from '../../middlewares'
import {
accountNameWithHostGetValidator,
accountsFollowersSortValidator,
accountsSortValidator,
ensureAuthUserOwnsAccountValidator,
videoChannelsSortValidator,
@ -93,6 +95,17 @@ accountsRouter.get('/:accountName/ratings',
asyncMiddleware(listAccountRatings)
)
accountsRouter.get('/:accountName/followers',
authenticate,
asyncMiddleware(accountNameWithHostGetValidator),
ensureAuthUserOwnsAccountValidator,
paginationValidator,
accountsFollowersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listAccountFollowers)
)
// ---------------------------------------------------------------------------
export {
@ -127,7 +140,7 @@ async function listAccountChannels (req: express.Request, res: express.Response)
search: req.query.search
}
const resultList = await VideoChannelModel.listByAccount(options)
const resultList = await VideoChannelModel.listByAccountForAPI(options)
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))
}
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) {
const serverActor = await getServerActor()
const resultList = await ActorFollowModel.listFollowingForApi({
const resultList = await ActorFollowModel.listInstanceFollowingForApi({
id: serverActor.id,
start: req.query.start,
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) {
const serverActor = await getServerActor()
const resultList = await ActorFollowModel.listFollowersForApi({
actorId: serverActor.id,
actorIds: [ serverActor.id ],
start: req.query.start,
count: req.query.count,
sort: req.query.sort,

View File

@ -95,7 +95,7 @@ async function areSubscriptionsExist (req: express.Request, res: express.Respons
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 } = {}
for (const handle of handles) {

View File

@ -1,6 +1,7 @@
import express from 'express'
import { pickCommonVideoQuery } from '@server/helpers/query'
import { Hooks } from '@server/lib/plugins/hooks'
import { ActorFollowModel } from '@server/models/actor/actor-follow'
import { getServerActor } from '@server/models/application/application'
import { MChannelBannerAccountDefault } from '@server/types/models'
import { ActorImageType, VideoChannelCreate, VideoChannelUpdate } from '../../../shared'
@ -33,7 +34,13 @@ import {
videoChannelsUpdateValidator,
videoPlaylistsSortValidator
} 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 { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
import { AccountModel } from '../../models/account/account'
@ -65,8 +72,8 @@ videoChannelRouter.post('/',
videoChannelRouter.post('/:nameWithHost/avatar/pick',
authenticate,
reqAvatarFile,
// Check the rights
asyncMiddleware(videoChannelsUpdateValidator),
asyncMiddleware(videoChannelsNameWithHostValidator),
ensureAuthUserOwnsChannelValidator,
updateAvatarValidator,
asyncMiddleware(updateVideoChannelAvatar)
)
@ -74,29 +81,31 @@ videoChannelRouter.post('/:nameWithHost/avatar/pick',
videoChannelRouter.post('/:nameWithHost/banner/pick',
authenticate,
reqBannerFile,
// Check the rights
asyncMiddleware(videoChannelsUpdateValidator),
asyncMiddleware(videoChannelsNameWithHostValidator),
ensureAuthUserOwnsChannelValidator,
updateBannerValidator,
asyncMiddleware(updateVideoChannelBanner)
)
videoChannelRouter.delete('/:nameWithHost/avatar',
authenticate,
// Check the rights
asyncMiddleware(videoChannelsUpdateValidator),
asyncMiddleware(videoChannelsNameWithHostValidator),
ensureAuthUserOwnsChannelValidator,
asyncMiddleware(deleteVideoChannelAvatar)
)
videoChannelRouter.delete('/:nameWithHost/banner',
authenticate,
// Check the rights
asyncMiddleware(videoChannelsUpdateValidator),
asyncMiddleware(videoChannelsNameWithHostValidator),
ensureAuthUserOwnsChannelValidator,
asyncMiddleware(deleteVideoChannelBanner)
)
videoChannelRouter.put('/:nameWithHost',
authenticate,
asyncMiddleware(videoChannelsUpdateValidator),
asyncMiddleware(videoChannelsNameWithHostValidator),
ensureAuthUserOwnsChannelValidator,
videoChannelsUpdateValidator,
asyncRetryTransactionMiddleware(updateVideoChannel)
)
@ -132,6 +141,17 @@ videoChannelRouter.get('/:nameWithHost/videos',
asyncMiddleware(listVideoChannelVideos)
)
videoChannelRouter.get('/:nameWithHost/followers',
authenticate,
asyncMiddleware(videoChannelsNameWithHostValidator),
ensureAuthUserOwnsChannelValidator,
paginationValidator,
videoChannelsFollowersSortValidator,
setDefaultSort,
setDefaultPagination,
asyncMiddleware(listVideoChannelFollowers)
)
// ---------------------------------------------------------------------------
export {
@ -332,3 +352,18 @@ async function listVideoChannelVideos (req: express.Request, res: express.Respon
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' ],
BLACKLISTS: [ 'id', 'name', 'duration', 'views', 'likes', 'dislikes', 'uuid', 'createdAt' ],
INSTANCE_FOLLOWERS: [ 'createdAt', 'state', 'score' ],
INSTANCE_FOLLOWING: [ 'createdAt', 'redundancyAllowed', 'state' ],
ACCOUNT_FOLLOWERS: [ 'createdAt' ],
CHANNEL_FOLLOWERS: [ 'createdAt' ],
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 videoRedundanciesSortValidator = checkSortFactory(SORTABLE_COLUMNS.VIDEO_REDUNDANCIES)
const accountsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.ACCOUNT_FOLLOWERS)
const videoChannelsFollowersSortValidator = checkSortFactory(SORTABLE_COLUMNS.CHANNEL_FOLLOWERS)
// ---------------------------------------------------------------------------
export {
@ -79,5 +82,7 @@ export {
videoPlaylistsSortValidator,
videoRedundanciesSortValidator,
videoPlaylistsSearchSortValidator,
accountsFollowersSortValidator,
videoChannelsFollowersSortValidator,
pluginsSortValidator
}

View File

@ -3,9 +3,7 @@ import { body, param, query } from 'express-validator'
import { omit } from 'lodash'
import { Hooks } from '@server/lib/plugins/hooks'
import { MUserDefault } from '@server/types/models'
import { HttpStatusCode } from '../../../shared/models/http/http-error-codes'
import { UserRole } from '../../../shared/models/users'
import { UserRegister } from '../../../shared/models/users/user-register.model'
import { HttpStatusCode, UserRegister, UserRole } from '@shared/models'
import { toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
import {
@ -462,7 +460,22 @@ const ensureAuthUserOwnsAccountValidator = [
if (res.locals.account.id !== user.Account.id) {
return res.fail({
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,
userAutocompleteValidator,
ensureAuthUserOwnsAccountValidator,
ensureAuthUserOwnsChannelValidator,
ensureCanManageUser
}

View File

@ -65,22 +65,6 @@ const videoChannelsUpdateValidator = [
logger.debug('Checking videoChannelsUpdate parameters', { parameters: req.body })
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()
}

View File

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

View File

@ -26,7 +26,7 @@ import {
isVideoChannelDisplayNameValid,
isVideoChannelSupportValid
} 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 {
MChannelActor,
@ -527,7 +527,7 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
})
}
static listByAccount (options: {
static listByAccountForAPI (options: {
accountId: number
start: 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> {
return VideoChannelModel.unscoped()
.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 () {
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 () {
it('Should fail with a non authenticated user', async function () {
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 () {
await cleanupTests(servers)
})

View File

@ -1,5 +1,5 @@
import { HttpStatusCode, ResultList } from '@shared/models'
import { Account } from '../../models/actors'
import { Account, ActorFollow } from '../../models/actors'
import { AccountVideoRate, VideoRateType } from '../../models/videos'
import { AbstractCommand, OverrideCommandOptions } from '../shared'
@ -53,4 +53,26 @@ export class AccountsCommand extends AbstractCommand {
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 { 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 { VideoChannelUpdate } from '../../models/videos/channel/video-channel-update.model'
import { unwrapBody } from '../requests'
@ -47,7 +47,7 @@ export class ChannelsCommand extends AbstractCommand {
}
async create (options: OverrideCommandOptions & {
attributes: VideoChannelCreate
attributes: Partial<VideoChannelCreate>
}) {
const path = '/api/v1/video-channels/'
@ -153,4 +153,26 @@ export class ChannelsCommand extends AbstractCommand {
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
})
}
}