aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+videos/+video-watch/comment
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2020-06-23 14:49:20 +0200
committerChocobozzz <chocobozzz@cpy.re>2020-06-23 16:00:49 +0200
commit1942f11d5ee6926ad93dc1b79fae18325ba5de18 (patch)
tree3f2a3cd9466a56c419d197ac832a3e9cbc86bec4 /client/src/app/+videos/+video-watch/comment
parent67ed6552b831df66713bac9e672738796128d33f (diff)
downloadPeerTube-1942f11d5ee6926ad93dc1b79fae18325ba5de18.tar.gz
PeerTube-1942f11d5ee6926ad93dc1b79fae18325ba5de18.tar.zst
PeerTube-1942f11d5ee6926ad93dc1b79fae18325ba5de18.zip
Lazy load all routes
Diffstat (limited to 'client/src/app/+videos/+video-watch/comment')
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-add.component.html56
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss82
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts149
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts7
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.html95
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.scss189
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.component.ts131
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.model.ts48
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comment.service.ts149
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comments.component.html98
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comments.component.scss53
-rw-r--r--client/src/app/+videos/+video-watch/comment/video-comments.component.ts232
12 files changed, 1289 insertions, 0 deletions
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..9b43d91da
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html
@@ -0,0 +1,56 @@
1<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
2 <div class="avatar-and-textarea">
3 <img [src]="getAvatarUrl()" alt="Avatar" />
4
5 <div class="form-group">
6 <textarea i18n-placeholder placeholder="Add comment..." myAutoResize
7 [readonly]="(user === null) ? true : false"
8 (click)="openVisitorModal($event)"
9 formControlName="text" [ngClass]="{ 'input-error': formErrors['text'] }"
10 (keyup.control.enter)="onValidKey()" (keyup.meta.enter)="onValidKey()" #textarea>
11
12 </textarea>
13 <div *ngIf="formErrors.text" class="form-error">
14 {{ formErrors.text }}
15 </div>
16 </div>
17 </div>
18
19 <div class="comment-buttons">
20 <button *ngIf="isAddButtonDisplayed()" class="cancel-button" (click)="cancelCommentReply()" type="button" i18n>
21 Cancel
22 </button>
23 <button *ngIf="isAddButtonDisplayed()" [ngClass]="{ disabled: !form.valid || addingComment }" i18n>
24 Reply
25 </button>
26 </div>
27</form>
28
29<ng-template #visitorModal let-modal>
30 <div class="modal-header">
31 <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4>
32 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideVisitorModal()"></my-global-icon>
33 </div>
34 <div class="modal-body">
35 <span i18n>
36 You can comment using an account on any ActivityPub-compatible instance.
37 On most platforms, you can find the video by typing its URL in the search bar and then comment it
38 from within the software's interface.
39 </span>
40 <span i18n>
41 If you have an account on Mastodon or Pleroma, you can open it directly in their interface:
42 </span>
43 <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe>
44 </div>
45 <div class="modal-footer inputs">
46 <input
47 type="button" role="button" i18n-value value="Cancel" class="action-button action-button-cancel"
48 (click)="hideVisitorModal()" (key.enter)="hideVisitorModal()"
49 >
50
51 <input
52 type="submit" i18n-value value="Login to comment" class="action-button-submit"
53 (click)="gotoLogin()"
54 >
55 </div>
56</ng-template>
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..b3725ab94
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss
@@ -0,0 +1,82 @@
1@import '_variables';
2@import '_mixins';
3
4form {
5 margin-bottom: 30px;
6}
7
8.avatar-and-textarea {
9 display: flex;
10 margin-bottom: 10px;
11
12 img {
13 @include avatar(25px);
14
15 vertical-align: top;
16 margin-right: 10px;
17 }
18
19 .form-group {
20 flex-grow: 1;
21 margin: 0;
22
23 textarea {
24 @include peertube-textarea(100%, 60px);
25
26 &:focus::placeholder {
27 opacity: 0;
28 }
29 }
30 }
31}
32
33.comment-buttons {
34 display: flex;
35 justify-content: flex-end;
36
37 button {
38 @include peertube-button;
39 @include disable-outline;
40 @include disable-default-a-behaviour;
41
42 &:not(:last-child) {
43 margin-right: .5rem;
44 }
45
46 &:last-child {
47 @include orange-button;
48 }
49 }
50
51 .cancel-button {
52 @include tertiary-button;
53
54 font-weight: $font-semibold;
55 display: inline-block;
56 padding: 0 10px 0 10px;
57 white-space: nowrap;
58 background: transparent;
59 }
60}
61
62@media screen and (max-width: 600px) {
63 textarea, .comment-buttons button {
64 font-size: 14px !important;
65 }
66
67 textarea {
68 padding: 5px !important;
69 }
70}
71
72.modal-body {
73 .btn {
74 @include peertube-button;
75 @include orange-button;
76 }
77
78 span {
79 float: left;
80 margin-bottom: 20px;
81 }
82}
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..79505c779
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts
@@ -0,0 +1,149 @@
1import { Observable } from 'rxjs'
2import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
3import { Router } from '@angular/router'
4import { Notifier, User } from '@app/core'
5import { FormReactive, FormValidatorService, VideoCommentValidatorsService } from '@app/shared/shared-forms'
6import { Video } from '@app/shared/shared-main'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { VideoCommentCreate } from '@shared/models'
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() user: User
19 @Input() video: Video
20 @Input() parentComment: VideoComment
21 @Input() parentComments: VideoComment[]
22 @Input() focusOnInit = false
23
24 @Output() commentCreated = new EventEmitter<VideoComment>()
25 @Output() cancel = new EventEmitter()
26
27 @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal
28 @ViewChild('textarea', { static: true }) textareaElement: ElementRef
29
30 addingComment = false
31
32 constructor (
33 protected formValidatorService: FormValidatorService,
34 private videoCommentValidatorsService: VideoCommentValidatorsService,
35 private notifier: Notifier,
36 private videoCommentService: VideoCommentService,
37 private modalService: NgbModal,
38 private router: Router
39 ) {
40 super()
41 }
42
43 ngOnInit () {
44 this.buildForm({
45 text: this.videoCommentValidatorsService.VIDEO_COMMENT_TEXT
46 })
47
48 if (this.user) {
49 if (this.focusOnInit === true) {
50 this.textareaElement.nativeElement.focus()
51 }
52
53 if (this.parentComment) {
54 const mentions = this.parentComments
55 .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves
56 .map(c => '@' + c.by)
57
58 const mentionsSet = new Set(mentions)
59 const mentionsText = Array.from(mentionsSet).join(' ') + ' '
60
61 this.form.patchValue({ text: mentionsText })
62 }
63 }
64 }
65
66 onValidKey () {
67 this.check()
68 if (!this.form.valid) return
69
70 this.formValidated()
71 }
72
73 openVisitorModal (event: any) {
74 if (this.user === null) { // we only open it for visitors
75 // fixing ng-bootstrap ModalService and the "Expression Changed After It Has Been Checked" Error
76 event.srcElement.blur()
77 event.preventDefault()
78
79 this.modalService.open(this.visitorModal)
80 }
81 }
82
83 hideVisitorModal () {
84 this.modalService.dismissAll()
85 }
86
87 formValidated () {
88 // If we validate very quickly the comment form, we might comment twice
89 if (this.addingComment) return
90
91 this.addingComment = true
92
93 const commentCreate: VideoCommentCreate = this.form.value
94 let obs: Observable<VideoComment>
95
96 if (this.parentComment) {
97 obs = this.addCommentReply(commentCreate)
98 } else {
99 obs = this.addCommentThread(commentCreate)
100 }
101
102 obs.subscribe(
103 comment => {
104 this.addingComment = false
105 this.commentCreated.emit(comment)
106 this.form.reset()
107 },
108
109 err => {
110 this.addingComment = false
111
112 this.notifier.error(err.text)
113 }
114 )
115 }
116
117 isAddButtonDisplayed () {
118 return this.form.value['text']
119 }
120
121 getUri () {
122 return window.location.href
123 }
124
125 getAvatarUrl () {
126 if (this.user) return this.user.accountAvatarUrl
127 return window.location.origin + '/client/assets/images/default-avatar.png'
128 }
129
130 gotoLogin () {
131 this.hideVisitorModal()
132 this.router.navigate([ '/login' ])
133 }
134
135 cancelCommentReply () {
136 this.cancel.emit(null)
137 this.form.value['text'] = this.textareaElement.nativeElement.value = ''
138 }
139
140 private addCommentReply (commentCreate: VideoCommentCreate) {
141 return this.videoCommentService
142 .addCommentReply(this.video.id, this.parentComment.id, commentCreate)
143 }
144
145 private addCommentThread (commentCreate: VideoCommentCreate) {
146 return this.videoCommentService
147 .addCommentThread(this.video.id, commentCreate)
148 }
149}
diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts
new file mode 100644
index 000000000..7c2aaeadd
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment-thread-tree.model.ts
@@ -0,0 +1,7 @@
1import { VideoCommentThreadTree as VideoCommentThreadTreeServerModel } from '@shared/models'
2import { VideoComment } from './video-comment.model'
3
4export class VideoCommentThreadTree implements VideoCommentThreadTreeServerModel {
5 comment: VideoComment
6 children: VideoCommentThreadTree[]
7}
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..002de57e4
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.html
@@ -0,0 +1,95 @@
1<div class="root-comment">
2 <div class="left">
3 <a *ngIf="!comment.isDeleted" [href]="comment.account.url" target="_blank" rel="noopener noreferrer">
4 <img
5 class="comment-avatar"
6 [src]="comment.accountAvatarUrl"
7 (error)="switchToDefaultAvatar($event)"
8 alt="Avatar"
9 />
10 </a>
11
12 <div class="vertical-border"></div>
13 </div>
14
15 <div class="right" [ngClass]="{ 'mb-3': firstInThread }">
16 <span *ngIf="comment.isDeleted" class="comment-avatar"></span>
17
18 <div class="comment">
19 <ng-container *ngIf="!comment.isDeleted">
20 <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div>
21
22 <div class="comment-account-date">
23 <div class="comment-account">
24 <a
25 [routerLink]="[ '/accounts', comment.by ]"
26 class="comment-account-name" [ngClass]="{ 'video-author': video.account.id === comment.account.id }"
27 >
28 {{ comment.account.displayName }}
29 </a>
30
31 <a [href]="comment.account.url" target="_blank" rel="noopener noreferrer" class="comment-account-fid ml-1">{{ comment.by }}</a>
32 </div>
33 <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
34 class="comment-date" [title]="comment.createdAt">{{ comment.createdAt | myFromNow }}</a>
35 </div>
36 <div
37 class="comment-html"
38 [innerHTML]="sanitizedCommentHTML"
39 (timestampClicked)="handleTimestampClicked($event)"
40 timestampRouteTransformer
41 ></div>
42
43 <div class="comment-actions">
44 <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
45 <div *ngIf="isRemovableByUser()" (click)="onWantToDelete()" class="comment-action-delete" i18n>Delete</div>
46
47 <my-user-moderation-dropdown
48 buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
49 ></my-user-moderation-dropdown>
50 </div>
51 </ng-container>
52
53 <ng-container *ngIf="comment.isDeleted">
54 <div class="comment-account-date">
55 <span class="comment-account" i18n>Deleted</span>
56 <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
57 class="comment-date">{{ comment.createdAt | myFromNow }}</a>
58 </div>
59
60 <div *ngIf="comment.isDeleted" class="comment-html comment-html-deleted">
61 <i i18n>This comment has been deleted</i>
62 </div>
63 </ng-container>
64
65 <my-video-comment-add
66 *ngIf="!comment.isDeleted && isUserLoggedIn() && inReplyToCommentId === comment.id"
67 [user]="user"
68 [video]="video"
69 [parentComment]="comment"
70 [parentComments]="newParentComments"
71 [focusOnInit]="true"
72 (commentCreated)="onCommentReplyCreated($event)"
73 (cancel)="onResetReply()"
74 ></my-video-comment-add>
75
76 <div *ngIf="commentTree" class="children">
77 <div *ngFor="let commentChild of commentTree.children">
78 <my-video-comment
79 [comment]="commentChild.comment"
80 [video]="video"
81 [inReplyToCommentId]="inReplyToCommentId"
82 [commentTree]="commentChild"
83 [parentComments]="newParentComments"
84 (wantedToReply)="onWantToReply($event)"
85 (wantedToDelete)="onWantToDelete($event)"
86 (resetReply)="onResetReply()"
87 (timestampClicked)="handleTimestampClicked($event)"
88 ></my-video-comment>
89 </div>
90 </div>
91
92 <ng-content></ng-content>
93 </div>
94 </div>
95</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..e7ef79561
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.scss
@@ -0,0 +1,189 @@
1@import '_variables';
2@import '_mixins';
3
4.root-comment {
5 font-size: 15px;
6 display: flex;
7
8 .left {
9 display: flex;
10 flex-direction: column;
11 align-items: center;
12 margin-right: 10px;
13
14 .vertical-border {
15 width: 2px;
16 height: 100%;
17 background-color: rgba(0, 0, 0, 0.05);
18 margin: 10px calc(1rem + 1px);
19 }
20 }
21
22 .right {
23 width: 100%;
24 }
25
26 .comment-avatar {
27 @include avatar(36px);
28 }
29
30 .comment {
31 flex-grow: 1;
32 // Fix word-wrap with flex
33 min-width: 1px;
34
35 .highlighted-comment {
36 display: inline-block;
37 background-color: #F5F5F5;
38 color: #3d3d3d;
39 padding: 0 5px;
40 font-size: 13px;
41 margin-bottom: 5px;
42 font-weight: $font-semibold;
43 border-radius: 3px;
44 }
45
46 .comment-account-date {
47 display: flex;
48 margin-bottom: 4px;
49
50 .video-author {
51 height: 20px;
52 background-color: #888888;
53 border-radius: 12px;
54 margin-bottom: 2px;
55 max-width: 100%;
56 box-sizing: border-box;
57 flex-direction: row;
58 align-items: center;
59 display: inline-flex;
60 padding-right: 6px;
61 padding-left: 6px;
62 color: white !important;
63 }
64
65 .comment-account {
66 word-break: break-all;
67 font-weight: 600;
68 font-size: 90%;
69
70 a {
71 @include disable-default-a-behaviour;
72
73 color: pvar(--mainForegroundColor);
74 }
75
76 .comment-account-fid {
77 opacity: .6;
78 }
79 }
80
81 .comment-date {
82 font-size: 90%;
83 color: pvar(--greyForegroundColor);
84 margin-left: 5px;
85 text-decoration: none;
86 }
87 }
88
89 .comment-html {
90 @include peertube-word-wrap;
91
92 // Mentions
93 ::ng-deep a {
94
95 &:not(.linkified-url) {
96 @include disable-default-a-behaviour;
97
98 color: pvar(--mainForegroundColor);
99
100 font-weight: $font-semibold;
101 }
102
103 }
104
105 // Paragraphs
106 ::ng-deep p {
107 margin-bottom: .3rem;
108 }
109
110 &.comment-html-deleted {
111 color: pvar(--greyForegroundColor);
112 margin-bottom: 1rem;
113 }
114 }
115
116 .comment-actions {
117 margin-bottom: 10px;
118 display: flex;
119
120 ::ng-deep .dropdown-toggle,
121 .comment-action-reply,
122 .comment-action-delete {
123 color: pvar(--greyForegroundColor);
124 cursor: pointer;
125 margin-right: 10px;
126
127 &:hover {
128 color: pvar(--mainForegroundColor);
129 }
130 }
131
132 ::ng-deep .action-button {
133 background-color: transparent;
134 padding: 0;
135 font-weight: unset;
136 }
137 }
138
139 my-video-comment-add {
140 ::ng-deep form {
141 margin-top: 1rem;
142 margin-bottom: 0;
143 }
144 }
145 }
146
147 .children {
148 // Reduce avatars size for replies
149 .comment-avatar {
150 @include avatar(25px);
151 }
152
153 .left {
154 margin-right: 6px;
155 }
156 }
157}
158
159@media screen and (max-width: 1200px) {
160 .children {
161 margin-left: -10px;
162 }
163}
164
165@media screen and (max-width: 600px) {
166 .root-comment {
167 .children {
168 margin-left: -20px;
169
170 .left {
171 align-items: flex-start;
172
173 .vertical-border {
174 margin-left: 2px;
175 }
176 }
177 }
178
179 .comment {
180 .comment-account-date {
181 flex-direction: column;
182
183 .comment-date {
184 margin-left: 0;
185 }
186 }
187 }
188 }
189}
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..27846c1ad
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts
@@ -0,0 +1,131 @@
1import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
2import { MarkdownService, Notifier, UserService } from '@app/core'
3import { AuthService } from '@app/core/auth'
4import { Account, Actor, Video } from '@app/shared/shared-main'
5import { User, UserRight } from '@shared/models'
6import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
7import { VideoComment } from './video-comment.model'
8
9@Component({
10 selector: 'my-video-comment',
11 templateUrl: './video-comment.component.html',
12 styleUrls: ['./video-comment.component.scss']
13})
14export class VideoCommentComponent implements OnInit, OnChanges {
15 @Input() video: Video
16 @Input() comment: VideoComment
17 @Input() parentComments: VideoComment[] = []
18 @Input() commentTree: VideoCommentThreadTree
19 @Input() inReplyToCommentId: number
20 @Input() highlightedComment = false
21 @Input() firstInThread = false
22
23 @Output() wantedToDelete = new EventEmitter<VideoComment>()
24 @Output() wantedToReply = new EventEmitter<VideoComment>()
25 @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
26 @Output() resetReply = new EventEmitter()
27 @Output() timestampClicked = new EventEmitter<number>()
28
29 sanitizedCommentHTML = ''
30 newParentComments: VideoComment[] = []
31
32 commentAccount: Account
33 commentUser: User
34
35 constructor (
36 private markdownService: MarkdownService,
37 private authService: AuthService,
38 private userService: UserService,
39 private notifier: Notifier
40 ) {}
41
42 get user () {
43 return this.authService.getUser()
44 }
45
46 ngOnInit () {
47 this.init()
48 }
49
50 ngOnChanges () {
51 this.init()
52 }
53
54 onCommentReplyCreated (createdComment: VideoComment) {
55 if (!this.commentTree) {
56 this.commentTree = {
57 comment: this.comment,
58 children: []
59 }
60
61 this.threadCreated.emit(this.commentTree)
62 }
63
64 this.commentTree.children.unshift({
65 comment: createdComment,
66 children: []
67 })
68 this.resetReply.emit()
69 }
70
71 onWantToReply (comment?: VideoComment) {
72 this.wantedToReply.emit(comment || this.comment)
73 }
74
75 onWantToDelete (comment?: VideoComment) {
76 this.wantedToDelete.emit(comment || this.comment)
77 }
78
79 isUserLoggedIn () {
80 return this.authService.isLoggedIn()
81 }
82
83 onResetReply () {
84 this.resetReply.emit()
85 }
86
87 handleTimestampClicked (timestamp: number) {
88 this.timestampClicked.emit(timestamp)
89 }
90
91 isRemovableByUser () {
92 return this.comment.account && this.isUserLoggedIn() &&
93 (
94 this.user.account.id === this.comment.account.id ||
95 this.user.account.id === this.video.account.id ||
96 this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
97 )
98 }
99
100 switchToDefaultAvatar ($event: Event) {
101 ($event.target as HTMLImageElement).src = Actor.GET_DEFAULT_AVATAR_URL()
102 }
103
104 private getUserIfNeeded (account: Account) {
105 if (!account.userId) return
106 if (!this.authService.isLoggedIn()) return
107
108 const user = this.authService.getUser()
109 if (user.hasRight(UserRight.MANAGE_USERS)) {
110 this.userService.getUserWithCache(account.userId)
111 .subscribe(
112 user => this.commentUser = user,
113
114 err => this.notifier.error(err.message)
115 )
116 }
117 }
118
119 private async init () {
120 const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true)
121 this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
122 this.newParentComments = this.parentComments.concat([ this.comment ])
123
124 if (this.comment.account) {
125 this.commentAccount = new Account(this.comment.account)
126 this.getUserIfNeeded(this.commentAccount)
127 } else {
128 this.comment.account = null
129 }
130 }
131}
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..e85443196
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.model.ts
@@ -0,0 +1,48 @@
1import { getAbsoluteAPIUrl } from '@app/helpers'
2import { Actor } from '@app/shared/shared-main'
3import { Account as AccountInterface, VideoComment as VideoCommentServerModel } from '@shared/models'
4
5export class VideoComment implements VideoCommentServerModel {
6 id: number
7 url: string
8 text: string
9 threadId: number
10 inReplyToCommentId: number
11 videoId: number
12 createdAt: Date | string
13 updatedAt: Date | string
14 deletedAt: Date | string
15 isDeleted: boolean
16 account: AccountInterface
17 totalRepliesFromVideoAuthor: number
18 totalReplies: number
19 by: string
20 accountAvatarUrl: string
21
22 isLocal: boolean
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.deletedAt = hash.deletedAt ? new Date(hash.deletedAt.toString()) : null
34 this.isDeleted = hash.isDeleted
35 this.account = hash.account
36 this.totalRepliesFromVideoAuthor = hash.totalRepliesFromVideoAuthor
37 this.totalReplies = hash.totalReplies
38
39 if (this.account) {
40 this.by = Actor.CREATE_BY_STRING(this.account.name, this.account.host)
41 this.accountAvatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.account)
42
43 const absoluteAPIUrl = getAbsoluteAPIUrl()
44 const thisHost = new URL(absoluteAPIUrl).host
45 this.isLocal = this.account.host.trim() === thisHost
46 }
47 }
48}
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..a73fb9ca8
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comment.service.ts
@@ -0,0 +1,149 @@
1import { Observable } from 'rxjs'
2import { catchError, map } from 'rxjs/operators'
3import { HttpClient, HttpParams } from '@angular/common/http'
4import { Injectable } from '@angular/core'
5import { ComponentPaginationLight, RestExtractor, RestService } from '@app/core'
6import { objectLineFeedToHtml } from '@app/helpers'
7import {
8 FeedFormat,
9 ResultList,
10 VideoComment as VideoCommentServerModel,
11 VideoCommentCreate,
12 VideoCommentThreadTree as VideoCommentThreadTreeServerModel
13} from '@shared/models'
14import { environment } from '../../../../environments/environment'
15import { VideoCommentThreadTree } from './video-comment-thread-tree.model'
16import { VideoComment } from './video-comment.model'
17
18@Injectable()
19export class VideoCommentService {
20 private static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/'
21 private static BASE_FEEDS_URL = environment.apiUrl + '/feeds/video-comments.'
22
23 constructor (
24 private authHttp: HttpClient,
25 private restExtractor: RestExtractor,
26 private restService: RestService
27 ) {}
28
29 addCommentThread (videoId: number | string, comment: VideoCommentCreate) {
30 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
31 const normalizedComment = objectLineFeedToHtml(comment, 'text')
32
33 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
34 .pipe(
35 map(data => this.extractVideoComment(data.comment)),
36 catchError(err => this.restExtractor.handleError(err))
37 )
38 }
39
40 addCommentReply (videoId: number | string, inReplyToCommentId: number, comment: VideoCommentCreate) {
41 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comments/' + inReplyToCommentId
42 const normalizedComment = objectLineFeedToHtml(comment, 'text')
43
44 return this.authHttp.post<{ comment: VideoCommentServerModel }>(url, normalizedComment)
45 .pipe(
46 map(data => this.extractVideoComment(data.comment)),
47 catchError(err => this.restExtractor.handleError(err))
48 )
49 }
50
51 getVideoCommentThreads (parameters: {
52 videoId: number | string,
53 componentPagination: ComponentPaginationLight,
54 sort: string
55 }): Observable<ResultList<VideoComment>> {
56 const { videoId, componentPagination, sort } = parameters
57
58 const pagination = this.restService.componentPaginationToRestPagination(componentPagination)
59
60 let params = new HttpParams()
61 params = this.restService.addRestGetParams(params, pagination, sort)
62
63 const url = VideoCommentService.BASE_VIDEO_URL + videoId + '/comment-threads'
64 return this.authHttp.get<ResultList<VideoComment>>(url, { params })
65 .pipe(
66 map(result => this.extractVideoComments(result)),
67 catchError(err => this.restExtractor.handleError(err))
68 )
69 }
70
71 getVideoThreadComments (parameters: {
72 videoId: number | string,
73 threadId: number
74 }): Observable<VideoCommentThreadTree> {
75 const { videoId, threadId } = parameters
76 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comment-threads/${threadId}`
77
78 return this.authHttp
79 .get<VideoCommentThreadTreeServerModel>(url)
80 .pipe(
81 map(tree => this.extractVideoCommentTree(tree)),
82 catchError(err => this.restExtractor.handleError(err))
83 )
84 }
85
86 deleteVideoComment (videoId: number | string, commentId: number) {
87 const url = `${VideoCommentService.BASE_VIDEO_URL + videoId}/comments/${commentId}`
88
89 return this.authHttp
90 .delete(url)
91 .pipe(
92 map(this.restExtractor.extractDataBool),
93 catchError(err => this.restExtractor.handleError(err))
94 )
95 }
96
97 getVideoCommentsFeeds (videoUUID?: string) {
98 const feeds = [
99 {
100 format: FeedFormat.RSS,
101 label: 'rss 2.0',
102 url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.RSS.toLowerCase()
103 },
104 {
105 format: FeedFormat.ATOM,
106 label: 'atom 1.0',
107 url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.ATOM.toLowerCase()
108 },
109 {
110 format: FeedFormat.JSON,
111 label: 'json 1.0',
112 url: VideoCommentService.BASE_FEEDS_URL + FeedFormat.JSON.toLowerCase()
113 }
114 ]
115
116 if (videoUUID !== undefined) {
117 for (const feed of feeds) {
118 feed.url += '?videoId=' + videoUUID
119 }
120 }
121
122 return feeds
123 }
124
125 private extractVideoComment (videoComment: VideoCommentServerModel) {
126 return new VideoComment(videoComment)
127 }
128
129 private extractVideoComments (result: ResultList<VideoCommentServerModel>) {
130 const videoCommentsJson = result.data
131 const totalComments = result.total
132 const comments: VideoComment[] = []
133
134 for (const videoCommentJson of videoCommentsJson) {
135 comments.push(new VideoComment(videoCommentJson))
136 }
137
138 return { data: comments, total: totalComments }
139 }
140
141 private extractVideoCommentTree (tree: VideoCommentThreadTreeServerModel) {
142 if (!tree) return tree as VideoCommentThreadTree
143
144 tree.comment = new VideoComment(tree.comment)
145 tree.children.forEach(c => this.extractVideoCommentTree(c))
146
147 return tree as VideoCommentThreadTree
148 }
149}
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..dd1d43560
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.html
@@ -0,0 +1,98 @@
1<div>
2 <div class="title-block">
3 <h2 class="title-page title-page-single">
4 <ng-container *ngIf="componentPagination.totalItems > 0; then hasComments; else noComments"></ng-container>
5 <ng-template #hasComments>
6 <ng-container i18n *ngIf="componentPagination.totalItems === 1; else manyComments">1 Comment</ng-container>
7 <ng-template i18n #manyComments>{{ componentPagination.totalItems }} Comments</ng-template>
8 </ng-template>
9 <ng-template i18n #noComments>Comments</ng-template>
10 </h2>
11
12 <my-feed [syndicationItems]="syndicationItems"></my-feed>
13
14 <div ngbDropdown class="d-inline-block ml-4">
15 <button class="btn btn-sm btn-outline-secondary" id="dropdown-sort-comments" ngbDropdownToggle i18n>
16 SORT BY
17 </button>
18 <div ngbDropdownMenu aria-labelledby="dropdown-sort-comments">
19 <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button>
20 <button (click)="handleSortChange('-totalReplies')" ngbDropdownItem i18n>Most replies first</button>
21 </div>
22 </div>
23 </div>
24
25 <ng-template [ngIf]="video.commentsEnabled === true">
26 <my-video-comment-add
27 [video]="video"
28 [user]="user"
29 (commentCreated)="onCommentThreadCreated($event)"
30 ></my-video-comment-add>
31
32 <div *ngIf="componentPagination.totalItems === 0 && comments.length === 0" i18n>No comments.</div>
33
34 <div
35 class="comment-threads"
36 myInfiniteScroller
37 [autoInit]="true"
38 (nearOfBottom)="onNearOfBottom()"
39 [dataObservable]="onDataSubject.asObservable()"
40 >
41 <div>
42 <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
43 <my-video-comment
44 *ngIf="highlightedThread"
45 [comment]="highlightedThread"
46 [video]="video"
47 [inReplyToCommentId]="inReplyToCommentId"
48 [commentTree]="threadComments[highlightedThread.id]"
49 [highlightedComment]="true"
50 [firstInThread]="true"
51 (wantedToReply)="onWantedToReply($event)"
52 (wantedToDelete)="onWantedToDelete($event)"
53 (threadCreated)="onThreadCreated($event)"
54 (resetReply)="onResetReply()"
55 (timestampClicked)="handleTimestampClicked($event)"
56 ></my-video-comment>
57 </div>
58
59 <div *ngFor="let comment of comments; index as i">
60 <my-video-comment
61 *ngIf="!highlightedThread || comment.id !== highlightedThread.id"
62 [comment]="comment"
63 [video]="video"
64 [inReplyToCommentId]="inReplyToCommentId"
65 [commentTree]="threadComments[comment.id]"
66 [firstInThread]="i + 1 !== comments.length"
67 (wantedToReply)="onWantedToReply($event)"
68 (wantedToDelete)="onWantedToDelete($event)"
69 (threadCreated)="onThreadCreated($event)"
70 (resetReply)="onResetReply()"
71 (timestampClicked)="handleTimestampClicked($event)"
72 >
73 <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment.id)" class="view-replies mb-2">
74 <span class="glyphicon glyphicon-menu-down"></span>
75
76 <ng-container *ngIf="comment.totalRepliesFromVideoAuthor > 0; then hasAuthorComments; else noAuthorComments"></ng-container>
77 <ng-template #hasAuthorComments>
78 <ng-container *ngIf="comment.totalReplies !== comment.totalRepliesFromVideoAuthor; else onlyAuthorComments" i18n>
79 View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }} and others
80 </ng-container>
81 <ng-template i18n #onlyAuthorComments>
82 View {{ comment.totalReplies }} replies from {{ video?.account?.displayName || 'the author' }}
83 </ng-template>
84 </ng-template>
85 <ng-template i18n #noAuthorComments>View {{ comment.totalReplies }} replies</ng-template>
86
87 <my-small-loader class="comment-thread-loading ml-1" [loading]="threadLoading[comment.id]"></my-small-loader>
88 </div>
89 </my-video-comment>
90
91 </div>
92 </div>
93 </ng-template>
94
95 <div *ngIf="video.commentsEnabled === false" i18n>
96 Comments are disabled.
97 </div>
98</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..df42fae73
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.scss
@@ -0,0 +1,53 @@
1@import '_variables';
2@import '_mixins';
3
4#highlighted-comment {
5 margin-bottom: 25px;
6}
7
8.view-replies {
9 font-weight: $font-semibold;
10 font-size: 15px;
11 cursor: pointer;
12}
13
14.glyphicon, .comment-thread-loading {
15 margin-right: 5px;
16 display: inline-block;
17 font-size: 13px;
18}
19
20.title-block {
21 .title-page {
22 margin-right: 0;
23 }
24
25 my-feed {
26 display: inline-block;
27 margin-left: 5px;
28 opacity: 0;
29 transition: ease-in .2s opacity;
30 }
31 &:hover my-feed {
32 opacity: 1;
33 }
34}
35
36#dropdown-sort-comments {
37 font-weight: 600;
38 text-transform: uppercase;
39 border: none;
40 transform: translateY(-7%);
41}
42
43@media screen and (max-width: 600px) {
44 .view-replies {
45 margin-left: 46px;
46 }
47}
48
49@media screen and (max-width: 450px) {
50 .view-replies {
51 font-size: 14px;
52 }
53}
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..df0018ec6
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts
@@ -0,0 +1,232 @@
1import { Subject, Subscription } from 'rxjs'
2import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'
3import { ActivatedRoute } from '@angular/router'
4import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core'
5import { HooksService } from '@app/core/plugins/hooks.service'
6import { Syndication, VideoDetails } from '@app/shared/shared-main'
7import { I18n } from '@ngx-translate/i18n-polyfill'
8import { VideoCommentThreadTree } from './video-comment-thread-tree.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, OnChanges, OnDestroy {
18 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
19 @Input() video: VideoDetails
20 @Input() user: User
21
22 @Output() timestampClicked = new EventEmitter<number>()
23
24 comments: VideoComment[] = []
25 highlightedThread: VideoComment
26 sort = '-createdAt'
27 componentPagination: ComponentPagination = {
28 currentPage: 1,
29 itemsPerPage: 10,
30 totalItems: null
31 }
32 inReplyToCommentId: number
33 threadComments: { [ id: number ]: VideoCommentThreadTree } = {}
34 threadLoading: { [ id: number ]: boolean } = {}
35
36 syndicationItems: Syndication[] = []
37
38 onDataSubject = new Subject<any[]>()
39
40 private sub: Subscription
41
42 constructor (
43 private authService: AuthService,
44 private notifier: Notifier,
45 private confirmService: ConfirmService,
46 private videoCommentService: VideoCommentService,
47 private activatedRoute: ActivatedRoute,
48 private i18n: I18n,
49 private hooks: HooksService
50 ) {}
51
52 ngOnInit () {
53 // Find highlighted comment in params
54 this.sub = this.activatedRoute.params.subscribe(
55 params => {
56 if (params['threadId']) {
57 const highlightedThreadId = +params['threadId']
58 this.processHighlightedThread(highlightedThreadId)
59 }
60 }
61 )
62 }
63
64 ngOnChanges (changes: SimpleChanges) {
65 if (changes['video']) {
66 this.resetVideo()
67 }
68 }
69
70 ngOnDestroy () {
71 if (this.sub) this.sub.unsubscribe()
72 }
73
74 viewReplies (commentId: number, highlightThread = false) {
75 this.threadLoading[commentId] = true
76
77 const params = {
78 videoId: this.video.id,
79 threadId: commentId
80 }
81
82 const obs = this.hooks.wrapObsFun(
83 this.videoCommentService.getVideoThreadComments.bind(this.videoCommentService),
84 params,
85 'video-watch',
86 'filter:api.video-watch.video-thread-replies.list.params',
87 'filter:api.video-watch.video-thread-replies.list.result'
88 )
89
90 obs.subscribe(
91 res => {
92 this.threadComments[commentId] = res
93 this.threadLoading[commentId] = false
94 this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res })
95
96 if (highlightThread) {
97 this.highlightedThread = new VideoComment(res.comment)
98
99 // Scroll to the highlighted thread
100 setTimeout(() => this.commentHighlightBlock.nativeElement.scrollIntoView(), 0)
101 }
102 },
103
104 err => this.notifier.error(err.message)
105 )
106 }
107
108 loadMoreThreads () {
109 const params = {
110 videoId: this.video.id,
111 componentPagination: this.componentPagination,
112 sort: this.sort
113 }
114
115 const obs = this.hooks.wrapObsFun(
116 this.videoCommentService.getVideoCommentThreads.bind(this.videoCommentService),
117 params,
118 'video-watch',
119 'filter:api.video-watch.video-threads.list.params',
120 'filter:api.video-watch.video-threads.list.result'
121 )
122
123 obs.subscribe(
124 res => {
125 this.comments = this.comments.concat(res.data)
126 this.componentPagination.totalItems = res.total
127
128 this.onDataSubject.next(res.data)
129 this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination })
130 },
131
132 err => this.notifier.error(err.message)
133 )
134 }
135
136 onCommentThreadCreated (comment: VideoComment) {
137 this.comments.unshift(comment)
138 }
139
140 onWantedToReply (comment: VideoComment) {
141 this.inReplyToCommentId = comment.id
142 }
143
144 onResetReply () {
145 this.inReplyToCommentId = undefined
146 }
147
148 onThreadCreated (commentTree: VideoCommentThreadTree) {
149 this.viewReplies(commentTree.comment.id)
150 }
151
152 handleSortChange (sort: string) {
153 if (this.sort === sort) return
154
155 this.sort = sort
156 this.resetVideo()
157 }
158
159 handleTimestampClicked (timestamp: number) {
160 this.timestampClicked.emit(timestamp)
161 }
162
163 async onWantedToDelete (commentToDelete: VideoComment) {
164 let message = 'Do you really want to delete this comment?'
165
166 if (commentToDelete.isLocal || this.video.isLocal) {
167 message += this.i18n(' The deletion will be sent to remote instances so they can reflect the change.')
168 } else {
169 message += this.i18n(' It is a remote comment, so the deletion will only be effective on your instance.')
170 }
171
172 const res = await this.confirmService.confirm(message, this.i18n('Delete'))
173 if (res === false) return
174
175 this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id)
176 .subscribe(
177 () => {
178 if (this.highlightedThread?.id === commentToDelete.id) {
179 commentToDelete = this.comments.find(c => c.id === commentToDelete.id)
180
181 this.highlightedThread = undefined
182 }
183
184 // Mark the comment as deleted
185 this.softDeleteComment(commentToDelete)
186 },
187
188 err => this.notifier.error(err.message)
189 )
190 }
191
192 isUserLoggedIn () {
193 return this.authService.isLoggedIn()
194 }
195
196 onNearOfBottom () {
197 if (hasMoreItems(this.componentPagination)) {
198 this.componentPagination.currentPage++
199 this.loadMoreThreads()
200 }
201 }
202
203 private softDeleteComment (comment: VideoComment) {
204 comment.isDeleted = true
205 comment.deletedAt = new Date()
206 comment.text = ''
207 comment.account = null
208 }
209
210 private resetVideo () {
211 if (this.video.commentsEnabled === true) {
212 // Reset all our fields
213 this.highlightedThread = null
214 this.comments = []
215 this.threadComments = {}
216 this.threadLoading = {}
217 this.inReplyToCommentId = undefined
218 this.componentPagination.currentPage = 1
219 this.componentPagination.totalItems = null
220
221 this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video.uuid)
222 this.loadMoreThreads()
223 }
224 }
225
226 private processHighlightedThread (highlightedThreadId: number) {
227 this.highlightedThread = this.comments.find(c => c.id === highlightedThreadId)
228
229 const highlightThread = true
230 this.viewReplies(highlightedThreadId, highlightThread)
231 }
232}