aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2017-12-27 16:11:53 +0100
committerChocobozzz <me@florianbigard.com>2017-12-27 16:11:53 +0100
commit4635f59d7c3fea4b97029f10886c62fdf38b2084 (patch)
treed97357a00042bbfb33c4177ee24c01171d28dfce
parentea44f375f5d3da06ca0aebfe871b9f924a26ec29 (diff)
downloadPeerTube-4635f59d7c3fea4b97029f10886c62fdf38b2084.tar.gz
PeerTube-4635f59d7c3fea4b97029f10886c62fdf38b2084.tar.zst
PeerTube-4635f59d7c3fea4b97029f10886c62fdf38b2084.zip
Add video comment components
-rw-r--r--client/src/app/shared/forms/form-validators/video-comment.ts10
-rw-r--r--client/src/app/shared/misc/button.component.scss2
-rw-r--r--client/src/app/shared/rest/component-pagination.model.ts (renamed from client/src/app/shared/video/video-pagination.model.ts)2
-rw-r--r--client/src/app/shared/rest/rest.service.ts7
-rw-r--r--client/src/app/shared/video/abstract-video-list.ts4
-rw-r--r--client/src/app/shared/video/video-miniature.component.scss1
-rw-r--r--client/src/app/shared/video/video.service.ts25
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-add.component.html15
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-add.component.scss20
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment-add.component.ts84
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.html29
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.scss38
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.ts67
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.model.ts38
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.service.ts93
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.html31
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.scss14
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.ts79
-rw-r--r--client/src/app/videos/+video-watch/modal/video-download.component.html (renamed from client/src/app/videos/+video-watch/video-download.component.html)0
-rw-r--r--client/src/app/videos/+video-watch/modal/video-download.component.scss (renamed from client/src/app/videos/+video-watch/video-download.component.scss)4
-rw-r--r--client/src/app/videos/+video-watch/modal/video-download.component.ts (renamed from client/src/app/videos/+video-watch/video-download.component.ts)2
-rw-r--r--client/src/app/videos/+video-watch/modal/video-report.component.html (renamed from client/src/app/videos/+video-watch/video-report.component.html)0
-rw-r--r--client/src/app/videos/+video-watch/modal/video-report.component.scss (renamed from client/src/app/videos/+video-watch/video-report.component.scss)4
-rw-r--r--client/src/app/videos/+video-watch/modal/video-report.component.ts (renamed from client/src/app/videos/+video-watch/video-report.component.ts)4
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.html (renamed from client/src/app/videos/+video-watch/video-share.component.html)0
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.scss (renamed from client/src/app/videos/+video-watch/video-share.component.scss)0
-rw-r--r--client/src/app/videos/+video-watch/modal/video-share.component.ts (renamed from client/src/app/videos/+video-watch/video-share.component.ts)2
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html7
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss4
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts10
-rw-r--r--client/src/app/videos/+video-watch/video-watch.module.ts18
-rw-r--r--client/src/assets/images/global/edit-black.svg15
-rw-r--r--client/src/assets/images/global/edit-grey.svg (renamed from client/src/assets/images/global/edit.svg)0
-rw-r--r--client/src/sass/application.scss15
-rw-r--r--server.ts2
-rw-r--r--server/controllers/api/videos/comment.ts12
-rw-r--r--server/lib/video-comment.ts12
-rw-r--r--server/middlewares/validators/pagination.ts4
-rw-r--r--server/models/video/video-comment.ts42
-rw-r--r--server/tests/api/video-comments.ts18
-rw-r--r--shared/models/videos/video-comment.model.ts2
41 files changed, 666 insertions, 70 deletions
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
index 000000000..42a97e300
--- /dev/null
+++ b/client/src/app/shared/forms/form-validators/video-comment.ts
@@ -0,0 +1,10 @@
1import { Validators } from '@angular/forms'
2
3export const VIDEO_COMMENT_TEXT = {
4 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
5 MESSAGES: {
6 'required': 'Comment is required.',
7 'minlength': 'Comment must be at least 2 characters long.',
8 'maxlength': 'Comment cannot be more than 3000 characters long.'
9 }
10}
diff --git a/client/src/app/shared/misc/button.component.scss b/client/src/app/shared/misc/button.component.scss
index c380c7ae1..145a3474a 100644
--- a/client/src/app/shared/misc/button.component.scss
+++ b/client/src/app/shared/misc/button.component.scss
@@ -20,7 +20,7 @@
20 top: -2px; 20 top: -2px;
21 21
22 &.icon-edit { 22 &.icon-edit {
23 background-image: url('../../../assets/images/global/edit.svg'); 23 background-image: url('../../../assets/images/global/edit-grey.svg');
24 } 24 }
25 25
26 &.icon-delete-grey { 26 &.icon-delete-grey {
diff --git a/client/src/app/shared/video/video-pagination.model.ts b/client/src/app/shared/rest/component-pagination.model.ts
index e9db61596..0b8ecc318 100644
--- a/client/src/app/shared/video/video-pagination.model.ts
+++ b/client/src/app/shared/rest/component-pagination.model.ts
@@ -1,4 +1,4 @@
1export interface VideoPagination { 1export interface ComponentPagination {
2 currentPage: number 2 currentPage: number
3 itemsPerPage: number 3 itemsPerPage: number
4 totalItems?: number 4 totalItems?: number
diff --git a/client/src/app/shared/rest/rest.service.ts b/client/src/app/shared/rest/rest.service.ts
index a1c301050..5d5410de9 100644
--- a/client/src/app/shared/rest/rest.service.ts
+++ b/client/src/app/shared/rest/rest.service.ts
@@ -1,6 +1,7 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { HttpParams } from '@angular/common/http' 2import { HttpParams } from '@angular/common/http'
3import { SortMeta } from 'primeng/components/common/sortmeta' 3import { SortMeta } from 'primeng/components/common/sortmeta'
4import { ComponentPagination } from './component-pagination.model'
4 5
5import { RestPagination } from './rest-pagination' 6import { RestPagination } from './rest-pagination'
6 7
@@ -31,4 +32,10 @@ export class RestService {
31 return newParams 32 return newParams
32 } 33 }
33 34
35 componentPaginationToRestPagination (componentPagination: ComponentPagination): RestPagination {
36 const start: number = (componentPagination.currentPage - 1) * componentPagination.itemsPerPage
37 const count: number = componentPagination.itemsPerPage
38
39 return { start, count }
40 }
34} 41}
diff --git a/client/src/app/shared/video/abstract-video-list.ts b/client/src/app/shared/video/abstract-video-list.ts
index 2b6870a78..bfe46bcdd 100644
--- a/client/src/app/shared/video/abstract-video-list.ts
+++ b/client/src/app/shared/video/abstract-video-list.ts
@@ -3,12 +3,12 @@ import { ActivatedRoute, Router } from '@angular/router'
3import { NotificationsService } from 'angular2-notifications' 3import { NotificationsService } from 'angular2-notifications'
4import { Observable } from 'rxjs/Observable' 4import { Observable } from 'rxjs/Observable'
5import { AuthService } from '../../core/auth' 5import { AuthService } from '../../core/auth'
6import { ComponentPagination } from '../rest/component-pagination.model'
6import { SortField } from './sort-field.type' 7import { SortField } from './sort-field.type'
7import { VideoPagination } from './video-pagination.model'
8import { Video } from './video.model' 8import { Video } from './video.model'
9 9
10export abstract class AbstractVideoList implements OnInit { 10export abstract class AbstractVideoList implements OnInit {
11 pagination: VideoPagination = { 11 pagination: ComponentPagination = {
12 currentPage: 1, 12 currentPage: 1,
13 itemsPerPage: 25, 13 itemsPerPage: 25,
14 totalItems: null 14 totalItems: null
diff --git a/client/src/app/shared/video/video-miniature.component.scss b/client/src/app/shared/video/video-miniature.component.scss
index 49ba1e51c..f0888ad9f 100644
--- a/client/src/app/shared/video/video-miniature.component.scss
+++ b/client/src/app/shared/video/video-miniature.component.scss
@@ -18,7 +18,6 @@
18 overflow: hidden; 18 overflow: hidden;
19 text-overflow: ellipsis; 19 text-overflow: ellipsis;
20 white-space: nowrap; 20 white-space: nowrap;
21 font-weight: bold;
22 transition: color 0.2s; 21 transition: color 0.2s;
23 font-size: 16px; 22 font-size: 16px;
24 font-weight: $font-semibold; 23 font-weight: $font-semibold;
diff --git a/client/src/app/shared/video/video.service.ts b/client/src/app/shared/video/video.service.ts
index 91dd3977a..fc7505a51 100644
--- a/client/src/app/shared/video/video.service.ts
+++ b/client/src/app/shared/video/video.service.ts
@@ -10,13 +10,13 @@ import { UserVideoRate } from '../../../../../shared/models/videos/user-video-ra
10import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type' 10import { VideoRateType } from '../../../../../shared/models/videos/video-rate.type'
11import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model' 11import { VideoUpdate } from '../../../../../shared/models/videos/video-update.model'
12import { environment } from '../../../environments/environment' 12import { environment } from '../../../environments/environment'
13import { ComponentPagination } from '../rest/component-pagination.model'
13import { RestExtractor } from '../rest/rest-extractor.service' 14import { RestExtractor } from '../rest/rest-extractor.service'
14import { RestService } from '../rest/rest.service' 15import { RestService } from '../rest/rest.service'
15import { UserService } from '../users/user.service' 16import { UserService } from '../users/user.service'
16import { SortField } from './sort-field.type' 17import { SortField } from './sort-field.type'
17import { VideoDetails } from './video-details.model' 18import { VideoDetails } from './video-details.model'
18import { VideoEdit } from './video-edit.model' 19import { VideoEdit } from './video-edit.model'
19import { VideoPagination } from './video-pagination.model'
20import { Video } from './video.model' 20import { Video } from './video.model'
21 21
22@Injectable() 22@Injectable()
@@ -71,8 +71,8 @@ export class VideoService {
71 .catch(this.restExtractor.handleError) 71 .catch(this.restExtractor.handleError)
72 } 72 }
73 73
74 getMyVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { 74 getMyVideos (videoPagination: ComponentPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> {
75 const pagination = this.videoPaginationToRestPagination(videoPagination) 75 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
76 76
77 let params = new HttpParams() 77 let params = new HttpParams()
78 params = this.restService.addRestGetParams(params, pagination, sort) 78 params = this.restService.addRestGetParams(params, pagination, sort)
@@ -82,8 +82,8 @@ export class VideoService {
82 .catch((res) => this.restExtractor.handleError(res)) 82 .catch((res) => this.restExtractor.handleError(res))
83 } 83 }
84 84
85 getVideos (videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { 85 getVideos (videoPagination: ComponentPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> {
86 const pagination = this.videoPaginationToRestPagination(videoPagination) 86 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
87 87
88 let params = new HttpParams() 88 let params = new HttpParams()
89 params = this.restService.addRestGetParams(params, pagination, sort) 89 params = this.restService.addRestGetParams(params, pagination, sort)
@@ -94,10 +94,14 @@ export class VideoService {
94 .catch((res) => this.restExtractor.handleError(res)) 94 .catch((res) => this.restExtractor.handleError(res))
95 } 95 }
96 96
97 searchVideos (search: string, videoPagination: VideoPagination, sort: SortField): Observable<{ videos: Video[], totalVideos: number}> { 97 searchVideos (
98 search: string,
99 videoPagination: ComponentPagination,
100 sort: SortField
101 ): Observable<{ videos: Video[], totalVideos: number}> {
98 const url = VideoService.BASE_VIDEO_URL + 'search' 102 const url = VideoService.BASE_VIDEO_URL + 'search'
99 103
100 const pagination = this.videoPaginationToRestPagination(videoPagination) 104 const pagination = this.restService.componentPaginationToRestPagination(videoPagination)
101 105
102 let params = new HttpParams() 106 let params = new HttpParams()
103 params = this.restService.addRestGetParams(params, pagination, sort) 107 params = this.restService.addRestGetParams(params, pagination, sort)
@@ -139,13 +143,6 @@ export class VideoService {
139 .catch(res => this.restExtractor.handleError(res)) 143 .catch(res => this.restExtractor.handleError(res))
140 } 144 }
141 145
142 private videoPaginationToRestPagination (videoPagination: VideoPagination) {
143 const start: number = (videoPagination.currentPage - 1) * videoPagination.itemsPerPage
144 const count: number = videoPagination.itemsPerPage
145
146 return { start, count }
147 }
148
149 private setVideoRate (id: number, rateType: VideoRateType) { 146 private setVideoRate (id: number, rateType: VideoRateType) {
150 const url = VideoService.BASE_VIDEO_URL + id + '/rate' 147 const url = VideoService.BASE_VIDEO_URL + id + '/rate'
151 const body: UserVideoRateUpdate = { 148 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
index 000000000..792053614
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.html
@@ -0,0 +1,15 @@
1<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
2 <div class="form-group">
3 <textarea placeholder="Add comment..." formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }">
4 </textarea>
5 <div *ngIf="formErrors.text" class="form-error">
6 {{ formErrors.text }}
7 </div>
8 </div>
9
10 <div class="submit-comment">
11 <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid }">
12 Post comment
13 </button>
14 </div>
15</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
index 000000000..9661062e8
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.scss
@@ -0,0 +1,20 @@
1@import '_variables';
2@import '_mixins';
3
4.form-group {
5 margin-bottom: 10px;
6}
7
8textarea {
9 @include peertube-textarea(100%, 150px);
10}
11
12.submit-comment {
13 display: flex;
14 justify-content: end;
15
16 button {
17 @include peertube-button;
18 @include orange-button
19 }
20}
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
index 000000000..5ad83fc47
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment-add.component.ts
@@ -0,0 +1,84 @@
1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
2import { FormBuilder, FormGroup } from '@angular/forms'
3import { NotificationsService } from 'angular2-notifications'
4import { Observable } from 'rxjs/Observable'
5import { VideoCommentCreate } from '../../../../../../shared/models/videos/video-comment.model'
6import { FormReactive } from '../../../shared'
7import { VIDEO_COMMENT_TEXT } from '../../../shared/forms/form-validators/video-comment'
8import { Video } from '../../../shared/video/video.model'
9import { VideoComment } from './video-comment.model'
10import { VideoCommentService } from './video-comment.service'
11
12@Component({
13 selector: 'my-video-comment-add',
14 templateUrl: './video-comment-add.component.html',
15 styleUrls: ['./video-comment-add.component.scss']
16})
17export class VideoCommentAddComponent extends FormReactive implements OnInit {
18 @Input() video: Video
19 @Input() parentComment: VideoComment
20
21 @Output() commentCreated = new EventEmitter<VideoCommentCreate>()
22
23 form: FormGroup
24 formErrors = {
25 'text': ''
26 }
27 validationMessages = {
28 'text': VIDEO_COMMENT_TEXT.MESSAGES
29 }
30
31 constructor (
32 private formBuilder: FormBuilder,
33 private notificationsService: NotificationsService,
34 private videoCommentService: VideoCommentService
35 ) {
36 super()
37 }
38
39 buildForm () {
40 this.form = this.formBuilder.group({
41 text: [ '', VIDEO_COMMENT_TEXT.VALIDATORS ]
42 })
43
44 this.form.valueChanges.subscribe(data => this.onValueChanged(data))
45 }
46
47 ngOnInit () {
48 this.buildForm()
49 }
50
51 formValidated () {
52 const commentCreate: VideoCommentCreate = this.form.value
53 let obs: Observable<any>
54
55 if (this.parentComment) {
56 obs = this.addCommentReply(commentCreate)
57 } else {
58 obs = this.addCommentThread(commentCreate)
59 }
60
61 obs.subscribe(
62 comment => {
63 this.commentCreated.emit(comment)
64 this.form.reset()
65 },
66
67 err => this.notificationsService.error('Error', err.text)
68 )
69}
70
71 isAddButtonDisplayed () {
72 return this.form.value['text']
73 }
74
75 private addCommentReply (commentCreate: VideoCommentCreate) {
76 return this.videoCommentService
77 .addCommentReply(this.video.id, this.parentComment.id, commentCreate)
78 }
79
80 private addCommentThread (commentCreate: VideoCommentCreate) {
81 return this.videoCommentService
82 .addCommentThread(this.video.id, commentCreate)
83 }
84}
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
index 000000000..9608a1033
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment.component.html
@@ -0,0 +1,29 @@
1<div class="comment">
2 <div class="comment-account-date">
3 <div class="comment-account">{{ comment.by }}</div>
4 <div class="comment-date">{{ comment.createdAt | myFromNow }}</div>
5 </div>
6 <div>{{ comment.text }}</div>
7
8 <div class="comment-actions">
9 <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply">Reply</div>
10 </div>
11
12 <my-video-comment-add
13 *ngIf="isUserLoggedIn() && inReplyToCommentId === comment.id" [video]="video" [parentComment]="comment"
14 (commentCreated)="onCommentReplyCreated($event)"
15 ></my-video-comment-add>
16
17 <div *ngIf="commentTree" class="children">
18 <div *ngFor="let commentChild of commentTree.children">
19 <my-video-comment
20 [comment]="commentChild.comment"
21 [video]="video"
22 [inReplyToCommentId]="inReplyToCommentId"
23 [commentTree]="commentChild"
24 (wantedToReply)="onWantedToReply($event)"
25 (resetReply)="onResetReply()"
26 ></my-video-comment>
27 </div>
28 </div>
29</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
index 000000000..7e1a32f48
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment.component.scss
@@ -0,0 +1,38 @@
1@import '_variables';
2@import '_mixins';
3
4.comment {
5 font-size: 15px;
6 margin-top: 30px;
7
8 .comment-account-date {
9 display: flex;
10 margin-bottom: 4px;
11
12 .comment-account {
13 font-weight: $font-bold;
14 }
15
16 .comment-date {
17 color: #585858;
18 margin-left: 10px;
19 }
20 }
21
22 .comment-actions {
23 margin: 10px 0;
24
25 .comment-action-reply {
26 color: #585858;
27 cursor: pointer;
28 }
29 }
30}
31
32.children {
33 margin-left: 20px;
34
35 .comment {
36 margin-top: 15px;
37 }
38}
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
index 000000000..b8e2acd52
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts
@@ -0,0 +1,67 @@
1import { Component, EventEmitter, Input, Output } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications'
3import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
4import { AuthService } from '../../../core/auth'
5import { User } from '../../../shared/users'
6import { Video } from '../../../shared/video/video.model'
7import { VideoComment } from './video-comment.model'
8import { VideoCommentService } from './video-comment.service'
9
10@Component({
11 selector: 'my-video-comment',
12 templateUrl: './video-comment.component.html',
13 styleUrls: ['./video-comment.component.scss']
14})
15export class VideoCommentComponent {
16 @Input() video: Video
17 @Input() comment: VideoComment
18 @Input() commentTree: VideoCommentThreadTree
19 @Input() inReplyToCommentId: number
20
21 @Output() wantedToReply = new EventEmitter<VideoComment>()
22 @Output() resetReply = new EventEmitter()
23
24 constructor (private authService: AuthService,
25 private notificationsService: NotificationsService,
26 private videoCommentService: VideoCommentService) {
27 }
28
29 onCommentReplyCreated (comment: VideoComment) {
30 this.videoCommentService.addCommentReply(this.video.id, this.comment.id, comment)
31 .subscribe(
32 createdComment => {
33 if (!this.commentTree) {
34 this.commentTree = {
35 comment: this.comment,
36 children: []
37 }
38 }
39
40 this.commentTree.children.push({
41 comment: createdComment,
42 children: []
43 })
44 this.resetReply.emit()
45 },
46
47 err => this.notificationsService.error('Error', err.message)
48 )
49 }
50
51 onWantToReply () {
52 this.wantedToReply.emit(this.comment)
53 }
54
55 isUserLoggedIn () {
56 return this.authService.isLoggedIn()
57 }
58
59 // Event from child comment
60 onWantedToReply (comment: VideoComment) {
61 this.wantedToReply.emit(comment)
62 }
63
64 onResetReply () {
65 this.resetReply.emit()
66 }
67}
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
index 000000000..df7d5244c
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment.model.ts
@@ -0,0 +1,38 @@
1import { VideoComment as VideoCommentServerModel } from '../../../../../../shared/models/videos/video-comment.model'
2
3export class VideoComment implements VideoCommentServerModel {
4 id: number
5 url: string
6 text: string
7 threadId: number
8 inReplyToCommentId: number
9 videoId: number
10 createdAt: Date | string
11 updatedAt: Date | string
12 account: {
13 name: string
14 host: string
15 }
16 totalReplies: number
17
18 by: string
19
20 private static createByString (account: string, serverHost: string) {
21 return account + '@' + serverHost
22 }
23
24 constructor (hash: VideoCommentServerModel) {
25 this.id = hash.id
26 this.url = hash.url
27 this.text = hash.text
28 this.threadId = hash.threadId
29 this.inReplyToCommentId = hash.inReplyToCommentId
30 this.videoId = hash.videoId
31 this.createdAt = new Date(hash.createdAt.toString())
32 this.updatedAt = new Date(hash.updatedAt.toString())
33 this.account = hash.account
34 this.totalReplies = hash.totalReplies
35
36 this.by = VideoComment.createByString(this.account.name, this.account.host)
37 }
38}
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
index 000000000..2fe6cc3e9
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comment.service.ts
@@ -0,0 +1,93 @@
1import { HttpClient, HttpParams } from '@angular/common/http'
2import { Injectable } from '@angular/core'
3import 'rxjs/add/operator/catch'
4import 'rxjs/add/operator/map'
5import { Observable } from 'rxjs/Observable'
6import { ResultList } from '../../../../../../shared/models'
7import {
8 VideoComment as VideoCommentServerModel, VideoCommentCreate,
9 VideoCommentThreadTree
10} from '../../../../../../shared/models/videos/video-comment.model'
11import { environment } from '../../../../environments/environment'
12import { RestExtractor, RestService } from '../../../shared/rest'
13import { ComponentPagination } from '../../../shared/rest/component-pagination.model'
14import { SortField } from '../../../shared/video/sort-field.type'
15import { VideoComment } from './video-comment.model'
16
17@Injectable()
18export class VideoCommentService {
19 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
20
21 constructor (
22 private authHttp: HttpClient,
23 private restExtractor: RestExtractor,
24 private restService: RestService
25 ) {}
26
27 addCommentThread (videoId: number | string, comment: VideoCommentCreate) {
28 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
29
30 return this.authHttp.post(url, comment)
31 .map(data => this.extractVideoComment(data['comment']))
32 .catch(this.restExtractor.handleError)
33 }
34
35 addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) {
36 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
37
38 return this.authHttp.post(url, comment)
39 .map(data => this.extractVideoComment(data['comment']))
40 .catch(this.restExtractor.handleError)
41 }
42
43 getVideoCommentThreads (
44 videoId: number | string,
45 componentPagination: ComponentPagination,
46 sort: SortField
47 ): Observable<{ comments: VideoComment[], totalComments: number}> {
48 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
49
50 let params = new HttpParams()
51 params = this.restService.addRestGetParams(params, pagination, sort)
52
53 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
54 return this.authHttp
55 .get(url, { params })
56 .map(this.extractVideoComments)
57 .catch((res) => this.restExtractor.handleError(res))
58 }
59
60 getVideoThreadComments (videoId: number | string, threadId: number): Observable<VideoCommentThreadTree> {
61 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
62
63 return this.authHttp
64 .get(url)
65 .map(tree => this.extractVideoCommentTree(tree as VideoCommentThreadTree))
66 .catch((res) => this.restExtractor.handleError(res))
67 }
68
69 private extractVideoComment (videoComment: VideoCommentServerModel) {
70 return new VideoComment(videoComment)
71 }
72
73 private extractVideoComments (result: ResultList<VideoCommentServerModel>) {
74 const videoCommentsJson = result.data
75 const totalComments = result.total
76 const comments = []
77
78 for (const videoCommentJson of videoCommentsJson) {
79 comments.push(new VideoComment(videoCommentJson))
80 }
81
82 return { comments, totalComments }
83 }
84
85 private extractVideoCommentTree (tree: VideoCommentThreadTree) {
86 if (!tree) return tree
87
88 tree.comment = new VideoComment(tree.comment)
89 tree.children.forEach(c => this.extractVideoCommentTree(c))
90
91 return tree
92 }
93}
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
index 000000000..9d7581269
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.html
@@ -0,0 +1,31 @@
1<div>
2 <div class="title-page title-page-single">
3 Comments
4 </div>
5
6 <my-video-comment-add
7 *ngIf="isUserLoggedIn()"
8 [video]="video"
9 (commentCreated)="onCommentThreadCreated($event)"
10 ></my-video-comment-add>
11
12 <div class="comment-threads">
13 <div *ngFor="let comment of comments">
14 <my-video-comment
15 [comment]="comment"
16 [video]="video"
17 [inReplyToCommentId]="inReplyToCommentId"
18 [commentTree]="threadComments[comment.id]"
19 (wantedToReply)="onWantedToReply($event)"
20 (resetReply)="onResetReply()"
21 ></my-video-comment>
22
23 <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment)" class="view-replies">
24 View all {{ comment.totalReplies }} replies
25
26 <span *ngIf="!threadLoading[comment.id]" class="glyphicon glyphicon-menu-down"></span>
27 <my-loader class="comment-thread-loading" [loading]="threadLoading[comment.id]"></my-loader>
28 </div>
29 </div>
30 </div>
31</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
index 000000000..2f6e4663b
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.scss
@@ -0,0 +1,14 @@
1@import '_variables';
2@import '_mixins';
3
4.view-replies {
5 font-weight: $font-semibold;
6 font-size: 15px;
7 cursor: pointer;
8}
9
10.glyphicon, .comment-thread-loading {
11 margin-left: 5px;
12 display: inline-block;
13 font-size: 13px;
14}
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
index 000000000..32e0f2fbd
--- /dev/null
+++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts
@@ -0,0 +1,79 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { NotificationsService } from 'angular2-notifications'
3import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/video-comment.model'
4import { AuthService } from '../../../core/auth'
5import { ComponentPagination } from '../../../shared/rest/component-pagination.model'
6import { User } from '../../../shared/users'
7import { SortField } from '../../../shared/video/sort-field.type'
8import { Video } from '../../../shared/video/video.model'
9import { VideoComment } from './video-comment.model'
10import { VideoCommentService } from './video-comment.service'
11
12@Component({
13 selector: 'my-video-comments',
14 templateUrl: './video-comments.component.html',
15 styleUrls: ['./video-comments.component.scss']
16})
17export class VideoCommentsComponent implements OnInit {
18 @Input() video: Video
19 @Input() user: User
20
21 comments: VideoComment[] = []
22 sort: SortField = '-createdAt'
23 componentPagination: ComponentPagination = {
24 currentPage: 1,
25 itemsPerPage: 25,
26 totalItems: null
27 }
28 inReplyToCommentId: number
29 threadComments: { [ id: number ]: VideoCommentThreadTree } = {}
30 threadLoading: { [ id: number ]: boolean } = {}
31
32 constructor (
33 private authService: AuthService,
34 private notificationsService: NotificationsService,
35 private videoCommentService: VideoCommentService
36 ) {}
37
38 ngOnInit () {
39 this.videoCommentService.getVideoCommentThreads(this.video.id, this.componentPagination, this.sort)
40 .subscribe(
41 res => {
42 this.comments = res.comments
43 this.componentPagination.totalItems = res.totalComments
44 },
45
46 err => this.notificationsService.error('Error', err.message)
47 )
48 }
49
50 viewReplies (comment: VideoComment) {
51 this.threadLoading[comment.id] = true
52
53 this.videoCommentService.getVideoThreadComments(this.video.id, comment.id)
54 .subscribe(
55 res => {
56 this.threadComments[comment.id] = res
57 this.threadLoading[comment.id] = false
58 },
59
60 err => this.notificationsService.error('Error', err.message)
61 )
62 }
63
64 onCommentThreadCreated (comment: VideoComment) {
65 this.comments.unshift(comment)
66 }
67
68 onWantedToReply (comment: VideoComment) {
69 this.inReplyToCommentId = comment.id
70 }
71
72 onResetReply () {
73 this.inReplyToCommentId = undefined
74 }
75
76 isUserLoggedIn () {
77 return this.authService.isLoggedIn()
78 }
79}
diff --git a/client/src/app/videos/+video-watch/video-download.component.html b/client/src/app/videos/+video-watch/modal/video-download.component.html
index f8f17a471..f8f17a471 100644
--- a/client/src/app/videos/+video-watch/video-download.component.html
+++ b/client/src/app/videos/+video-watch/modal/video-download.component.html
diff --git a/client/src/app/videos/+video-watch/video-download.component.scss b/client/src/app/videos/+video-watch/modal/video-download.component.scss
index 5fca82135..6325f67a3 100644
--- a/client/src/app/videos/+video-watch/video-download.component.scss
+++ b/client/src/app/videos/+video-watch/modal/video-download.component.scss
@@ -1,5 +1,5 @@
1@import '_variables'; 1@import 'variables';
2@import '_mixins'; 2@import 'mixins';
3 3
4.peertube-select-container { 4.peertube-select-container {
5 @include peertube-select-container(130px); 5 @include peertube-select-container(130px);
diff --git a/client/src/app/videos/+video-watch/video-download.component.ts b/client/src/app/videos/+video-watch/modal/video-download.component.ts
index 44ece986c..1a73ea6df 100644
--- a/client/src/app/videos/+video-watch/video-download.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-download.component.ts
@@ -1,6 +1,6 @@
1import { Component, Input, OnInit, ViewChild } from '@angular/core' 1import { Component, Input, OnInit, ViewChild } from '@angular/core'
2import { ModalDirective } from 'ngx-bootstrap/modal' 2import { ModalDirective } from 'ngx-bootstrap/modal'
3import { VideoDetails } from '../../shared/video/video-details.model' 3import { VideoDetails } from '../../../shared/video/video-details.model'
4 4
5@Component({ 5@Component({
6 selector: 'my-video-download', 6 selector: 'my-video-download',
diff --git a/client/src/app/videos/+video-watch/video-report.component.html b/client/src/app/videos/+video-watch/modal/video-report.component.html
index a9a7beb48..a9a7beb48 100644
--- a/client/src/app/videos/+video-watch/video-report.component.html
+++ b/client/src/app/videos/+video-watch/modal/video-report.component.html
diff --git a/client/src/app/videos/+video-watch/video-report.component.scss b/client/src/app/videos/+video-watch/modal/video-report.component.scss
index 09d273b35..84562f15c 100644
--- a/client/src/app/videos/+video-watch/video-report.component.scss
+++ b/client/src/app/videos/+video-watch/modal/video-report.component.scss
@@ -1,5 +1,5 @@
1@import '_variables'; 1@import 'variables';
2@import '_mixins'; 2@import 'mixins';
3 3
4textarea { 4textarea {
5 @include peertube-textarea(100%, 60px); 5 @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/modal/video-report.component.ts
index ece14754a..050e827e7 100644
--- a/client/src/app/videos/+video-watch/video-report.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-report.component.ts
@@ -2,8 +2,8 @@ import { Component, Input, OnInit, ViewChild } from '@angular/core'
2import { FormBuilder, FormGroup } from '@angular/forms' 2import { FormBuilder, FormGroup } from '@angular/forms'
3import { NotificationsService } from 'angular2-notifications' 3import { NotificationsService } from 'angular2-notifications'
4import { ModalDirective } from 'ngx-bootstrap/modal' 4import { ModalDirective } from 'ngx-bootstrap/modal'
5import { FormReactive, VIDEO_ABUSE_REASON, VideoAbuseService } from '../../shared' 5import { FormReactive, VIDEO_ABUSE_REASON, VideoAbuseService } from '../../../shared/index'
6import { VideoDetails } from '../../shared/video/video-details.model' 6import { VideoDetails } from '../../../shared/video/video-details.model'
7 7
8@Component({ 8@Component({
9 selector: 'my-video-report', 9 selector: 'my-video-report',
diff --git a/client/src/app/videos/+video-watch/video-share.component.html b/client/src/app/videos/+video-watch/modal/video-share.component.html
index 85cf10a6c..85cf10a6c 100644
--- a/client/src/app/videos/+video-watch/video-share.component.html
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.html
diff --git a/client/src/app/videos/+video-watch/video-share.component.scss b/client/src/app/videos/+video-watch/modal/video-share.component.scss
index 184e09027..184e09027 100644
--- a/client/src/app/videos/+video-watch/video-share.component.scss
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.scss
diff --git a/client/src/app/videos/+video-watch/video-share.component.ts b/client/src/app/videos/+video-watch/modal/video-share.component.ts
index 0664c28be..678cccfb5 100644
--- a/client/src/app/videos/+video-watch/video-share.component.ts
+++ b/client/src/app/videos/+video-watch/modal/video-share.component.ts
@@ -3,7 +3,7 @@ import { Component, Input, ViewChild } from '@angular/core'
3import { NotificationsService } from 'angular2-notifications' 3import { NotificationsService } from 'angular2-notifications'
4 4
5import { ModalDirective } from 'ngx-bootstrap/modal' 5import { ModalDirective } from 'ngx-bootstrap/modal'
6import { VideoDetails } from '../../shared/video/video-details.model' 6import { VideoDetails } from '../../../shared/video/video-details.model'
7 7
8@Component({ 8@Component({
9 selector: 'my-video-share', 9 selector: 'my-video-share',
diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html
index 860edecd2..48d1bb474 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.html
+++ b/client/src/app/videos/+video-watch/video-watch.component.html
@@ -54,6 +54,12 @@
54 </a> 54 </a>
55 </li> 55 </li>
56 56
57 <li *ngIf="isVideoUpdatable()" role="menuitem">
58 <a class="dropdown-item" title="Update this video" href="#" [routerLink]="[ '/videos/edit', video.uuid ]">
59 <span class="icon icon-edit"></span> Update
60 </a>
61 </li>
62
57 <li *ngIf="isVideoRemovable()" role="menuitem"> 63 <li *ngIf="isVideoRemovable()" role="menuitem">
58 <a class="dropdown-item" title="Delete this video" href="#" (click)="removeVideo($event)"> 64 <a class="dropdown-item" title="Delete this video" href="#" (click)="removeVideo($event)">
59 <span class="icon icon-blacklist"></span> Delete 65 <span class="icon icon-blacklist"></span> Delete
@@ -149,6 +155,7 @@
149 </div> 155 </div>
150 </div> 156 </div>
151 157
158 <my-video-comments [video]="video" [user]="user"></my-video-comments>
152 </div> 159 </div>
153 160
154 <div class="other-videos"> 161 <div class="other-videos">
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index b37fa3d61..c101aa04e 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -126,6 +126,10 @@
126 background-image: url('../../../assets/images/video/download-black.svg'); 126 background-image: url('../../../assets/images/video/download-black.svg');
127 } 127 }
128 128
129 &.icon-edit {
130 background-image: url('../../../assets/images/global/edit-black.svg');
131 }
132
129 &.icon-alert { 133 &.icon-alert {
130 background-image: url('../../../assets/images/video/alert.svg'); 134 background-image: url('../../../assets/images/video/alert.svg');
131 } 135 }
diff --git a/client/src/app/videos/+video-watch/video-watch.component.ts b/client/src/app/videos/+video-watch/video-watch.component.ts
index c388b138b..4afd6160c 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -14,9 +14,9 @@ import { VideoDetails } from '../../shared/video/video-details.model'
14import { Video } from '../../shared/video/video.model' 14import { Video } from '../../shared/video/video.model'
15import { VideoService } from '../../shared/video/video.service' 15import { VideoService } from '../../shared/video/video.service'
16import { MarkdownService } from '../shared' 16import { MarkdownService } from '../shared'
17import { VideoDownloadComponent } from './video-download.component' 17import { VideoDownloadComponent } from './modal/video-download.component'
18import { VideoReportComponent } from './video-report.component' 18import { VideoReportComponent } from './modal/video-report.component'
19import { VideoShareComponent } from './video-share.component' 19import { VideoShareComponent } from './modal/video-share.component'
20 20
21@Component({ 21@Component({
22 selector: 'my-video-watch', 22 selector: 'my-video-watch',
@@ -208,6 +208,10 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
208 return this.authService.isLoggedIn() 208 return this.authService.isLoggedIn()
209 } 209 }
210 210
211 isVideoUpdatable () {
212 return this.video.isUpdatableBy(this.authService.getUser())
213 }
214
211 isVideoBlacklistable () { 215 isVideoBlacklistable () {
212 return this.video.isBlackistableBy(this.user) 216 return this.video.isBlackistableBy(this.user)
213 } 217 }
diff --git a/client/src/app/videos/+video-watch/video-watch.module.ts b/client/src/app/videos/+video-watch/video-watch.module.ts
index e77883472..085a9ec5a 100644
--- a/client/src/app/videos/+video-watch/video-watch.module.ts
+++ b/client/src/app/videos/+video-watch/video-watch.module.ts
@@ -3,9 +3,13 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip'
3import { ClipboardModule } from 'ngx-clipboard' 3import { ClipboardModule } from 'ngx-clipboard'
4import { SharedModule } from '../../shared' 4import { SharedModule } from '../../shared'
5import { MarkdownService } from '../shared' 5import { MarkdownService } from '../shared'
6import { VideoDownloadComponent } from './video-download.component' 6import { VideoCommentAddComponent } from './comment/video-comment-add.component'
7import { VideoReportComponent } from './video-report.component' 7import { VideoCommentComponent } from './comment/video-comment.component'
8import { VideoShareComponent } from './video-share.component' 8import { VideoCommentService } from './comment/video-comment.service'
9import { VideoCommentsComponent } from './comment/video-comments.component'
10import { VideoDownloadComponent } from './modal/video-download.component'
11import { VideoReportComponent } from './modal/video-report.component'
12import { VideoShareComponent } from './modal/video-share.component'
9 13
10import { VideoWatchRoutingModule } from './video-watch-routing.module' 14import { VideoWatchRoutingModule } from './video-watch-routing.module'
11 15
@@ -24,7 +28,10 @@ import { VideoWatchComponent } from './video-watch.component'
24 28
25 VideoDownloadComponent, 29 VideoDownloadComponent,
26 VideoShareComponent, 30 VideoShareComponent,
27 VideoReportComponent 31 VideoReportComponent,
32 VideoCommentsComponent,
33 VideoCommentAddComponent,
34 VideoCommentComponent
28 ], 35 ],
29 36
30 exports: [ 37 exports: [
@@ -32,7 +39,8 @@ import { VideoWatchComponent } from './video-watch.component'
32 ], 39 ],
33 40
34 providers: [ 41 providers: [
35 MarkdownService 42 MarkdownService,
43 VideoCommentService
36 ] 44 ]
37}) 45})
38export class VideoWatchModule { } 46export 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
index 000000000..0176b0f37
--- /dev/null
+++ b/client/src/assets/images/global/edit-black.svg
@@ -0,0 +1,15 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<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">
3 <!-- Generator: Sketch 43.2 (39069) - http://www.bohemiancoding.com/sketch -->
4 <title>edit</title>
5 <desc>Created with Sketch.</desc>
6 <defs></defs>
7 <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
8 <g id="Artboard-4" transform="translate(-48.000000, -203.000000)" stroke="#000000" stroke-width="2">
9 <g id="41" transform="translate(48.000000, 203.000000)">
10 <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>
11 <path d="M14.5,5.5 L18.5,9.5" id="Path-75"></path>
12 </g>
13 </g>
14 </g>
15</svg>
diff --git a/client/src/assets/images/global/edit.svg b/client/src/assets/images/global/edit-grey.svg
index 23ece68f1..23ece68f1 100644
--- a/client/src/assets/images/global/edit.svg
+++ b/client/src/assets/images/global/edit-grey.svg
diff --git a/client/src/sass/application.scss b/client/src/sass/application.scss
index 0539ec486..253bb1b3c 100644
--- a/client/src/sass/application.scss
+++ b/client/src/sass/application.scss
@@ -118,10 +118,7 @@ label {
118 118
119// Thanks https://gist.github.com/alexandrevicenzi/680147013e902a4eaa5d 119// Thanks https://gist.github.com/alexandrevicenzi/680147013e902a4eaa5d
120.glyphicon-refresh-animate { 120.glyphicon-refresh-animate {
121 -animation: spin .7s infinite linear; 121 animation: spin .7s infinite linear;
122 -ms-animation: spin .7s infinite linear;
123 -webkit-animation: spinw .7s infinite linear;
124 -moz-animation: spinm .7s infinite linear;
125} 122}
126 123
127@keyframes spin { 124@keyframes spin {
@@ -129,16 +126,6 @@ label {
129 to { transform: scale(1) rotate(360deg);} 126 to { transform: scale(1) rotate(360deg);}
130} 127}
131 128
132@-webkit-keyframes spinw {
133 from { -webkit-transform: rotate(0deg);}
134 to { -webkit-transform: rotate(360deg);}
135}
136
137@-moz-keyframes spinm {
138 from { -moz-transform: rotate(0deg);}
139 to { -moz-transform: rotate(360deg);}
140}
141
142// ngprime data table customizations 129// ngprime data table customizations
143p-datatable { 130p-datatable {
144 font-size: 15px !important; 131 font-size: 15px !important;
diff --git a/server.ts b/server.ts
index f64c4ac53..05fc39acb 100644
--- a/server.ts
+++ b/server.ts
@@ -151,7 +151,7 @@ app.use(function (req, res, next) {
151}) 151})
152 152
153app.use(function (err, req, res, next) { 153app.use(function (err, req, res, next) {
154 logger.error(err) 154 logger.error(err, err)
155 res.sendStatus(err.status || 500) 155 res.sendStatus(err.status || 500)
156}) 156})
157 157
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index e9dbb6d1b..276948098 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -66,9 +66,7 @@ async function addVideoCommentThreadRetryWrapper (req: express.Request, res: exp
66 const comment = await retryTransactionWrapper(addVideoCommentThread, options) 66 const comment = await retryTransactionWrapper(addVideoCommentThread, options)
67 67
68 res.json({ 68 res.json({
69 comment: { 69 comment: comment.toFormattedJSON()
70 id: comment.id
71 }
72 }).end() 70 }).end()
73} 71}
74 72
@@ -80,7 +78,7 @@ function addVideoCommentThread (req: express.Request, res: express.Response) {
80 text: videoCommentInfo.text, 78 text: videoCommentInfo.text,
81 inReplyToComment: null, 79 inReplyToComment: null,
82 video: res.locals.video, 80 video: res.locals.video,
83 accountId: res.locals.oauth.token.User.Account.id 81 account: res.locals.oauth.token.User.Account
84 }, t) 82 }, t)
85 }) 83 })
86} 84}
@@ -94,9 +92,7 @@ async function addVideoCommentReplyRetryWrapper (req: express.Request, res: expr
94 const comment = await retryTransactionWrapper(addVideoCommentReply, options) 92 const comment = await retryTransactionWrapper(addVideoCommentReply, options)
95 93
96 res.json({ 94 res.json({
97 comment: { 95 comment: comment.toFormattedJSON()
98 id: comment.id
99 }
100 }).end() 96 }).end()
101} 97}
102 98
@@ -108,7 +104,7 @@ function addVideoCommentReply (req: express.Request, res: express.Response, next
108 text: videoCommentInfo.text, 104 text: videoCommentInfo.text,
109 inReplyToComment: res.locals.videoComment, 105 inReplyToComment: res.locals.videoComment,
110 video: res.locals.video, 106 video: res.locals.video,
111 accountId: res.locals.oauth.token.User.Account.id 107 account: res.locals.oauth.token.User.Account
112 }, t) 108 }, t)
113 }) 109 })
114} 110}
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index ef6a8f097..0d744c526 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -1,29 +1,32 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { ResultList } from '../../shared/models' 2import { ResultList } from '../../shared/models'
3import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model' 3import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model'
4import { AccountModel } from '../models/account/account'
4import { VideoModel } from '../models/video/video' 5import { VideoModel } from '../models/video/video'
5import { VideoCommentModel } from '../models/video/video-comment' 6import { VideoCommentModel } from '../models/video/video-comment'
6import { getVideoCommentActivityPubUrl, sendVideoRateChangeToFollowers } from './activitypub' 7import { getVideoCommentActivityPubUrl } from './activitypub'
7import { sendCreateVideoCommentToOrigin, sendCreateVideoCommentToVideoFollowers } from './activitypub/send' 8import { sendCreateVideoCommentToOrigin, sendCreateVideoCommentToVideoFollowers } from './activitypub/send'
8 9
9async function createVideoComment (obj: { 10async function createVideoComment (obj: {
10 text: string, 11 text: string,
11 inReplyToComment: VideoCommentModel, 12 inReplyToComment: VideoCommentModel,
12 video: VideoModel 13 video: VideoModel
13 accountId: number 14 account: AccountModel
14}, t: Sequelize.Transaction) { 15}, t: Sequelize.Transaction) {
15 let originCommentId: number = null 16 let originCommentId: number = null
17 let inReplyToCommentId: number = null
16 18
17 if (obj.inReplyToComment) { 19 if (obj.inReplyToComment) {
18 originCommentId = obj.inReplyToComment.originCommentId || obj.inReplyToComment.id 20 originCommentId = obj.inReplyToComment.originCommentId || obj.inReplyToComment.id
21 inReplyToCommentId = obj.inReplyToComment.id
19 } 22 }
20 23
21 const comment = await VideoCommentModel.create({ 24 const comment = await VideoCommentModel.create({
22 text: obj.text, 25 text: obj.text,
23 originCommentId, 26 originCommentId,
24 inReplyToCommentId: obj.inReplyToComment.id, 27 inReplyToCommentId,
25 videoId: obj.video.id, 28 videoId: obj.video.id,
26 accountId: obj.accountId, 29 accountId: obj.account.id,
27 url: 'fake url' 30 url: 'fake url'
28 }, { transaction: t, validate: false }) 31 }, { transaction: t, validate: false })
29 32
@@ -32,6 +35,7 @@ async function createVideoComment (obj: {
32 const savedComment = await comment.save({ transaction: t }) 35 const savedComment = await comment.save({ transaction: t })
33 savedComment.InReplyToVideoComment = obj.inReplyToComment 36 savedComment.InReplyToVideoComment = obj.inReplyToComment
34 savedComment.Video = obj.video 37 savedComment.Video = obj.video
38 savedComment.Account = obj.account
35 39
36 if (savedComment.Video.isOwned()) { 40 if (savedComment.Video.isOwned()) {
37 await sendCreateVideoCommentToVideoFollowers(savedComment, t) 41 await sendCreateVideoCommentToVideoFollowers(savedComment, t)
diff --git a/server/middlewares/validators/pagination.ts b/server/middlewares/validators/pagination.ts
index 0895b4eb8..25debfa6e 100644
--- a/server/middlewares/validators/pagination.ts
+++ b/server/middlewares/validators/pagination.ts
@@ -4,8 +4,8 @@ import { logger } from '../../helpers'
4import { areValidationErrors } from './utils' 4import { areValidationErrors } from './utils'
5 5
6const paginationValidator = [ 6const paginationValidator = [
7 query('start').optional().isInt().withMessage('Should have a number start'), 7 query('start').optional().isInt({ min: 0 }).withMessage('Should have a number start'),
8 query('count').optional().isInt().withMessage('Should have a number count'), 8 query('count').optional().isInt({ min: 0 }).withMessage('Should have a number count'),
9 9
10 (req: express.Request, res: express.Response, next: express.NextFunction) => { 10 (req: express.Request, res: express.Response, next: express.NextFunction) => {
11 logger.debug('Checking pagination parameters', { parameters: req.query }) 11 logger.debug('Checking pagination parameters', { parameters: req.query })
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index 25cd6d563..a3e8c48d4 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -8,18 +8,48 @@ import { VideoComment } from '../../../shared/models/videos/video-comment.model'
8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub' 8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub'
9import { CONSTRAINTS_FIELDS } from '../../initializers' 9import { CONSTRAINTS_FIELDS } from '../../initializers'
10import { AccountModel } from '../account/account' 10import { AccountModel } from '../account/account'
11import { ActorModel } from '../activitypub/actor'
12import { ServerModel } from '../server/server'
11import { getSort, throwIfNotValid } from '../utils' 13import { getSort, throwIfNotValid } from '../utils'
12import { VideoModel } from './video' 14import { VideoModel } from './video'
13 15
14enum ScopeNames { 16enum ScopeNames {
15 WITH_ACCOUNT = 'WITH_ACCOUNT', 17 WITH_ACCOUNT = 'WITH_ACCOUNT',
16 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO' 18 WITH_IN_REPLY_TO = 'WITH_IN_REPLY_TO',
19 ATTRIBUTES_FOR_API = 'ATTRIBUTES_FOR_API'
17} 20}
18 21
19@Scopes({ 22@Scopes({
23 [ScopeNames.ATTRIBUTES_FOR_API]: {
24 attributes: {
25 include: [
26 [
27 Sequelize.literal(
28 '(SELECT COUNT("replies"."id") ' +
29 'FROM "videoComment" AS "replies" ' +
30 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id")'
31 ),
32 'totalReplies'
33 ]
34 ]
35 }
36 },
20 [ScopeNames.WITH_ACCOUNT]: { 37 [ScopeNames.WITH_ACCOUNT]: {
21 include: [ 38 include: [
22 () => AccountModel 39 {
40 model: () => AccountModel,
41 include: [
42 {
43 model: () => ActorModel,
44 include: [
45 {
46 model: () => ServerModel,
47 required: false
48 }
49 ]
50 }
51 ]
52 }
23 ] 53 ]
24 }, 54 },
25 [ScopeNames.WITH_IN_REPLY_TO]: { 55 [ScopeNames.WITH_IN_REPLY_TO]: {
@@ -149,7 +179,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
149 } 179 }
150 180
151 return VideoCommentModel 181 return VideoCommentModel
152 .scope([ ScopeNames.WITH_ACCOUNT ]) 182 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
153 .findAndCountAll(query) 183 .findAndCountAll(query)
154 .then(({ rows, count }) => { 184 .then(({ rows, count }) => {
155 return { total: count, data: rows } 185 return { total: count, data: rows }
@@ -169,7 +199,7 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
169 } 199 }
170 200
171 return VideoCommentModel 201 return VideoCommentModel
172 .scope([ ScopeNames.WITH_ACCOUNT ]) 202 .scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.ATTRIBUTES_FOR_API ])
173 .findAndCountAll(query) 203 .findAndCountAll(query)
174 .then(({ rows, count }) => { 204 .then(({ rows, count }) => {
175 return { total: count, data: rows } 205 return { total: count, data: rows }
@@ -186,8 +216,10 @@ export class VideoCommentModel extends Model<VideoCommentModel> {
186 videoId: this.videoId, 216 videoId: this.videoId,
187 createdAt: this.createdAt, 217 createdAt: this.createdAt,
188 updatedAt: this.updatedAt, 218 updatedAt: this.updatedAt,
219 totalReplies: this.get('totalReplies') || 0,
189 account: { 220 account: {
190 name: this.Account.name 221 name: this.Account.name,
222 host: this.Account.Actor.getHost()
191 } 223 }
192 } as VideoComment 224 } as VideoComment
193 } 225 }
diff --git a/server/tests/api/video-comments.ts b/server/tests/api/video-comments.ts
index 2f1e6260a..2c7d1c6e2 100644
--- a/server/tests/api/video-comments.ts
+++ b/server/tests/api/video-comments.ts
@@ -39,7 +39,18 @@ describe('Test video comments', function () {
39 it('Should create a thread in this video', async function () { 39 it('Should create a thread in this video', async function () {
40 const text = 'my super first comment' 40 const text = 'my super first comment'
41 41
42 await addVideoCommentThread(server.url, server.accessToken, videoUUID, text) 42 const res = await addVideoCommentThread(server.url, server.accessToken, videoUUID, text)
43 const comment = res.body
44
45 expect(comment.inReplyToCommentId).to.be.null
46 expect(comment.text).equal('my super first comment')
47 expect(comment.videoId).to.equal(videoId)
48 expect(comment.id).to.equal(comment.threadId)
49 expect(comment.account.name).to.equal('root')
50 expect(comment.account.host).to.equal('localhost:9001')
51 expect(comment.totalReplies).to.equal(0)
52 expect(dateIsValid(comment.createdAt as string)).to.be.true
53 expect(dateIsValid(comment.updatedAt as string)).to.be.true
43 }) 54 })
44 55
45 it('Should list threads of this video', async function () { 56 it('Should list threads of this video', async function () {
@@ -55,6 +66,8 @@ describe('Test video comments', function () {
55 expect(comment.videoId).to.equal(videoId) 66 expect(comment.videoId).to.equal(videoId)
56 expect(comment.id).to.equal(comment.threadId) 67 expect(comment.id).to.equal(comment.threadId)
57 expect(comment.account.name).to.equal('root') 68 expect(comment.account.name).to.equal('root')
69 expect(comment.account.host).to.equal('localhost:9001')
70 expect(comment.totalReplies).to.equal(0)
58 expect(dateIsValid(comment.createdAt as string)).to.be.true 71 expect(dateIsValid(comment.createdAt as string)).to.be.true
59 expect(dateIsValid(comment.updatedAt as string)).to.be.true 72 expect(dateIsValid(comment.updatedAt as string)).to.be.true
60 73
@@ -120,8 +133,11 @@ describe('Test video comments', function () {
120 expect(res.body.data).to.have.lengthOf(3) 133 expect(res.body.data).to.have.lengthOf(3)
121 134
122 expect(res.body.data[0].text).to.equal('my super first comment') 135 expect(res.body.data[0].text).to.equal('my super first comment')
136 expect(res.body.data[0].totalReplies).to.equal(2)
123 expect(res.body.data[1].text).to.equal('super thread 2') 137 expect(res.body.data[1].text).to.equal('super thread 2')
138 expect(res.body.data[1].totalReplies).to.equal(1)
124 expect(res.body.data[2].text).to.equal('super thread 3') 139 expect(res.body.data[2].text).to.equal('super thread 3')
140 expect(res.body.data[2].totalReplies).to.equal(0)
125 }) 141 })
126 142
127 after(async function () { 143 after(async function () {
diff --git a/shared/models/videos/video-comment.model.ts b/shared/models/videos/video-comment.model.ts
index 69884782f..d572927c2 100644
--- a/shared/models/videos/video-comment.model.ts
+++ b/shared/models/videos/video-comment.model.ts
@@ -7,8 +7,10 @@ export interface VideoComment {
7 videoId: number 7 videoId: number
8 createdAt: Date | string 8 createdAt: Date | string
9 updatedAt: Date | string 9 updatedAt: Date | string
10 totalReplies: number
10 account: { 11 account: {
11 name: string 12 name: string
13 host: string
12 } 14 }
13} 15}
14 16