Add video comment components
authorChocobozzz <me@florianbigard.com>
Wed, 27 Dec 2017 15:11:53 +0000 (16:11 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 27 Dec 2017 15:11:53 +0000 (16:11 +0100)
52 files changed:
client/src/app/shared/forms/form-validators/video-comment.ts [new file with mode: 0644]
client/src/app/shared/misc/button.component.scss
client/src/app/shared/rest/component-pagination.model.ts [new file with mode: 0644]
client/src/app/shared/rest/rest.service.ts
client/src/app/shared/video/abstract-video-list.ts
client/src/app/shared/video/video-miniature.component.scss
client/src/app/shared/video/video-pagination.model.ts [deleted file]
client/src/app/shared/video/video.service.ts
client/src/app/videos/+video-watch/comment/video-comment-add.component.html [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comment-add.component.scss [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comment-add.component.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comment.component.html [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comment.component.scss [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comment.component.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comment.model.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comment.service.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comments.component.html [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comments.component.scss [new file with mode: 0644]
client/src/app/videos/+video-watch/comment/video-comments.component.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-download.component.html [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-download.component.scss [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-download.component.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-report.component.html [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-report.component.scss [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-report.component.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-share.component.html [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-share.component.scss [new file with mode: 0644]
client/src/app/videos/+video-watch/modal/video-share.component.ts [new file with mode: 0644]
client/src/app/videos/+video-watch/video-download.component.html [deleted file]
client/src/app/videos/+video-watch/video-download.component.scss [deleted file]
client/src/app/videos/+video-watch/video-download.component.ts [deleted file]
client/src/app/videos/+video-watch/video-report.component.html [deleted file]
client/src/app/videos/+video-watch/video-report.component.scss [deleted file]
client/src/app/videos/+video-watch/video-report.component.ts [deleted file]
client/src/app/videos/+video-watch/video-share.component.html [deleted file]
client/src/app/videos/+video-watch/video-share.component.scss [deleted file]
client/src/app/videos/+video-watch/video-share.component.ts [deleted file]
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/+video-watch/video-watch.component.scss
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/+video-watch/video-watch.module.ts
client/src/assets/images/global/edit-black.svg [new file with mode: 0644]
client/src/assets/images/global/edit-grey.svg [new file with mode: 0644]
client/src/assets/images/global/edit.svg [deleted file]
client/src/sass/application.scss
server.ts
server/controllers/api/videos/comment.ts
server/lib/video-comment.ts
server/middlewares/validators/pagination.ts
server/models/video/video-comment.ts
server/tests/api/video-comments.ts
shared/models/videos/video-comment.model.ts

diff --git a/client/src/app/shared/forms/form-validators/video-comment.ts b/client/src/app/shared/forms/form-validators/video-comment.ts
new file mode 100644 (file)
index 0000000..42a97e3
--- /dev/null
@@ -0,0 +1,10 @@
+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.'
+  }
+}
index c380c7ae1ab3e9420a87b9d574426e8010ee935e..145a3474aa2a123bec5b9bb529ef44faed84fe12 100644 (file)
@@ -20,7 +20,7 @@
     top: -2px;
 
     &.icon-edit {
-      background-image: url('../../../assets/images/global/edit.svg');
+      background-image: url('../../../assets/images/global/edit-grey.svg');
     }
 
     &.icon-delete-grey {
diff --git a/client/src/app/shared/rest/component-pagination.model.ts b/client/src/app/shared/rest/component-pagination.model.ts
new file mode 100644 (file)
index 0000000..0b8ecc3
--- /dev/null
@@ -0,0 +1,5 @@
+export interface ComponentPagination {
+  currentPage: number
+  itemsPerPage: number
+  totalItems?: number
+}
index a1c3010501c1bdecc46b60655843aff4030336f0..5d5410de98c50deb1345f08b1051937182c66c16 100644 (file)
@@ -1,6 +1,7 @@
 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'
 
@@ -31,4 +32,10 @@ export class RestService {
     return newParams
   }
 
+  componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination {
+    const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
+    const count: number = componentPagination.itemsPerPage
+
+    return { start, count }
+  }
 }
index 2b6870a789b2b06c5c0101df5ae8477604203ff0..bfe46bcdd23929d495d750403c6351d651daacde 100644 (file)
@@ -3,12 +3,12 @@ import { ActivatedRoute, Router } from '@angular/router'
 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
index 49ba1e51cb936a2054e3c5a76e50e9628899755b..f0888ad9f600427cd9127294630e431739e8e7ca 100644 (file)
@@ -18,7 +18,6 @@
       overflow: hidden;
       text-overflow: ellipsis;
       white-space: nowrap;
-      font-weight: bold;
       transition: color 0.2s;
       font-size: 16px;
       font-weight: $font-semibold;
diff --git a/client/src/app/shared/video/video-pagination.model.ts b/client/src/app/shared/video/video-pagination.model.ts
deleted file mode 100644 (file)
index e9db615..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface VideoPagination {
-  currentPage: number
-  itemsPerPage: number
-  totalItems?: number
-}
index 91dd3977a733c049d31fc2d21ba7fb19eba6bc7a..fc7505a51fdedca0370882d7acb70e8750336fb1 100644 (file)
@@ -10,13 +10,13 @@ import { UserVideoRate } from '../../../../../shared/models/videos/user-video-ra
 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()
@@ -71,8 +71,8 @@ export class VideoService {
       .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)
@@ -82,8 +82,8 @@ export class VideoService {
       .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)
@@ -94,10 +94,14 @@ export class VideoService {
       .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)
@@ -139,13 +143,6 @@ export class VideoService {
       .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 = {
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html
new file mode 100644 (file)
index 0000000..7920536
--- /dev/null
@@ -0,0 +1,15 @@
+<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>
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss
new file mode 100644 (file)
index 0000000..9661062
--- /dev/null
@@ -0,0 +1,20 @@
+@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
+  }
+}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
new file mode 100644 (file)
index 0000000..5ad83fc
--- /dev/null
@@ -0,0 +1,84 @@
+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)
+  }
+}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.html b/client/src/app/videos/+video-watch/comment/video-comment.component.html
new file mode 100644 (file)
index 0000000..9608a10
--- /dev/null
@@ -0,0 +1,29 @@
+<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>
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.scss b/client/src/app/videos/+video-watch/comment/video-comment.component.scss
new file mode 100644 (file)
index 0000000..7e1a32f
--- /dev/null
@@ -0,0 +1,38 @@
+@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;
+  }
+}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.component.ts b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
new file mode 100644 (file)
index 0000000..b8e2acd
--- /dev/null
@@ -0,0 +1,67 @@
+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()
+  }
+}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.model.ts b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
new file mode 100644 (file)
index 0000000..df7d524
--- /dev/null
@@ -0,0 +1,38 @@
+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)
+  }
+}
diff --git a/client/src/app/videos/+video-watch/comment/video-comment.service.ts b/client/src/app/videos/+video-watch/comment/video-comment.service.ts
new file mode 100644 (file)
index 0000000..2fe6cc3
--- /dev/null
@@ -0,0 +1,93 @@
+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
+  }
+}
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.html b/client/src/app/videos/+video-watch/comment/video-comments.component.html
new file mode 100644 (file)
index 0000000..9d75812
--- /dev/null
@@ -0,0 +1,31 @@
+<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>
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.scss b/client/src/app/videos/+video-watch/comment/video-comments.component.scss
new file mode 100644 (file)
index 0000000..2f6e466
--- /dev/null
@@ -0,0 +1,14 @@
+@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;
+}
diff --git a/client/src/app/videos/+video-watch/comment/video-comments.component.ts b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
new file mode 100644 (file)
index 0000000..32e0f2f
--- /dev/null
@@ -0,0 +1,79 @@
+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()
+  }
+}
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.html b/client/src/app/videos/+video-watch/modal/video-download.component.html
new file mode 100644 (file)
index 0000000..f8f17a4
--- /dev/null
@@ -0,0 +1,42 @@
+<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>
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.scss b/client/src/app/videos/+video-watch/modal/video-download.component.scss
new file mode 100644 (file)
index 0000000..6325f67
--- /dev/null
@@ -0,0 +1,17 @@
+@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;
+  }
+}
diff --git a/client/src/app/videos/+video-watch/modal/video-download.component.ts b/client/src/app/videos/+video-watch/modal/video-download.component.ts
new file mode 100644 (file)
index 0000000..1a73ea6
--- /dev/null
@@ -0,0 +1,44 @@
+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)
+  }
+}
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.html b/client/src/app/videos/+video-watch/modal/video-report.component.html
new file mode 100644 (file)
index 0000000..a9a7beb
--- /dev/null
@@ -0,0 +1,36 @@
+<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>
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.scss b/client/src/app/videos/+video-watch/modal/video-report.component.scss
new file mode 100644 (file)
index 0000000..84562f1
--- /dev/null
@@ -0,0 +1,6 @@
+@import 'variables';
+@import 'mixins';
+
+textarea {
+  @include peertube-textarea(100%, 60px);
+}
diff --git a/client/src/app/videos/+video-watch/modal/video-report.component.ts b/client/src/app/videos/+video-watch/modal/video-report.component.ts
new file mode 100644 (file)
index 0000000..050e827
--- /dev/null
@@ -0,0 +1,68 @@
+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)
+                           )
+  }
+}
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html
new file mode 100644 (file)
index 0000000..85cf10a
--- /dev/null
@@ -0,0 +1,47 @@
+<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>
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss
new file mode 100644 (file)
index 0000000..184e090
--- /dev/null
@@ -0,0 +1,3 @@
+.action-button-cancel {
+  margin-right: 0 !important;
+}
diff --git a/client/src/app/videos/+video-watch/modal/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts
new file mode 100644 (file)
index 0000000..678cccf
--- /dev/null
@@ -0,0 +1,48 @@
+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')
+  }
+}
diff --git a/client/src/app/videos/+video-watch/video-download.component.html b/client/src/app/videos/+video-watch/video-download.component.html
deleted file mode 100644 (file)
index f8f17a4..0000000
+++ /dev/null
@@ -1,42 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/video-download.component.scss b/client/src/app/videos/+video-watch/video-download.component.scss
deleted file mode 100644 (file)
index 5fca821..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-@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;
-  }
-}
diff --git a/client/src/app/videos/+video-watch/video-download.component.ts b/client/src/app/videos/+video-watch/video-download.component.ts
deleted file mode 100644 (file)
index 44ece98..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-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)
-  }
-}
diff --git a/client/src/app/videos/+video-watch/video-report.component.html b/client/src/app/videos/+video-watch/video-report.component.html
deleted file mode 100644 (file)
index a9a7beb..0000000
+++ /dev/null
@@ -1,36 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/video-report.component.scss b/client/src/app/videos/+video-watch/video-report.component.scss
deleted file mode 100644 (file)
index 09d273b..0000000
+++ /dev/null
@@ -1,6 +0,0 @@
-@import '_variables';
-@import '_mixins';
-
-textarea {
-  @include peertube-textarea(100%, 60px);
-}
diff --git a/client/src/app/videos/+video-watch/video-report.component.ts b/client/src/app/videos/+video-watch/video-report.component.ts
deleted file mode 100644 (file)
index ece1475..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-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)
-                           )
-  }
-}
diff --git a/client/src/app/videos/+video-watch/video-share.component.html b/client/src/app/videos/+video-watch/video-share.component.html
deleted file mode 100644 (file)
index 85cf10a..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<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>
diff --git a/client/src/app/videos/+video-watch/video-share.component.scss b/client/src/app/videos/+video-watch/video-share.component.scss
deleted file mode 100644 (file)
index 184e090..0000000
+++ /dev/null
@@ -1,3 +0,0 @@
-.action-button-cancel {
-  margin-right: 0 !important;
-}
diff --git a/client/src/app/videos/+video-watch/video-share.component.ts b/client/src/app/videos/+video-watch/video-share.component.ts
deleted file mode 100644 (file)
index 0664c28..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-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')
-  }
-}
index 860edecd293f1ff92a0e921bda362a421ef29b9b..48d1bb47426dd9ce76d3e165d9259bc6376eba93 100644 (file)
                 </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">
