--- /dev/null
+import { Validators } from '@angular/forms'
+
+export const VIDEO_COMMENT_TEXT = {
+ VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
+ MESSAGES: {
+ 'required': 'Comment is required.',
+ 'minlength': 'Comment must be at least 2 characters long.',
+ 'maxlength': 'Comment cannot be more than 3000 characters long.'
+ }
+}
top: -2px;
&.icon-edit {
- background-image: url('../../../assets/images/global/edit.svg');
+ background-image: url('../../../assets/images/global/edit-grey.svg');
}
&.icon-delete-grey {
--- /dev/null
+export interface ComponentPagination {
+ currentPage: number
+ itemsPerPage: number
+ totalItems?: number
+}
import { Injectable } from '@angular/core'
import { HttpParams } from '@angular/common/http'
import { SortMeta } from 'primeng/components/common/sortmeta'
+import { ComponentPagination } from './component-pagination.model'
import { RestPagination } from './rest-pagination'
return newParams
}
+ componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination {
+ const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
+ const count: number = componentPagination.itemsPerPage
+
+ return { start, count }
+ }
}
import { NotificationsService } from 'angular2-notifications'
import { Observable } from 'rxjs/Observable'
import { AuthService } from '../../core/auth'
+import { ComponentPagination } from '../rest/component-pagination.model'
import { SortField } from './sort-field.type'
-import { VideoPagination } from './video-pagination.model'
import { Video } from './video.model'
export abstract class AbstractVideoList implements OnInit {
- pagination: VideoPagination = {
+ pagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 25,
totalItems: null
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- font-weight: bold;
transition: color 0.2s;
font-size: 16px;
font-weight: $font-semibold;
+++ /dev/null
-export interface VideoPagination {
- currentPage: number
- itemsPerPage: number
- totalItems?: number
-}
import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type'
import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model'
import { environment } from '../../../environments/environment'
+import { ComponentPagination } from '../rest/component-pagination.model'
import { RestExtractor } from '../rest/rest-extractor.service'
import { RestService } from '../rest/rest.service'
import { UserService } from '../users/user.service'
import { SortField } from './sort-field.type'
import { VideoDetails } from './video-details.model'
import { VideoEdit } from './video-edit.model'
-import { VideoPagination } from './video-pagination.model'
import { Video } from './video.model'
@Injectable()
.catch(this.restExtractor.handleError)
}
- getMyVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> {
- const pagination = this.videoPaginationToRestPagination(videoPagination)
+ getMyVideos (videoPagination: ComponentPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> {
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
.catch((res) => this.restExtractor.handleError(res))
}
- getVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> {
- const pagination = this.videoPaginationToRestPagination(videoPagination)
+ getVideos (videoPagination: ComponentPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> {
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
.catch((res) => this.restExtractor.handleError(res))
}
- searchVideos (search: string, videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> {
+ searchVideos (
+ search: string,
+ videoPagination: ComponentPagination,
+ sort: SortField
+ ): Observable<{ videos: Video[], totalVideos: number}> {
const url = VideoService.BASE_VIDEO_URL + 'search'
- const pagination = this.videoPaginationToRestPagination(videoPagination)
+ const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
let params = new HttpParams()
params = this.restService.addRestGetParams(params, pagination, sort)
.catch(res => this.restExtractor.handleError(res))
}
- private videoPaginationToRestPagination (videoPagination: VideoPagination) {
- const start: number = (videoPagination.currentPage - 1) * videoPagination.itemsPerPage
- const count: number = videoPagination.itemsPerPage
-
- return { start, count }
- }
-
private setVideoRate (id: number, rateType: VideoRateType) {
const url = VideoService.BASE_VIDEO_URL + id + '/rate'
const body: UserVideoRateUpdate = {
--- /dev/null
+<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
+ <div class="form-group">
+ <textarea placeholder="Add comment..." formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }">
+ </textarea>
+ <div *ngIf="formErrors.text" class="form-error">
+ {{ formErrors.text }}
+ </div>
+ </div>
+
+ <div class="submit-comment">
+ <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid }">
+ Post comment
+ </button>
+ </div>
+</form>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.form-group {
+ margin-bottom: 10px;
+}
+
+textarea {
+ @include peertube-textarea(100%, 150px);
+}
+
+.submit-comment {
+ display: flex;
+ justify-content: end;
+
+ button {
+ @include peertube-button;
+ @include orange-button
+ }
+}
--- /dev/null
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
+import { FormBuilder, FormGroup } from '@angular/forms'
+import { NotificationsService } from 'angular2-notifications'
+import { Observable } from 'rxjs/Observable'
+import { VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
+import { FormReactive } from '../../../shared'
+import { VIDEO_COMMENT_TEXT } from '../../../shared/forms/form-validators/video-comment'
+import { Video } from '../../../shared/video/video.model'
+import { VideoComment } from './video-comment.model'
+import { VideoCommentService } from './video-comment.service'
+
+@Component({
+ selector: 'my-video-comment-add',
+ templateUrl: './video-comment-add.component.html',
+ styleUrls: ['./video-comment-add.component.scss']
+})
+export class VideoCommentAddComponent extends FormReactive implements OnInit {
+ @Input() video: Video
+ @Input() parentComment: VideoComment
+
+ @Output() commentCreated = new EventEmitter<VideoCommentCreate>()
+
+ form: FormGroup
+ formErrors = {
+ 'text': ''
+ }
+ validationMessages = {
+ 'text': VIDEO_COMMENT_TEXT.MESSAGES
+ }
+
+ constructor (
+ private formBuilder: FormBuilder,
+ private notificationsService: NotificationsService,
+ private videoCommentService: VideoCommentService
+ ) {
+ super()
+ }
+
+ buildForm () {
+ this.form = this.formBuilder.group({
+ text: [ '', VIDEO_COMMENT_TEXT.VALIDATORS ]
+ })
+
+ this.form.valueChanges.subscribe(data => this.onValueChanged(data))
+ }
+
+ ngOnInit () {
+ this.buildForm()
+ }
+
+ formValidated () {
+ const commentCreate: VideoCommentCreate = this.form.value
+ let obs: Observable<any>
+
+ if (this.parentComment) {
+ obs = this.addCommentReply(commentCreate)
+ } else {
+ obs = this.addCommentThread(commentCreate)
+ }
+
+ obs.subscribe(
+ comment => {
+ this.commentCreated.emit(comment)
+ this.form.reset()
+ },
+
+ err => this.notificationsService.error('Error', err.text)
+ )
+}
+
+ isAddButtonDisplayed () {
+ return this.form.value['text']
+ }
+
+ private addCommentReply (commentCreate: VideoCommentCreate) {
+ return this.videoCommentService
+ .addCommentReply(this.video.id, this.parentComment.id, commentCreate)
+ }
+
+ private addCommentThread (commentCreate: VideoCommentCreate) {
+ return this.videoCommentService
+ .addCommentThread(this.video.id, commentCreate)
+ }
+}
--- /dev/null
+<div class="comment">
+ <div class="comment-account-date">
+ <div class="comment-account">{{ comment.by }}</div>
+ <div class="comment-date">{{ comment.createdAt | myFromNow }}</div>
+ </div>
+ <div>{{ comment.text }}</div>
+
+ <div class="comment-actions">
+ <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply">Reply</div>
+ </div>
+
+ <my-video-comment-add
+ *ngIf="isUserLoggedIn() && inReplyToCommentId === comment.id" [video]="video" [parentComment]="comment"
+ (commentCreated)="onCommentReplyCreated($event)"
+ ></my-video-comment-add>
+
+ <div *ngIf="commentTree" class="children">
+ <div *ngFor="let commentChild of commentTree.children">
+ <my-video-comment
+ [comment]="commentChild.comment"
+ [video]="video"
+ [inReplyToCommentId]="inReplyToCommentId"
+ [commentTree]="commentChild"
+ (wantedToReply)="onWantedToReply($event)"
+ (resetReply)="onResetReply()"
+ ></my-video-comment>
+ </div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.comment {
+ font-size: 15px;
+ margin-top: 30px;
+
+ .comment-account-date {
+ display: flex;
+ margin-bottom: 4px;
+
+ .comment-account {
+ font-weight: $font-bold;
+ }
+
+ .comment-date {
+ color: #585858;
+ margin-left: 10px;
+ }
+ }
+
+ .comment-actions {
+ margin: 10px 0;
+
+ .comment-action-reply {
+ color: #585858;
+ cursor: pointer;
+ }
+ }
+}
+
+.children {
+ margin-left: 20px;
+
+ .comment {
+ margin-top: 15px;
+ }
+}
--- /dev/null
+import { Component, EventEmitter, Input, Output } from '@angular/core'
+import { NotificationsService } from 'angular2-notifications'
+import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
+import { AuthService } from '../../../core/auth'
+import { User } from '../../../shared/users'
+import { Video } from '../../../shared/video/video.model'
+import { VideoComment } from './video-comment.model'
+import { VideoCommentService } from './video-comment.service'
+
+@Component({
+ selector: 'my-video-comment',
+ templateUrl: './video-comment.component.html',
+ styleUrls: ['./video-comment.component.scss']
+})
+export class VideoCommentComponent {
+ @Input() video: Video
+ @Input() comment: VideoComment
+ @Input() commentTree: VideoCommentThreadTree
+ @Input() inReplyToCommentId: number
+
+ @Output() wantedToReply = new EventEmitter<VideoComment>()
+ @Output() resetReply = new EventEmitter()
+
+ constructor (private authService: AuthService,
+ private notificationsService: NotificationsService,
+ private videoCommentService: VideoCommentService) {
+ }
+
+ onCommentReplyCreated (comment: VideoComment) {
+ this.videoCommentService.addCommentReply(this.video.id, this.comment.id, comment)
+ .subscribe(
+ createdComment => {
+ if (!this.commentTree) {
+ this.commentTree = {
+ comment: this.comment,
+ children: []
+ }
+ }
+
+ this.commentTree.children.push({
+ comment: createdComment,
+ children: []
+ })
+ this.resetReply.emit()
+ },
+
+ err => this.notificationsService.error('Error', err.message)
+ )
+ }
+
+ onWantToReply () {
+ this.wantedToReply.emit(this.comment)
+ }
+
+ isUserLoggedIn () {
+ return this.authService.isLoggedIn()
+ }
+
+ // Event from child comment
+ onWantedToReply (comment: VideoComment) {
+ this.wantedToReply.emit(comment)
+ }
+
+ onResetReply () {
+ this.resetReply.emit()
+ }
+}
--- /dev/null
+import { VideoComment as VideoCommentServerModel } from '../../../../../../shared/models/videos/video-comment.model'
+
+export class VideoComment implements VideoCommentServerModel {
+ id: number
+ url: string
+ text: string
+ threadId: number
+ inReplyToCommentId: number
+ videoId: number
+ createdAt: Date | string
+ updatedAt: Date | string
+ account: {
+ name: string
+ host: string
+ }
+ totalReplies: number
+
+ by: string
+
+ private static createByString (account: string, serverHost: string) {
+ return account + '@' + serverHost
+ }
+
+ constructor (hash: VideoCommentServerModel) {
+ this.id = hash.id
+ this.url = hash.url
+ this.text = hash.text
+ this.threadId = hash.threadId
+ this.inReplyToCommentId = hash.inReplyToCommentId
+ this.videoId = hash.videoId
+ this.createdAt = new Date(hash.createdAt.toString())
+ this.updatedAt = new Date(hash.updatedAt.toString())
+ this.account = hash.account
+ this.totalReplies = hash.totalReplies
+
+ this.by = VideoComment.createByString(this.account.name, this.account.host)
+ }
+}
--- /dev/null
+import { HttpClient, HttpParams } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import 'rxjs/add/operator/catch'
+import 'rxjs/add/operator/map'
+import { Observable } from 'rxjs/Observable'
+import { ResultList } from '../../../../../../shared/models'
+import {
+ VideoComment as VideoCommentServerModel, VideoCommentCreate,
+ VideoCommentThreadTree
+} from '../../../../../../shared/models/videos/video-comment.model'
+import { environment } from '../../../../environments/environment'
+import { RestExtractor, RestService } from '../../../shared/rest'
+import { ComponentPagination } from '../../../shared/rest/component-pagination.model'
+import { SortField } from '../../../shared/video/sort-field.type'
+import { VideoComment } from './video-comment.model'
+
+@Injectable()
+export class VideoCommentService {
+ private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
+
+ constructor (
+ private authHttp: HttpClient,
+ private restExtractor: RestExtractor,
+ private restService: RestService
+ ) {}
+
+ addCommentThread (videoId: number | string, comment: VideoCommentCreate) {
+ const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
+
+ return this.authHttp.post(url, comment)
+ .map(data => this.extractVideoComment(data['comment']))
+ .catch(this.restExtractor.handleError)
+ }
+
+ addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) {
+ const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
+
+ return this.authHttp.post(url, comment)
+ .map(data => this.extractVideoComment(data['comment']))
+ .catch(this.restExtractor.handleError)
+ }
+
+ getVideoCommentThreads (
+ videoId: number | string,
+ componentPagination: ComponentPagination,
+ sort: SortField
+ ): Observable<{ comments: VideoComment[], totalComments: number}> {
+ const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
+
+ let params = new HttpParams()
+ params = this.restService.addRestGetParams(params, pagination, sort)
+
+ const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
+ return this.authHttp
+ .get(url, { params })
+ .map(this.extractVideoComments)
+ .catch((res) => this.restExtractor.handleError(res))
+ }
+
+ getVideoThreadComments (videoId: number | string, threadId: number): Observable<VideoCommentThreadTree> {
+ const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
+
+ return this.authHttp
+ .get(url)
+ .map(tree => this.extractVideoCommentTree(tree as VideoCommentThreadTree))
+ .catch((res) => this.restExtractor.handleError(res))
+ }
+
+ private extractVideoComment (videoComment: VideoCommentServerModel) {
+ return new VideoComment(videoComment)
+ }
+
+ private extractVideoComments (result: ResultList<VideoCommentServerModel>) {
+ const videoCommentsJson = result.data
+ const totalComments = result.total
+ const comments = []
+
+ for (const videoCommentJson of videoCommentsJson) {
+ comments.push(new VideoComment(videoCommentJson))
+ }
+
+ return { comments, totalComments }
+ }
+
+ private extractVideoCommentTree (tree: VideoCommentThreadTree) {
+ if (!tree) return tree
+
+ tree.comment = new VideoComment(tree.comment)
+ tree.children.forEach(c => this.extractVideoCommentTree(c))
+
+ return tree
+ }
+}
--- /dev/null
+<div>
+ <div class="title-page title-page-single">
+ Comments
+ </div>
+
+ <my-video-comment-add
+ *ngIf="isUserLoggedIn()"
+ [video]="video"
+ (commentCreated)="onCommentThreadCreated($event)"
+ ></my-video-comment-add>
+
+ <div class="comment-threads">
+ <div *ngFor="let comment of comments">
+ <my-video-comment
+ [comment]="comment"
+ [video]="video"
+ [inReplyToCommentId]="inReplyToCommentId"
+ [commentTree]="threadComments[comment.id]"
+ (wantedToReply)="onWantedToReply($event)"
+ (resetReply)="onResetReply()"
+ ></my-video-comment>
+
+ <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment)" class="view-replies">
+ View all {{ comment.totalReplies }} replies
+
+ <span *ngIf="!threadLoading[comment.id]" class="glyphicon glyphicon-menu-down"></span>
+ <my-loader class="comment-thread-loading" [loading]="threadLoading[comment.id]"></my-loader>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+@import '_variables';
+@import '_mixins';
+
+.view-replies {
+ font-weight: $font-semibold;
+ font-size: 15px;
+ cursor: pointer;
+}
+
+.glyphicon, .comment-thread-loading {
+ margin-left: 5px;
+ display: inline-block;
+ font-size: 13px;
+}
--- /dev/null
+import { Component, Input, OnInit } from '@angular/core'
+import { NotificationsService } from 'angular2-notifications'
+import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
+import { AuthService } from '../../../core/auth'
+import { ComponentPagination } from '../../../shared/rest/component-pagination.model'
+import { User } from '../../../shared/users'
+import { SortField } from '../../../shared/video/sort-field.type'
+import { Video } from '../../../shared/video/video.model'
+import { VideoComment } from './video-comment.model'
+import { VideoCommentService } from './video-comment.service'
+
+@Component({
+ selector: 'my-video-comments',
+ templateUrl: './video-comments.component.html',
+ styleUrls: ['./video-comments.component.scss']
+})
+export class VideoCommentsComponent implements OnInit {
+ @Input() video: Video
+ @Input() user: User
+
+ comments: VideoComment[] = []
+ sort: SortField = '-createdAt'
+ componentPagination: ComponentPagination = {
+ currentPage: 1,
+ itemsPerPage: 25,
+ totalItems: null
+ }
+ inReplyToCommentId: number
+ threadComments: { [ id: number ]: VideoCommentThreadTree } = {}
+ threadLoading: { [ id: number ]: boolean } = {}
+
+ constructor (
+ private authService: AuthService,
+ private notificationsService: NotificationsService,
+ private videoCommentService: VideoCommentService
+ ) {}
+
+ ngOnInit () {
+ this.videoCommentService.getVideoCommentThreads(this.video.id, this.componentPagination, this.sort)
+ .subscribe(
+ res => {
+ this.comments = res.comments
+ this.componentPagination.totalItems = res.totalComments
+ },
+
+ err => this.notificationsService.error('Error', err.message)
+ )
+ }
+
+ viewReplies (comment: VideoComment) {
+ this.threadLoading[comment.id] = true
+
+ this.videoCommentService.getVideoThreadComments(this.video.id, comment.id)
+ .subscribe(
+ res => {
+ this.threadComments[comment.id] = res
+ this.threadLoading[comment.id] = false
+ },
+
+ err => this.notificationsService.error('Error', err.message)
+ )
+ }
+
+ onCommentThreadCreated (comment: VideoComment) {
+ this.comments.unshift(comment)
+ }
+
+ onWantedToReply (comment: VideoComment) {
+ this.inReplyToCommentId = comment.id
+ }
+
+ onResetReply () {
+ this.inReplyToCommentId = undefined
+ }
+
+ isUserLoggedIn () {
+ return this.authService.isLoggedIn()
+ }
+}
--- /dev/null
+<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+
+ <div class="modal-header">
+ <span class="close" aria-hidden="true" (click)="hide()"></span>
+ <h4 class="modal-title">Download video</h4>
+ </div>
+
+ <div class="modal-body">
+ <div class="peertube-select-container">
+ <select [(ngModel)]="resolution">
+ <option *ngFor="let file of video.files" [value]="file.resolution">{{ file.resolutionLabel }}</option>
+ </select>
+ </div>
+
+ <div class="download-type">
+ <div class="peertube-radio-container">
+ <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
+ <label for="download-torrent">Torrent</label>
+ </div>
+
+ <div class="peertube-radio-container">
+ <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
+ <label for="download-direct">Direct download</label>
+ </div>
+ </div>
+
+ <div class="form-group inputs">
+ <span class="action-button action-button-cancel" (click)="hide()">
+ Cancel
+ </span>
+
+ <input
+ type="submit" value="Download" class="action-button-submit"
+ (click)="download()"
+ >
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+@import 'variables';
+@import 'mixins';
+
+.peertube-select-container {
+ @include peertube-select-container(130px);
+}
+
+.download-type {
+ margin-top: 30px;
+
+ .peertube-radio-container {
+ @include peertube-radio-container;
+
+ display: inline-block;
+ margin-right: 30px;
+ }
+}
--- /dev/null
+import { Component, Input, OnInit, ViewChild } from '@angular/core'
+import { ModalDirective } from 'ngx-bootstrap/modal'
+import { VideoDetails } from '../../../shared/video/video-details.model'
+
+@Component({
+ selector: 'my-video-download',
+ templateUrl: './video-download.component.html',
+ styleUrls: [ './video-download.component.scss' ]
+})
+export class VideoDownloadComponent implements OnInit {
+ @Input() video: VideoDetails = null
+
+ @ViewChild('modal') modal: ModalDirective
+
+ downloadType: 'direct' | 'torrent' = 'torrent'
+ resolution = -1
+
+ constructor () {
+ // empty
+ }
+
+ ngOnInit () {
+ this.resolution = this.video.files[0].resolution
+ }
+
+ show () {
+ this.modal.show()
+ }
+
+ hide () {
+ this.modal.hide()
+ }
+
+ download () {
+ const file = this.video.files.find(f => f.resolution === this.resolution)
+ if (!file) {
+ console.error('Could not find file with resolution %d.', this.resolution)
+ return
+ }
+
+ const link = this.downloadType === 'direct' ? file.fileUrl : file.torrentUrl
+ window.open(link)
+ }
+}
--- /dev/null
+<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+
+ <div class="modal-header">
+ <span class="close" aria-hidden="true" (click)="hide()"></span>
+ <h4 class="modal-title">Report video</h4>
+ </div>
+
+ <div class="modal-body">
+
+ <form novalidate [formGroup]="form" (ngSubmit)="report()">
+ <div class="form-group">
+ <textarea placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }">
+ </textarea>
+ <div *ngIf="formErrors.reason" class="form-error">
+ {{ formErrors.reason }}
+ </div>
+ </div>
+
+ <div class="form-group inputs">
+ <span class="action-button action-button-cancel" (click)="hide()">
+ Cancel
+ </span>
+
+ <input
+ type="submit" value="Submit" class="action-button-submit"
+ [disabled]="!form.valid"
+ >
+ </div>
+ </form>
+
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+@import 'variables';
+@import 'mixins';
+
+textarea {
+ @include peertube-textarea(100%, 60px);
+}
--- /dev/null
+import { Component, Input, OnInit, ViewChild } from '@angular/core'
+import { FormBuilder, FormGroup } from '@angular/forms'
+import { NotificationsService } from 'angular2-notifications'
+import { ModalDirective } from 'ngx-bootstrap/modal'
+import { FormReactive, VIDEO_ABUSE_REASON, VideoAbuseService } from '../../../shared/index'
+import { VideoDetails } from '../../../shared/video/video-details.model'
+
+@Component({
+ selector: 'my-video-report',
+ templateUrl: './video-report.component.html',
+ styleUrls: [ './video-report.component.scss' ]
+})
+export class VideoReportComponent extends FormReactive implements OnInit {
+ @Input() video: VideoDetails = null
+
+ @ViewChild('modal') modal: ModalDirective
+
+ error: string = null
+ form: FormGroup
+ formErrors = {
+ reason: ''
+ }
+ validationMessages = {
+ reason: VIDEO_ABUSE_REASON.MESSAGES
+ }
+
+ constructor (
+ private formBuilder: FormBuilder,
+ private videoAbuseService: VideoAbuseService,
+ private notificationsService: NotificationsService
+ ) {
+ super()
+ }
+
+ ngOnInit () {
+ this.buildForm()
+ }
+
+ buildForm () {
+ this.form = this.formBuilder.group({
+ reason: [ '', VIDEO_ABUSE_REASON.VALIDATORS ]
+ })
+
+ this.form.valueChanges.subscribe(data => this.onValueChanged(data))
+ }
+
+ show () {
+ this.modal.show()
+ }
+
+ hide () {
+ this.modal.hide()
+ }
+
+ report () {
+ const reason = this.form.value['reason']
+
+ this.videoAbuseService.reportVideo(this.video.id, reason)
+ .subscribe(
+ () => {
+ this.notificationsService.success('Success', 'Video reported.')
+ this.hide()
+ },
+
+ err => this.notificationsService.error('Error', err.message)
+ )
+ }
+}
--- /dev/null
+<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
+ <div class="modal-dialog">
+ <div class="modal-content">
+
+ <div class="modal-header">
+ <span class="close" aria-hidden="true" (click)="hide()"></span>
+ <h4 class="modal-title">Share</h4>
+ </div>
+
+ <div class="modal-body">
+ <div class="form-group">
+ <label>URL</label>
+ <div class="input-group input-group-sm">
+ <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getVideoUrl()" />
+ <div class="input-group-btn" placement="bottom right">
+ <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-default btn-search">
+ <span class="glyphicon glyphicon-copy"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label>Embed</label>
+ <div class="input-group input-group-sm">
+ <input #shareInput (click)="shareInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getVideoIframeCode()" />
+ <div class="input-group-btn" placement="bottom right">
+ <button [ngxClipboard]="shareInput" (click)="activateCopiedMessage()" type="button" class="btn btn-default btn-search">
+ <span class="glyphicon glyphicon-copy"></span>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div *ngIf="notSecure()" class="alert alert-warning">
+ The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS websites).
+ </div>
+
+ <div class="form-group inputs">
+ <span class="action-button action-button-cancel" (click)="hide()">
+ Cancel
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
--- /dev/null
+.action-button-cancel {
+ margin-right: 0 !important;
+}
--- /dev/null
+import { Component, Input, ViewChild } from '@angular/core'
+
+import { NotificationsService } from 'angular2-notifications'
+
+import { ModalDirective } from 'ngx-bootstrap/modal'
+import { VideoDetails } from '../../../shared/video/video-details.model'
+
+@Component({
+ selector: 'my-video-share',
+ templateUrl: './video-share.component.html',
+ styleUrls: [ './video-share.component.scss' ]
+})
+export class VideoShareComponent {
+ @Input() video: VideoDetails = null
+
+ @ViewChild('modal') modal: ModalDirective
+
+ constructor (private notificationsService: NotificationsService) {
+ // empty
+ }
+
+ show () {
+ this.modal.show()
+ }
+
+ hide () {
+ this.modal.hide()
+ }
+
+ getVideoIframeCode () {
+ return '<iframe width="560" height="315" ' +
+ 'src="' + this.video.embedUrl + '" ' +
+ 'frameborder="0" allowfullscreen>' +
+ '</iframe>'
+ }
+
+ getVideoUrl () {
+ return window.location.href
+ }
+
+ notSecure () {
+ return window.location.protocol === 'http:'
+ }
+
+ activateCopiedMessage () {
+ this.notificationsService.success('Success', 'Copied')
+ }
+}
+++ /dev/null
-<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
- <div class="modal-dialog">
- <div class="modal-content">
-
- <div class="modal-header">
- <span class="close" aria-hidden="true" (click)="hide()"></span>
- <h4 class="modal-title">Download video</h4>
- </div>
-
- <div class="modal-body">
- <div class="peertube-select-container">
- <select [(ngModel)]="resolution">
- <option *ngFor="let file of video.files" [value]="file.resolution">{{ file.resolutionLabel }}</option>
- </select>
- </div>
-
- <div class="download-type">
- <div class="peertube-radio-container">
- <input type="radio" name="download" id="download-torrent" [(ngModel)]="downloadType" value="torrent">
- <label for="download-torrent">Torrent</label>
- </div>
-
- <div class="peertube-radio-container">
- <input type="radio" name="download" id="download-direct" [(ngModel)]="downloadType" value="direct">
- <label for="download-direct">Direct download</label>
- </div>
- </div>
-
- <div class="form-group inputs">
- <span class="action-button action-button-cancel" (click)="hide()">
- Cancel
- </span>
-
- <input
- type="submit" value="Download" class="action-button-submit"
- (click)="download()"
- >
- </div>
- </div>
- </div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-.peertube-select-container {
- @include peertube-select-container(130px);
-}
-
-.download-type {
- margin-top: 30px;
-
- .peertube-radio-container {
- @include peertube-radio-container;
-
- display: inline-block;
- margin-right: 30px;
- }
-}
+++ /dev/null
-import { Component, Input, OnInit, ViewChild } from '@angular/core'
-import { ModalDirective } from 'ngx-bootstrap/modal'
-import { VideoDetails } from '../../shared/video/video-details.model'
-
-@Component({
- selector: 'my-video-download',
- templateUrl: './video-download.component.html',
- styleUrls: [ './video-download.component.scss' ]
-})
-export class VideoDownloadComponent implements OnInit {
- @Input() video: VideoDetails = null
-
- @ViewChild('modal') modal: ModalDirective
-
- downloadType: 'direct' | 'torrent' = 'torrent'
- resolution = -1
-
- constructor () {
- // empty
- }
-
- ngOnInit () {
- this.resolution = this.video.files[0].resolution
- }
-
- show () {
- this.modal.show()
- }
-
- hide () {
- this.modal.hide()
- }
-
- download () {
- const file = this.video.files.find(f => f.resolution === this.resolution)
- if (!file) {
- console.error('Could not find file with resolution %d.', this.resolution)
- return
- }
-
- const link = this.downloadType === 'direct' ? file.fileUrl : file.torrentUrl
- window.open(link)
- }
-}
+++ /dev/null
-<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
- <div class="modal-dialog">
- <div class="modal-content">
-
- <div class="modal-header">
- <span class="close" aria-hidden="true" (click)="hide()"></span>
- <h4 class="modal-title">Report video</h4>
- </div>
-
- <div class="modal-body">
-
- <form novalidate [formGroup]="form" (ngSubmit)="report()">
- <div class="form-group">
- <textarea placeholder="Reason..." formControlName="reason" [ngClass]="{ 'input-error': formErrors['reason'] }">
- </textarea>
- <div *ngIf="formErrors.reason" class="form-error">
- {{ formErrors.reason }}
- </div>
- </div>
-
- <div class="form-group inputs">
- <span class="action-button action-button-cancel" (click)="hide()">
- Cancel
- </span>
-
- <input
- type="submit" value="Submit" class="action-button-submit"
- [disabled]="!form.valid"
- >
- </div>
- </form>
-
- </div>
- </div>
- </div>
-</div>
+++ /dev/null
-@import '_variables';
-@import '_mixins';
-
-textarea {
- @include peertube-textarea(100%, 60px);
-}
+++ /dev/null
-import { Component, Input, OnInit, ViewChild } from '@angular/core'
-import { FormBuilder, FormGroup } from '@angular/forms'
-import { NotificationsService } from 'angular2-notifications'
-import { ModalDirective } from 'ngx-bootstrap/modal'
-import { FormReactive, VIDEO_ABUSE_REASON, VideoAbuseService } from '../../shared'
-import { VideoDetails } from '../../shared/video/video-details.model'
-
-@Component({
- selector: 'my-video-report',
- templateUrl: './video-report.component.html',
- styleUrls: [ './video-report.component.scss' ]
-})
-export class VideoReportComponent extends FormReactive implements OnInit {
- @Input() video: VideoDetails = null
-
- @ViewChild('modal') modal: ModalDirective
-
- error: string = null
- form: FormGroup
- formErrors = {
- reason: ''
- }
- validationMessages = {
- reason: VIDEO_ABUSE_REASON.MESSAGES
- }
-
- constructor (
- private formBuilder: FormBuilder,
- private videoAbuseService: VideoAbuseService,
- private notificationsService: NotificationsService
- ) {
- super()
- }
-
- ngOnInit () {
- this.buildForm()
- }
-
- buildForm () {
- this.form = this.formBuilder.group({
- reason: [ '', VIDEO_ABUSE_REASON.VALIDATORS ]
- })
-
- this.form.valueChanges.subscribe(data => this.onValueChanged(data))
- }
-
- show () {
- this.modal.show()
- }
-
- hide () {
- this.modal.hide()
- }
-
- report () {
- const reason = this.form.value['reason']
-
- this.videoAbuseService.reportVideo(this.video.id, reason)
- .subscribe(
- () => {
- this.notificationsService.success('Success', 'Video reported.')
- this.hide()
- },
-
- err => this.notificationsService.error('Error', err.message)
- )
- }
-}
+++ /dev/null
-<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
- <div class="modal-dialog">
- <div class="modal-content">
-
- <div class="modal-header">
- <span class="close" aria-hidden="true" (click)="hide()"></span>
- <h4 class="modal-title">Share</h4>
- </div>
-
- <div class="modal-body">
- <div class="form-group">
- <label>URL</label>
- <div class="input-group input-group-sm">
- <input #urlInput (click)="urlInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getVideoUrl()" />
- <div class="input-group-btn" placement="bottom right">
- <button [ngxClipboard]="urlInput" (click)="activateCopiedMessage()" type="button" class="btn btn-default btn-search">
- <span class="glyphicon glyphicon-copy"></span>
- </button>
- </div>
- </div>
- </div>
-
- <div class="form-group">
- <label>Embed</label>
- <div class="input-group input-group-sm">
- <input #shareInput (click)="shareInput.select()" type="text" class="form-control input-sm readonly" readonly [value]="getVideoIframeCode()" />
- <div class="input-group-btn" placement="bottom right">
- <button [ngxClipboard]="shareInput" (click)="activateCopiedMessage()" type="button" class="btn btn-default btn-search">
- <span class="glyphicon glyphicon-copy"></span>
- </button>
- </div>
- </div>
- </div>
-
- <div *ngIf="notSecure()" class="alert alert-warning">
- The url is not secured (no HTTPS), so the embed video won't work on HTTPS websites (web browsers block non secured HTTP requests on HTTPS websites).
- </div>
-
- <div class="form-group inputs">
- <span class="action-button action-button-cancel" (click)="hide()">
- Cancel
- </span>
- </div>
- </div>
- </div>
- </div>
-</div>
+++ /dev/null
-.action-button-cancel {
- margin-right: 0 !important;
-}
+++ /dev/null
-import { Component, Input, ViewChild } from '@angular/core'
-
-import { NotificationsService } from 'angular2-notifications'
-
-import { ModalDirective } from 'ngx-bootstrap/modal'
-import { VideoDetails } from '../../shared/video/video-details.model'
-
-@Component({
- selector: 'my-video-share',
- templateUrl: './video-share.component.html',
- styleUrls: [ './video-share.component.scss' ]
-})
-export class VideoShareComponent {
- @Input() video: VideoDetails = null
-
- @ViewChild('modal') modal: ModalDirective
-
- constructor (private notificationsService: NotificationsService) {
- // empty
- }
-
- show () {
- this.modal.show()
- }
-
- hide () {
- this.modal.hide()
- }
-
- getVideoIframeCode () {
- return '<iframe width="560" height="315" ' +
- 'src="' + this.video.embedUrl + '" ' +
- 'frameborder="0" allowfullscreen>' +
- '</iframe>'
- }
-
- getVideoUrl () {
- return window.location.href
- }
-
- notSecure () {
- return window.location.protocol === 'http:'
- }
-
- activateCopiedMessage () {
- this.notificationsService.success('Success', 'Copied')
- }
-}
</a>
</li>
+ <li *ngIf="isVideoUpdatable()" role="menuitem">
+ <a class="dropdown-item" title="Update this video" href="#" [routerLink]="[ '/videos/edit', video.uuid ]">
+ <span class="icon icon-edit"></span> Update
+ </a>
+ </li>
+
<li *ngIf="isVideoRemovable()" role="menuitem">
<a class="dropdown-item" title="Delete this video" href="#" (click)="removeVideo($event)">
<span class="icon icon-blacklist"></span> Delete
</div>
</div>
+ <my-video-comments [video]="video" [user]="user"></my-video-comments>
</div>
<div class="other-videos">
background-image: url('../../../assets/images/video/download-black.svg');
}
+ &.icon-edit {
+ background-image: url('../../../assets/images/global/edit-black.svg');
+ }
+
&.icon-alert {
background-image: url('../../../assets/images/video/alert.svg');
}
import { Video } from '../../shared/video/video.model'
import { VideoService } from '../../shared/video/video.service'
import { MarkdownService } from '../shared'
-import { VideoDownloadComponent } from './video-download.component'
-import { VideoReportComponent } from './video-report.component'
-import { VideoShareComponent } from './video-share.component'
+import { VideoDownloadComponent } from './modal/video-download.component'
+import { VideoReportComponent } from './modal/video-report.component'
+import { VideoShareComponent } from './modal/video-share.component'
@Component({
selector: 'my-video-watch',
return this.authService.isLoggedIn()
}
+ isVideoUpdatable () {
+ return this.video.isUpdatableBy(this.authService.getUser())
+ }
+
isVideoBlacklistable () {
return this.video.isBlackistableBy(this.user)
}
import { ClipboardModule } from 'ngx-clipboard'
import { SharedModule } from '../../shared'
import { MarkdownService } from '../shared'
-import { VideoDownloadComponent } from './video-download.component'
-import { VideoReportComponent } from './video-report.component'
-import { VideoShareComponent } from './video-share.component'
+import { VideoCommentAddComponent } from './comment/video-comment-add.component'
+import { VideoCommentComponent } from './comment/video-comment.component'
+import { VideoCommentService } from './comment/video-comment.service'
+import { VideoCommentsComponent } from './comment/video-comments.component'
+import { VideoDownloadComponent } from './modal/video-download.component'
+import { VideoReportComponent } from './modal/video-report.component'
+import { VideoShareComponent } from './modal/video-share.component'
import { VideoWatchRoutingModule } from './video-watch-routing.module'
VideoDownloadComponent,
VideoShareComponent,
- VideoReportComponent
+ VideoReportComponent,
+ VideoCommentsComponent,
+ VideoCommentAddComponent,
+ VideoCommentComponent
],
exports: [
],
providers: [
- MarkdownService
+ MarkdownService,
+ VideoCommentService
]
})
export class VideoWatchModule { }
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
+ <title>edit</title>
+ <desc>Created with Sketch.</desc>
+ <defs></defs>
+ <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="Artboard-4" transform="translate(-48.000000, -203.000000)" stroke="#000000" stroke-width="2">
+ <g id="41" transform="translate(48.000000, 203.000000)">
+ <path d="M3,21.0000003 L3,17 L15.8898356,4.11016442 C17.0598483,2.9401517 18.9638992,2.94723715 20.1306896,4.11402752 L19.9181432,3.90148112 C21.0902894,5.07362738 21.0882407,6.97202708 19.9174652,8.1377941 L7,21.0000003 L3,21.0000003 Z" id="Path-74" stroke-linecap="round" stroke-linejoin="round"></path>
+ <path d="M14.5,5.5 L18.5,9.5" id="Path-75"></path>
+ </g>
+ </g>
+ </g>
+</svg>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
+ <title>edit</title>
+ <desc>Created with Sketch.</desc>
+ <defs></defs>
+ <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="Artboard-4" transform="translate(-48.000000, -203.000000)" stroke="#585858" stroke-width="2">
+ <g id="41" transform="translate(48.000000, 203.000000)">
+ <path d="M3,21.0000003 L3,17 L15.8898356,4.11016442 C17.0598483,2.9401517 18.9638992,2.94723715 20.1306896,4.11402752 L19.9181432,3.90148112 C21.0902894,5.07362738 21.0882407,6.97202708 19.9174652,8.1377941 L7,21.0000003 L3,21.0000003 Z" id="Path-74" stroke-linecap="round" stroke-linejoin="round"></path>
+ <path d="M14.5,5.5 L18.5,9.5" id="Path-75"></path>
+ </g>
+ </g>
+ </g>
+</svg>
+++ /dev/null
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
- <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
- <title>edit</title>
- <desc>Created with Sketch.</desc>
- <defs></defs>
- <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
- <g id="Artboard-4" transform="translate(-48.000000, -203.000000)" stroke="#585858" stroke-width="2">
- <g id="41" transform="translate(48.000000, 203.000000)">
- <path d="M3,21.0000003 L3,17 L15.8898356,4.11016442 C17.0598483,2.9401517 18.9638992,2.94723715 20.1306896,4.11402752 L19.9181432,3.90148112 C21.0902894,5.07362738 21.0882407,6.97202708 19.9174652,8.1377941 L7,21.0000003 L3,21.0000003 Z" id="Path-74" stroke-linecap="round" stroke-linejoin="round"></path>
- <path d="M14.5,5.5 L18.5,9.5" id="Path-75"></path>
- </g>
- </g>
- </g>
-</svg>
// Thanks https://gist.github.com/alexandrevicenzi/680147013e902a4eaa5d
.glyphicon-refresh-animate {
- -animation: spin .7s infinite linear;
- -ms-animation: spin .7s infinite linear;
- -webkit-animation: spinw .7s infinite linear;
- -moz-animation: spinm .7s infinite linear;
+ animation: spin .7s infinite linear;
}
@keyframes spin {
to { transform: scale(1) rotate(360deg);}
}
-@-webkit-keyframes spinw {
- from { -webkit-transform: rotate(0deg);}
- to { -webkit-transform: rotate(360deg);}
-}
-
-@-moz-keyframes spinm {
- from { -moz-transform: rotate(0deg);}
- to { -moz-transform: rotate(360deg);}
-}
-
// ngprime data table customizations
p-datatable {
font-size: 15px !important;
})
app.use(function (err, req, res, next) {
- logger.error(err)
+ logger.error(err, err)
res.sendStatus(err.status || 500)
})
const comment = await retryTransactionWrapper(addVideoCommentThread, options)
res.json({
- comment: {
- id: comment.id
- }
+ comment: comment.toFormattedJSON()
}).end()
}
text: videoCommentInfo.text,
inReplyToComment: null,
video: res.locals.video,
- accountId: res.locals.oauth.token.User.Account.id
+ account: res.locals.oauth.token.User.Account
}, t)
})
}
const comment = await retryTransactionWrapper(addVideoCommentReply, options)
res.json({
- comment: {
- id: comment.id
- }
+ comment: comment.toFormattedJSON()
}).end()
}
text: videoCommentInfo.text,
inReplyToComment: res.locals.videoComment,
video: res.locals.video,
- accountId: res.locals.oauth.token.User.Account.id
+ account: res.locals.oauth.token.User.Account
}, t)
})
}
import * as Sequelize from 'sequelize'
import { ResultList } from '../../shared/models'
import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model'
+import { AccountModel } from '../models/account/account'
import { VideoModel } from '../models/video/video'
import { VideoCommentModel } from '../models/video/video-comment'
-import { getVideoCommentActivityPubUrl, sendVideoRateChangeToFollowers } from './activitypub'
+import { getVideoCommentActivityPubUrl } from './activitypub'
import { sendCreateVideoCommentToOrigin, sendCreateVideoCommentToVideoFollowers } from './activitypub/send'
async function createVideoComment (obj: {
text: string,
inReplyToComment: VideoCommentModel,
video: VideoModel
- accountId: number
+ account: AccountModel
}, t: Sequelize.Transaction) {
let originCommentId: number = null
+ let inReplyToCommentId: number = null
if (obj.inReplyToComment) {
originCommentId = obj.inReplyToComment.originCommentId || obj.inReplyToComment.id
+ inReplyToCommentId = obj.inReplyToComment.id
}
const comment = await VideoCommentModel.create({
text: obj.text,
originCommentId,
- inReplyToCommentId: obj.inReplyToComment.id,
+ inReplyToCommentId,
videoId: obj.video.id,
- accountId: obj.accountId,
+ accountId: obj.account.id,
url: 'fake url'
}, { transaction: t, validate: false })
const savedComment = await comment.save({ transaction: t })
savedComment.InReplyToVideoComment = obj.inReplyToComment
savedComment.Video = obj.video
+ savedComment.Account = obj.account
if (savedComment.Video.isOwned()) {
await sendCreateVideoCommentToVideoFollowers(savedComment, t)
import { areValidationErrors } from './utils'
const paginationValidator = [
- query('start').optional().isInt().withMessage('Should have a number start'),
- query('count').optional().isInt().withMessage('Should have a number count'),
+ query('start').optional().isInt({ min: 0 }).withMessage('Should have a number start'),
+ query('count').optional().isInt({ min: 0 }).withMessage('Should have a number count'),
(req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking pagination parameters', { parameters: req.query })
import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub'
import { CONSTRAINTS_FIELDS } from '../../initializers'
import { AccountModel } from '../account/account'
+import { ActorModel } from '../activitypub/actor'
+import { ServerModel } from '../server/server'
import { getSort, throwIfNotValid } from '../utils'
import { VideoModel } from './video'
enum ScopeNames {
WITH_ACCOUNT = 'WITH_ACCOUNT',
- WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO'
+ WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
+ ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
}
@Scopes({
+ [ScopeNames.ATTRIBUTES_FOR_API]: {
+ attributes: {
+ include: [
+ [
+ Sequelize.literal(
+ '(SELECT COUNT("replies"."id") ' +
+ 'FROM "videoComment" AS "replies" ' +
+ 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
+ ),
+ 'totalReplies'
+ ]
+ ]
+ }
+ },
[ScopeNames.WITH_ACCOUNT]: {
include: [
- () => AccountModel
+ {
+ model: () => AccountModel,
+ include: [
+ {
+ model: () => ActorModel,
+ include: [
+ {
+ model: () => ServerModel,
+ required: false
+ }
+ ]
+ }
+ ]
+ }
]
},
[ScopeNames.WITH_IN_REPLY_TO]: {
}
return VideoCommentModel
- .scope([ ScopeNames.WITH_ACCOUNT ])
+ .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
.findAndCountAll(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
}
return VideoCommentModel
- .scope([ ScopeNames.WITH_ACCOUNT ])
+ .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
.findAndCountAll(query)
.then(({ rows, count }) => {
return { total: count, data: rows }
videoId: this.videoId,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
+ totalReplies: this.get('totalReplies') || 0,
account: {
- name: this.Account.name
+ name: this.Account.name,
+ host: this.Account.Actor.getHost()
}
} as VideoComment
}
it('Should create a thread in this video', async function () {
const text = 'my super first comment'
- await addVideoCommentThread(server.url, server.accessToken, videoUUID, text)
+ const res = await addVideoCommentThread(server.url, server.accessToken, videoUUID, text)
+ const comment = res.body
+
+ expect(comment.inReplyToCommentId).to.be.null
+ expect(comment.text).equal('my super first comment')
+ expect(comment.videoId).to.equal(videoId)
+ expect(comment.id).to.equal(comment.threadId)
+ expect(comment.account.name).to.equal('root')
+ expect(comment.account.host).to.equal('localhost:9001')
+ expect(comment.totalReplies).to.equal(0)
+ expect(dateIsValid(comment.createdAt as string)).to.be.true
+ expect(dateIsValid(comment.updatedAt as string)).to.be.true
})
it('Should list threads of this video', async function () {
expect(comment.videoId).to.equal(videoId)
expect(comment.id).to.equal(comment.threadId)
expect(comment.account.name).to.equal('root')
+ expect(comment.account.host).to.equal('localhost:9001')
+ expect(comment.totalReplies).to.equal(0)
expect(dateIsValid(comment.createdAt as string)).to.be.true
expect(dateIsValid(comment.updatedAt as string)).to.be.true
expect(res.body.data).to.have.lengthOf(3)
expect(res.body.data[0].text).to.equal('my super first comment')
+ expect(res.body.data[0].totalReplies).to.equal(2)
expect(res.body.data[1].text).to.equal('super thread 2')
+ expect(res.body.data[1].totalReplies).to.equal(1)
expect(res.body.data[2].text).to.equal('super thread 3')
+ expect(res.body.data[2].totalReplies).to.equal(0)
})
after(async function () {
videoId: number
createdAt: Date | string
updatedAt: Date | string
+ totalReplies: number
account: {
name: string
+ host: string
}
}