index b37fa3d61d7759652350aa69684aaefdca349a88..c101aa04ede5c12437b577d8a12a2f1fb8aef34c 100644 (file)
                 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');
               }
index c388b138b3e1cec6b526b69ff57a3161d37d85e9..4afd6160c15b540742002db46b34832d73d0f6bb 100644 (file)
@@ -14,9 +14,9 @@ import { VideoDetails } from '../../shared/video/video-details.model'
 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',
@@ -208,6 +208,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
     return this.authService.isLoggedIn()
   }
 
+  isVideoUpdatable () {
+    return this.video.isUpdatableBy(this.authService.getUser())
+  }
+
   isVideoBlacklistable () {
     return this.video.isBlackistableBy(this.user)
   }
index e77883472c910e53090dcc60035a853c1aacde35..085a9ec5a36a15e078a9a6682c97c3950bd62256 100644 (file)
@@ -3,9 +3,13 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip'
 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'
 
@@ -24,7 +28,10 @@ import { VideoWatchComponent } from './video-watch.component'
 
     VideoDownloadComponent,
     VideoShareComponent,
-    VideoReportComponent
+    VideoReportComponent,
+    VideoCommentsComponent,
+    VideoCommentAddComponent,
+    VideoCommentComponent
   ],
 
   exports: [
@@ -32,7 +39,8 @@ import { VideoWatchComponent } from './video-watch.component'
   ],
 
   providers: [
-    MarkdownService
+    MarkdownService,
+    VideoCommentService
   ]
 })
 export class VideoWatchModule { }
diff --git a/client/src/assets/images/global/edit-black.svg b/client/src/assets/images/global/edit-black.svg
new file mode 100644 (file)
index 0000000..0176b0f
--- /dev/null
@@ -0,0 +1,15 @@
+<?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>
diff --git a/client/src/assets/images/global/edit-grey.svg b/client/src/assets/images/global/edit-grey.svg
new file mode 100644 (file)
index 0000000..23ece68
--- /dev/null
@@ -0,0 +1,15 @@
+<?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>
diff --git a/client/src/assets/images/global/edit.svg b/client/src/assets/images/global/edit.svg
deleted file mode 100644 (file)
index 23ece68..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-<?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>
index 0539ec486a7b505722f89dcc2f3680ea5be9fea7..253bb1b3cd533a2d57a4bad64d68493341926330 100644 (file)
@@ -118,10 +118,7 @@ label {
 
 // 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 {
@@ -129,16 +126,6 @@ label {
   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;
index f64c4ac53f5ff81481b24488dba52b8a06f5fa98..05fc39acbb5a808cfeccf5e4384ce440ee912884 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -151,7 +151,7 @@ app.use(function (req, res, next) {
 })
 
 app.use(function (err, req, res, next) {
-  logger.error(err)
+  logger.error(err, err)
   res.sendStatus(err.status || 500)
 })
 
index e9dbb6d1b6166bb4dacbefdce62fde4ba6f4f96d..276948098ce9775afd4c6521a694f9847a67f2cc 100644 (file)
@@ -66,9 +66,7 @@ async function addVideoCommentThreadRetryWrapper (req: express.Request, res: exp
   const comment = await retryTransactionWrapper(addVideoCommentThread, options)
 
   res.json({
-    comment: {
-      id: comment.id
-    }
+    comment: comment.toFormattedJSON()
   }).end()
 }
 
@@ -80,7 +78,7 @@ function addVideoCommentThread (req: express.Request, res: express.Response) {
       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)
   })
 }
@@ -94,9 +92,7 @@ async function addVideoCommentReplyRetryWrapper (req: express.Request, res: expr
   const comment = await retryTransactionWrapper(addVideoCommentReply, options)
 
   res.json({
-    comment: {
-      id: comment.id
-    }
+    comment: comment.toFormattedJSON()
   }).end()
 }
 
@@ -108,7 +104,7 @@ function addVideoCommentReply (req: express.Request, res: express.Response, next
       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)
   })
 }
index ef6a8f097b7c222fa37bf143fad14f29cb651802..0d744c526ee74da49a7cc32cfe104857cc8c9bf3 100644 (file)
@@ -1,29 +1,32 @@
 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 })
 
@@ -32,6 +35,7 @@ async function createVideoComment (obj: {
   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)
index 0895b4eb8304695a1d5fbfd9f51c0d540f89532c..25debfa6e5c95792d3ef66a19684e76bb2334ee5 100644 (file)
@@ -4,8 +4,8 @@ import { logger } from '../../helpers'
 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 })
index 25cd6d5635eee82787d59838137a805aaa79fa22..a3e8c48d4eb464bfaa104e96715a4c850f82da1c 100644 (file)
@@ -8,18 +8,48 @@ import { VideoComment } from '../../../shared/models/videos/video-comment.model'
 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]: {
@@ -149,7 +179,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
     }
 
     return VideoCommentModel
-      .scope([ ScopeNames.WITH_ACCOUNT ])
+      .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
       .findAndCountAll(query)
       .then(({ rows, count }) => {
         return { total: count, data: rows }
@@ -169,7 +199,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
     }
 
     return VideoCommentModel
-      .scope([ ScopeNames.WITH_ACCOUNT ])
+      .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
       .findAndCountAll(query)
       .then(({ rows, count }) => {
         return { total: count, data: rows }
@@ -186,8 +216,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
       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
   }
index 2f1e6260a16f76fa552b0a76300fc6602138c4ba..2c7d1c6e2d9ea16a795c15c0f64734204def2c1d 100644 (file)
@@ -39,7 +39,18 @@ describe('Test video comments', function () {
   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 () {
@@ -55,6 +66,8 @@ describe('Test video comments', 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
 
@@ -120,8 +133,11 @@ describe('Test video comments', function () {
     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 () {
index 69884782fbf6264d17b83b5e4f6c6ca3d268243c..d572927c27f414ba0d17299c7c11211cc6250dae 100644 (file)
@@ -7,8 +7,10 @@ export interface VideoComment {
   videoId: number
   createdAt: Date | string
   updatedAt: Date | string
+  totalReplies: number
   account: {
     name: string
+    host: string
   }
 }