aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+videos/+video-watch/shared/comment
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2021-06-29 17:18:30 +0200
committerChocobozzz <me@florianbigard.com>2021-06-29 17:18:39 +0200
commit911186dae411d78788ccede093c251303187589a (patch)
tree967a07cd985ae4e2ea5249855726455fe929471d /client/src/app/+videos/+video-watch/shared/comment
parentb0c43e36dbdc2c964f6828a78b146faebfb75b21 (diff)
downloadPeerTube-911186dae411d78788ccede093c251303187589a.tar.gz
PeerTube-911186dae411d78788ccede093c251303187589a.tar.zst
PeerTube-911186dae411d78788ccede093c251303187589a.zip
Reorganize watch components
Diffstat (limited to 'client/src/app/+videos/+video-watch/shared/comment')
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/index.ts3
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.html96
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.scss119
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts220
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html94
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss199
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts208
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html100
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.scss60
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts261
10 files changed, 1360 insertions, 0 deletions
diff --git a/client/src/app/+videos/+video-watch/shared/comment/index.ts b/client/src/app/+videos/+video-watch/shared/comment/index.ts
new file mode 100644
index 000000000..2f2c69893
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/index.ts
@@ -0,0 +1,3 @@
1export * from './video-comment-add.component'
2export * from './video-comment.component'
3export * from './video-comments.component'
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.html
new file mode 100644
index 000000000..3ee818c8b
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.html
@@ -0,0 +1,96 @@
1<form novalidate [formGroup]="form" (ngSubmit)="formValidated()">
2 <div class="avatar-and-textarea">
3 <my-actor-avatar [account]="user?.account" size="25"></my-actor-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 </textarea>
12
13 <my-help
14 [ngClass]="{ 'is-rtl': isRTL() }" class="markdown-guide" helpType="custom" iconName="markdown"
15 tooltipPlacement="left auto" autoClose="true" i18n-title title="Markdown compatible"
16 >
17 <ng-template ptTemplate="customHtml">
18 <span i18n>Markdown compatible that supports:</span>
19
20 <ul>
21 <li><span i18n>Auto generated links</span></li>
22 <li><span i18n>Break lines</span></li>
23 <li><span i18n>Lists</span></li>
24 <li>
25 <span i18n>Emphasis</span>
26 <code>**<strong i18n>bold</strong>** _<i i18n>italic</i>_</code>
27 </li>
28 <li>
29 <span i18n>Emoji shortcuts</span>
30 <code>:) &lt;3</code>
31 </li>
32 <li>
33 <span i18n>Emoji markup</span>
34 <code>:smile:</code>
35 <div><a href="" (click)="openEmojiModal($event)" i18n>See complete list</a></div>
36 </li>
37 </ul>
38 </ng-template>
39 </my-help>
40 <div *ngIf="formErrors.text" class="form-error">
41 {{ formErrors.text }}
42 </div>
43 </div>
44 </div>
45
46 <div class="comment-buttons">
47 <button *ngIf="isAddButtonDisplayed()" class="peertube-button tertiary-button cancel-button" (click)="cancelCommentReply()" type="button" i18n>
48 Cancel
49 </button>
50
51 <button *ngIf="isAddButtonDisplayed()" class="peertube-button orange-button" [ngClass]="{ disabled: !form.valid || addingComment }">
52 {{ addingCommentButtonValue }}
53 </button>
54 </div>
55</form>
56
57<ng-template #visitorModal let-modal>
58 <div class="modal-header">
59 <h4 class="modal-title" id="modal-basic-title" i18n>You are one step away from commenting</h4>
60 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideModals()"></my-global-icon>
61 </div>
62
63 <div class="modal-body">
64 <span i18n>
65 You can comment using an account on any ActivityPub-compatible instance (PeerTube/Mastodon/Pleroma account for example).
66 </span>
67
68 <my-remote-subscribe [interact]="true" [uri]="getUri()"></my-remote-subscribe>
69 </div>
70
71 <div class="modal-footer inputs">
72 <input
73 type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
74 (click)="hideModals()" (key.enter)="hideModals()"
75 >
76
77 <input
78 type="submit" i18n-value value="Login to comment" class="peertube-button orange-button"
79 (click)="gotoLogin()"
80 >
81 </div>
82</ng-template>
83
84<ng-template #emojiModal>
85 <div class="modal-header">
86 <h4 class="modal-title" id="modal-basic-title" i18n>Markdown Emoji List</h4>
87 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hideModals()"></my-global-icon>
88 </div>
89 <div class="modal-body">
90 <div class="emoji-flex">
91 <div class="emoji-flex-item" *ngFor="let emojiMarkup of emojiMarkupList">
92 {{ emojiMarkup[0] }} <code>:{{ emojiMarkup[1] }}:</code>
93 </div>
94 </div>
95 </div>
96</ng-template>
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.scss b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.scss
new file mode 100644
index 000000000..fb79991db
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.scss
@@ -0,0 +1,119 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4$markdown-icon-height: 18px;
5$markdown-icon-width: 30px;
6$peertube-textarea-height: 60px;
7
8form {
9 margin-bottom: 30px;
10}
11
12.avatar-and-textarea {
13 display: flex;
14 margin-bottom: 10px;
15
16 my-actor-avatar {
17 @include margin-right(10px);
18 }
19
20 .form-group {
21 flex-grow: 1;
22 margin: 0;
23 position: relative;
24 }
25
26 textarea {
27 @include peertube-textarea(100%, $peertube-textarea-height);
28 @include button-focus(pvar(--mainColorLightest));
29 @include padding-right($markdown-icon-width + 15px !important);
30
31 min-height: calc(#{$peertube-textarea-height} - 15px * 2);
32
33 @media screen and (max-width: 600px) {
34 @include padding-right($markdown-icon-width + 19px !important);
35 }
36
37 &:focus::placeholder {
38 opacity: 0;
39 }
40 }
41}
42
43.markdown-guide {
44 position: absolute;
45 top: 5px;
46 right: 9px;
47
48 // inset-inline is not well supported by web browsers
49 &.is-rtl {
50 right: unset;
51 left: 9px;
52 }
53
54 ::ng-deep .help-tooltip-button {
55 my-global-icon {
56 height: $markdown-icon-height;
57 width: $markdown-icon-width;
58
59 svg {
60 color: #C6C6C6;
61 fill: #C6C6C6;
62 border-radius: 3px;
63 }
64 }
65
66 &:focus,
67 &:active,
68 &:hover {
69 my-global-icon svg {
70 background-color: #C6C6C6;
71 color: pvar(--mainBackgroundColor);
72 fill: pvar(--mainBackgroundColor);
73 }
74 }
75 }
76}
77
78.comment-buttons {
79 display: flex;
80 justify-content: flex-end;
81}
82
83.emoji-flex {
84 display: flex;
85 flex-flow: row wrap;
86 align-items: center;
87
88 .emoji-flex-item {
89 text-align: left;
90 margin: auto;
91 min-width: 227px;
92 flex: 1;
93
94 code {
95 @include margin-left(5px);
96
97 display: inline-block;
98 vertical-align: middle;
99 }
100 }
101}
102
103@media screen and (max-width: 600px) {
104 textarea,
105 .comment-buttons button {
106 font-size: 14px !important;
107 }
108
109 textarea {
110 padding: 5px !important;
111 }
112}
113
114.modal-body {
115 > span {
116 float: left;
117 margin-bottom: 20px;
118 }
119}
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
new file mode 100644
index 000000000..78efe1684
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
@@ -0,0 +1,220 @@
1import { Observable } from 'rxjs'
2import { getLocaleDirection } from '@angular/common'
3import {
4 Component,
5 ElementRef,
6 EventEmitter,
7 Inject,
8 Input,
9 LOCALE_ID,
10 OnChanges,
11 OnInit,
12 Output,
13 SimpleChanges,
14 ViewChild
15} from '@angular/core'
16import { Router } from '@angular/router'
17import { Notifier, User } from '@app/core'
18import { VIDEO_COMMENT_TEXT_VALIDATOR } from '@app/shared/form-validators/video-comment-validators'
19import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
20import { Video } from '@app/shared/shared-main'
21import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment'
22import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
23import { VideoCommentCreate } from '@shared/models'
24
25@Component({
26 selector: 'my-video-comment-add',
27 templateUrl: './video-comment-add.component.html',
28 styleUrls: ['./video-comment-add.component.scss']
29})
30export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit {
31 @Input() user: User
32 @Input() video: Video
33 @Input() parentComment?: VideoComment
34 @Input() parentComments?: VideoComment[]
35 @Input() focusOnInit = false
36 @Input() textValue?: string
37
38 @Output() commentCreated = new EventEmitter<VideoComment>()
39 @Output() cancel = new EventEmitter()
40
41 @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal
42 @ViewChild('emojiModal', { static: true }) emojiModal: NgbModal
43 @ViewChild('textarea', { static: true }) textareaElement: ElementRef
44
45 addingComment = false
46 addingCommentButtonValue: string
47
48 constructor (
49 protected formValidatorService: FormValidatorService,
50 private notifier: Notifier,
51 private videoCommentService: VideoCommentService,
52 private modalService: NgbModal,
53 private router: Router,
54 @Inject(LOCALE_ID) private localeId: string
55 ) {
56 super()
57 }
58
59 get emojiMarkupList () {
60 const emojiMarkupObjectList = require('markdown-it-emoji/lib/data/light.json')
61
62 // Populate emoji-markup-list from object to array to avoid keys alphabetical order
63 const emojiMarkupArrayList = []
64 for (const emojiMarkupName in emojiMarkupObjectList) {
65 if (emojiMarkupName) {
66 const emoji = emojiMarkupObjectList[emojiMarkupName]
67 emojiMarkupArrayList.push([emoji, emojiMarkupName])
68 }
69 }
70
71 return emojiMarkupArrayList
72 }
73
74 ngOnInit () {
75 this.buildForm({
76 text: VIDEO_COMMENT_TEXT_VALIDATOR
77 })
78
79 if (this.user) {
80 if (!this.parentComment) {
81 this.addingCommentButtonValue = $localize`Comment`
82 } else {
83 this.addingCommentButtonValue = $localize`Reply`
84 }
85
86 this.initTextValue()
87 }
88 }
89
90 ngOnChanges (changes: SimpleChanges) {
91 // Not initialized yet
92 if (!this.form) return
93
94 if (changes.textValue && changes.textValue.currentValue && changes.textValue.currentValue !== changes.textValue.previousValue) {
95 this.patchTextValue(changes.textValue.currentValue, true)
96 }
97 }
98
99 onValidKey () {
100 this.check()
101 if (!this.form.valid) return
102
103 this.formValidated()
104 }
105
106 openVisitorModal (event: any) {
107 if (this.user === null) { // we only open it for visitors
108 // fixing ng-bootstrap ModalService and the "Expression Changed After It Has Been Checked" Error
109 event.srcElement.blur()
110 event.preventDefault()
111
112 this.modalService.open(this.visitorModal)
113 }
114 }
115
116 openEmojiModal (event: any) {
117 event.preventDefault()
118 this.modalService.open(this.emojiModal, { backdrop: true, size: 'lg' })
119 }
120
121 hideModals () {
122 this.modalService.dismissAll()
123 }
124
125 formValidated () {
126 // If we validate very quickly the comment form, we might comment twice
127 if (this.addingComment) return
128
129 this.addingComment = true
130
131 const commentCreate: VideoCommentCreate = this.form.value
132 let obs: Observable<VideoComment>
133
134 if (this.parentComment) {
135 obs = this.addCommentReply(commentCreate)
136 } else {
137 obs = this.addCommentThread(commentCreate)
138 }
139
140 obs.subscribe(
141 comment => {
142 this.addingComment = false
143 this.commentCreated.emit(comment)
144 this.form.reset()
145 },
146
147 err => {
148 this.addingComment = false
149
150 this.notifier.error(err.text)
151 }
152 )
153 }
154
155 isAddButtonDisplayed () {
156 return this.form.value['text']
157 }
158
159 getUri () {
160 return window.location.href
161 }
162
163 gotoLogin () {
164 this.hideModals()
165 this.router.navigate([ '/login' ])
166 }
167
168 cancelCommentReply () {
169 this.cancel.emit(null)
170 this.form.value['text'] = this.textareaElement.nativeElement.value = ''
171 }
172
173 isRTL () {
174 return getLocaleDirection(this.localeId) === 'rtl'
175 }
176
177 private addCommentReply (commentCreate: VideoCommentCreate) {
178 return this.videoCommentService
179 .addCommentReply(this.video.id, this.parentComment.id, commentCreate)
180 }
181
182 private addCommentThread (commentCreate: VideoCommentCreate) {
183 return this.videoCommentService
184 .addCommentThread(this.video.id, commentCreate)
185 }
186
187 private initTextValue () {
188 if (this.textValue) {
189 this.patchTextValue(this.textValue, this.focusOnInit)
190 return
191 }
192
193 if (this.parentComment) {
194 const mentions = this.parentComments
195 .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves
196 .map(c => '@' + c.by)
197
198 const mentionsSet = new Set(mentions)
199 const mentionsText = Array.from(mentionsSet).join(' ') + ' '
200
201 this.patchTextValue(mentionsText, this.focusOnInit)
202 }
203 }
204
205 private patchTextValue (text: string, focus: boolean) {
206 setTimeout(() => {
207 if (focus) {
208 this.textareaElement.nativeElement.focus()
209 }
210
211 // Scroll to textarea
212 this.textareaElement.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
213
214 // Use the native textarea autosize according to the text's break lines
215 this.textareaElement.nativeElement.dispatchEvent(new Event('input'))
216 })
217
218 this.form.patchValue({ text })
219 }
220}
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
new file mode 100644
index 000000000..d8b944b35
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html
@@ -0,0 +1,94 @@
1<div *ngIf="isCommentDisplayed()" class="root-comment" [ngClass]="{ 'is-child': isChild() }">
2 <div class="left">
3 <my-actor-avatar *ngIf="!comment.isDeleted" [href]="comment.account.url" [account]="comment.account"></my-actor-avatar>
4 <div class="vertical-border"></div>
5 </div>
6
7 <div class="right" [ngClass]="{ 'mb-3': firstInThread }">
8 <div class="comment">
9 <ng-container *ngIf="!comment.isDeleted">
10 <div *ngIf="highlightedComment === true" class="highlighted-comment" i18n>Highlighted comment</div>
11
12 <div class="comment-account-date">
13 <div class="comment-account">
14 <a [routerLink]="[ '/a', comment.by ]">
15 <span class="comment-account-name" [ngClass]="{ 'video-author': video.account.id === comment.account.id }">
16 {{ comment.account.displayName }}
17 </span>
18
19 <span class="comment-account-fid ml-1">{{ comment.by }}</span>
20 </a>
21 </div>
22
23 <a [routerLink]="['/w', video.shortUUID, { 'threadId': comment.threadId }]" class="comment-date" [title]="comment.createdAt">
24 {{ comment.createdAt | myFromNow }}
25 </a>
26 </div>
27
28 <div
29 class="comment-html"
30 [innerHTML]="sanitizedCommentHTML"
31 (timestampClicked)="handleTimestampClicked($event)"
32 timestampRouteTransformer
33 ></div>
34
35 <div class="comment-actions">
36 <div tabindex=0 (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
37
38 <my-user-moderation-dropdown
39 [prependActions]="prependModerationActions" tabindex=0 [buttonStyled]="false"
40 buttonSize="small" [account]="commentAccount" [user]="commentUser" i18n-label label="Options" placement="bottom-left auto"
41 ></my-user-moderation-dropdown>
42 </div>
43 </ng-container>
44
45 <ng-container *ngIf="comment.isDeleted">
46 <div class="comment-account-date">
47 <span class="comment-account" i18n>Deleted</span>
48 <a [routerLink]="['/w', video.shortUUID, { 'threadId': comment.threadId }]"
49 class="comment-date">{{ comment.createdAt | myFromNow }}</a>
50 </div>
51
52 <div class="comment-html comment-html-deleted">
53 <i i18n>This comment has been deleted</i>
54 </div>
55 </ng-container>
56
57 <my-video-comment-add
58 *ngIf="!comment.isDeleted && inReplyToCommentId === comment.id"
59 [user]="user"
60 [video]="video"
61 [parentComment]="comment"
62 [parentComments]="newParentComments"
63 [focusOnInit]="true"
64 (commentCreated)="onCommentReplyCreated($event)"
65 (cancel)="onResetReply()"
66 [textValue]="redraftValue"
67 ></my-video-comment-add>
68
69 <div *ngIf="commentTree">
70 <div *ngFor="let commentChild of commentTree.children">
71 <my-video-comment
72 [comment]="commentChild.comment"
73 [video]="video"
74 [inReplyToCommentId]="inReplyToCommentId"
75 [commentTree]="commentChild"
76 [parentComments]="newParentComments"
77 (wantedToReply)="onWantToReply($event)"
78 (wantedToDelete)="onWantToDelete($event)"
79 (wantedToRedraft)="onWantToRedraft($event)"
80 (resetReply)="onResetReply()"
81 (timestampClicked)="handleTimestampClicked($event)"
82 [redraftValue]="redraftValue"
83 ></my-video-comment>
84 </div>
85 </div>
86
87 <ng-content></ng-content>
88 </div>
89 </div>
90</div>
91
92<ng-container *ngIf="prependModerationActions">
93 <my-comment-report #commentReportModal [comment]="comment"></my-comment-report>
94</ng-container>
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss
new file mode 100644
index 000000000..87e313d41
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss
@@ -0,0 +1,199 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.root-comment {
5 font-size: 15px;
6 display: flex;
7
8 .left {
9 @include margin-right(10px);
10
11 display: flex;
12 flex-direction: column;
13 align-items: center;
14
15 .vertical-border {
16 width: 2px;
17 height: 100%;
18 background-color: rgba(0, 0, 0, 0.05);
19 margin: 10px calc(1rem + 1px);
20 }
21 }
22
23 .right {
24 width: 100%;
25 }
26}
27
28my-actor-avatar {
29 @include actor-avatar-size(36px);
30}
31
32.comment {
33 flex-grow: 1;
34 // Fix word-wrap with flex
35 min-width: 1px;
36}
37
38.highlighted-comment {
39 display: inline-block;
40 background-color: #F5F5F5;
41 color: #3d3d3d;
42 padding: 0 5px;
43 font-size: 13px;
44 margin-bottom: 5px;
45 font-weight: $font-semibold;
46 border-radius: 3px;
47}
48
49.comment-account-date {
50 display: flex;
51 margin-bottom: 4px;
52}
53
54.video-author {
55 @include padding-right(6px);
56 @include padding-left(6px);
57
58 height: 20px;
59 background-color: #888888;
60 border-radius: 12px;
61 margin-bottom: 2px;
62 max-width: 100%;
63 box-sizing: border-box;
64 flex-direction: row;
65 align-items: center;
66 display: inline-flex;
67 color: #fff !important;
68}
69
70.comment-account {
71 word-break: break-all;
72 font-weight: 600;
73 font-size: 90%;
74
75 a {
76 @include disable-default-a-behaviour;
77
78 color: pvar(--mainForegroundColor);
79
80 &:hover {
81 text-decoration: underline;
82 }
83 }
84
85 .comment-account-fid {
86 opacity: .6;
87 }
88}
89
90.comment-date {
91 @include margin-left(5px);
92
93 font-size: 90%;
94 color: pvar(--greyForegroundColor);
95 text-decoration: none;
96
97 &:hover {
98 text-decoration: underline;
99 }
100}
101
102.comment-html {
103 @include peertube-word-wrap;
104
105 // Mentions
106 ::ng-deep a {
107
108 &:not(.linkified-url) {
109 @include disable-default-a-behaviour;
110
111 color: pvar(--mainForegroundColor);
112
113 font-weight: $font-semibold;
114 }
115
116 }
117
118 // Paragraphs
119 ::ng-deep p {
120 margin-bottom: .3rem;
121 }
122
123 &.comment-html-deleted {
124 color: pvar(--greyForegroundColor);
125 margin-bottom: 1rem;
126 }
127}
128
129.comment-actions {
130 margin-bottom: 10px;
131 display: flex;
132
133 ::ng-deep .dropdown-toggle,
134 .comment-action-reply {
135 @include margin-right(10px);
136
137 color: pvar(--greyForegroundColor);
138 cursor: pointer;
139
140 &:hover,
141 &:active,
142 &:focus,
143 &:focus-visible {
144 color: pvar(--mainForegroundColor);
145 }
146 }
147
148 ::ng-deep .action-button {
149 background-color: transparent;
150 padding: 0;
151 font-weight: unset;
152 }
153}
154
155my-video-comment-add {
156 ::ng-deep form {
157 margin-top: 1rem;
158 margin-bottom: 0;
159 }
160}
161
162.is-child {
163 // Reduce avatars size for replies
164 my-actor-avatar {
165 @include actor-avatar-size(25px);
166 }
167
168 .left {
169 @include margin-right(6px);
170 }
171}
172
173@media screen and (max-width: 1200px) {
174 .children {
175 @include margin-left(-10px);
176 }
177}
178
179@media screen and (max-width: 600px) {
180 .children {
181 @include margin-left(-20px);
182
183 .left {
184 align-items: flex-start;
185
186 .vertical-border {
187 @include margin-left(2px);
188 }
189 }
190 }
191
192 .comment-account-date {
193 flex-direction: column;
194
195 .comment-date {
196 @include margin-left(0);
197 }
198 }
199}
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
new file mode 100644
index 000000000..04f8f0d58
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts
@@ -0,0 +1,208 @@
1
2import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core'
3import { MarkdownService, Notifier, UserService } from '@app/core'
4import { AuthService } from '@app/core/auth'
5import { Account, DropdownAction, Video } from '@app/shared/shared-main'
6import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component'
7import { VideoComment, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
8import { User, UserRight } from '@shared/models'
9
10@Component({
11 selector: 'my-video-comment',
12 templateUrl: './video-comment.component.html',
13 styleUrls: ['./video-comment.component.scss']
14})
15export class VideoCommentComponent implements OnInit, OnChanges {
16 @ViewChild('commentReportModal') commentReportModal: CommentReportComponent
17
18 @Input() video: Video
19 @Input() comment: VideoComment
20 @Input() parentComments: VideoComment[] = []
21 @Input() commentTree: VideoCommentThreadTree
22 @Input() inReplyToCommentId: number
23 @Input() highlightedComment = false
24 @Input() firstInThread = false
25 @Input() redraftValue?: string
26
27 @Output() wantedToReply = new EventEmitter<VideoComment>()
28 @Output() wantedToDelete = new EventEmitter<VideoComment>()
29 @Output() wantedToRedraft = new EventEmitter<VideoComment>()
30 @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
31 @Output() resetReply = new EventEmitter()
32 @Output() timestampClicked = new EventEmitter<number>()
33
34 prependModerationActions: DropdownAction<any>[]
35
36 sanitizedCommentHTML = ''
37 newParentComments: VideoComment[] = []
38
39 commentAccount: Account
40 commentUser: User
41
42 constructor (
43 private markdownService: MarkdownService,
44 private authService: AuthService,
45 private userService: UserService,
46 private notifier: Notifier
47 ) {}
48
49 get user () {
50 return this.authService.getUser()
51 }
52
53 ngOnInit () {
54 this.init()
55 }
56
57 ngOnChanges () {
58 this.init()
59 }
60
61 onCommentReplyCreated (createdComment: VideoComment) {
62 if (!this.commentTree) {
63 this.commentTree = {
64 comment: this.comment,
65 hasDisplayedChildren: false,
66 children: []
67 }
68
69 this.threadCreated.emit(this.commentTree)
70 }
71
72 this.commentTree.children.unshift({
73 comment: createdComment,
74 hasDisplayedChildren: false,
75 children: []
76 })
77
78 this.resetReply.emit()
79
80 this.redraftValue = undefined
81 }
82
83 onWantToReply (comment?: VideoComment) {
84 this.wantedToReply.emit(comment || this.comment)
85 }
86
87 onWantToDelete (comment?: VideoComment) {
88 this.wantedToDelete.emit(comment || this.comment)
89 }
90
91 onWantToRedraft (comment?: VideoComment) {
92 this.wantedToRedraft.emit(comment || this.comment)
93 }
94
95 isUserLoggedIn () {
96 return this.authService.isLoggedIn()
97 }
98
99 onResetReply () {
100 this.resetReply.emit()
101 }
102
103 handleTimestampClicked (timestamp: number) {
104 this.timestampClicked.emit(timestamp)
105 }
106
107 isRemovableByUser () {
108 return this.comment.account && this.isUserLoggedIn() &&
109 (
110 this.user.account.id === this.comment.account.id ||
111 this.user.account.id === this.video.account.id ||
112 this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
113 )
114 }
115
116 isRedraftableByUser () {
117 return (
118 this.comment.account &&
119 this.isUserLoggedIn() &&
120 this.user.account.id === this.comment.account.id &&
121 this.comment.totalReplies === 0
122 )
123 }
124
125 isReportableByUser () {
126 return (
127 this.comment.account &&
128 this.isUserLoggedIn() &&
129 this.comment.isDeleted === false &&
130 this.user.account.id !== this.comment.account.id
131 )
132 }
133
134 isCommentDisplayed () {
135 // Not deleted
136 return !this.comment.isDeleted ||
137 this.comment.totalReplies !== 0 || // Or root comment thread has replies
138 (this.commentTree?.hasDisplayedChildren) // Or this is a reply that have other replies
139 }
140
141 isChild () {
142 return this.parentComments.length !== 0
143 }
144
145 private getUserIfNeeded (account: Account) {
146 if (!account.userId) return
147 if (!this.authService.isLoggedIn()) return
148
149 const user = this.authService.getUser()
150 if (user.hasRight(UserRight.MANAGE_USERS)) {
151 this.userService.getUserWithCache(account.userId)
152 .subscribe(
153 user => this.commentUser = user,
154
155 err => this.notifier.error(err.message)
156 )
157 }
158 }
159
160 private async init () {
161 // Before HTML rendering restore line feed for markdown list compatibility
162 const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
163 const html = await this.markdownService.textMarkdownToHTML(commentText, true, true)
164 this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html)
165 this.newParentComments = this.parentComments.concat([ this.comment ])
166
167 if (this.comment.account) {
168 this.commentAccount = new Account(this.comment.account)
169 this.getUserIfNeeded(this.commentAccount)
170 } else {
171 this.comment.account = null
172 }
173
174 this.prependModerationActions = []
175
176 if (this.isReportableByUser()) {
177 this.prependModerationActions.push({
178 label: $localize`Report this comment`,
179 iconName: 'flag',
180 handler: () => this.showReportModal()
181 })
182 }
183
184 if (this.isRemovableByUser()) {
185 this.prependModerationActions.push({
186 label: $localize`Remove`,
187 iconName: 'delete',
188 handler: () => this.onWantToDelete()
189 })
190 }
191
192 if (this.isRedraftableByUser()) {
193 this.prependModerationActions.push({
194 label: $localize`Remove & re-draft`,
195 iconName: 'edit',
196 handler: () => this.onWantToRedraft()
197 })
198 }
199
200 if (this.prependModerationActions.length === 0) {
201 this.prependModerationActions = undefined
202 }
203 }
204
205 private showReportModal () {
206 this.commentReportModal.show()
207 }
208}
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
new file mode 100644
index 000000000..9e6fde2e0
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html
@@ -0,0 +1,100 @@
1<div>
2 <div class="title-block">
3 <h2 class="title-page title-page-single">
4 {totalNotDeletedComments, plural, =0 {Comments} =1 {1 Comment} other {{{totalNotDeletedComments}} Comments}}
5 </h2>
6
7 <my-feed [syndicationItems]="syndicationItems"></my-feed>
8
9 <div ngbDropdown class="d-inline-block ml-4 dropdown-root">
10 <button class="btn btn-sm btn-outline-secondary" id="dropdown-sort-comments" ngbDropdownToggle i18n>
11 SORT BY
12 </button>
13 <div ngbDropdownMenu aria-labelledby="dropdown-sort-comments">
14 <button (click)="handleSortChange('-createdAt')" ngbDropdownItem i18n>Most recent first (default)</button>
15 <button (click)="handleSortChange('-totalReplies')" ngbDropdownItem i18n>Most replies first</button>
16 </div>
17 </div>
18 </div>
19
20 <ng-template [ngIf]="video.commentsEnabled === true">
21 <my-video-comment-add
22 [video]="video"
23 [user]="user"
24 (commentCreated)="onCommentThreadCreated($event)"
25 [textValue]="commentThreadRedraftValue"
26 ></my-video-comment-add>
27
28 <div *ngIf="totalNotDeletedComments === 0 && comments.length === 0" i18n>No comments.</div>
29
30 <div
31 class="comment-threads"
32 myInfiniteScroller
33 [autoInit]="true"
34 (nearOfBottom)="onNearOfBottom()"
35 [dataObservable]="onDataSubject.asObservable()"
36 >
37 <div>
38 <div class="anchor" #commentHighlightBlock id="highlighted-comment"></div>
39 <my-video-comment
40 *ngIf="highlightedThread"
41 [comment]="highlightedThread"
42 [video]="video"
43 [inReplyToCommentId]="inReplyToCommentId"
44 [commentTree]="threadComments[highlightedThread.id]"
45 [highlightedComment]="true"
46 [firstInThread]="true"
47 (wantedToReply)="onWantedToReply($event)"
48 (wantedToDelete)="onWantedToDelete($event)"
49 (wantedToRedraft)="onWantedToRedraft($event)"
50 (threadCreated)="onThreadCreated($event)"
51 (resetReply)="onResetReply()"
52 (timestampClicked)="handleTimestampClicked($event)"
53 [redraftValue]="commentReplyRedraftValue"
54 ></my-video-comment>
55 </div>
56
57 <div *ngFor="let comment of comments; index as i">
58 <my-video-comment
59 *ngIf="!highlightedThread || comment.id !== highlightedThread.id"
60 [comment]="comment"
61 [video]="video"
62 [inReplyToCommentId]="inReplyToCommentId"
63 [commentTree]="threadComments[comment.id]"
64 [firstInThread]="i + 1 !== comments.length"
65 (wantedToReply)="onWantedToReply($event)"
66 (wantedToDelete)="onWantedToDelete($event)"
67 (wantedToRedraft)="onWantedToRedraft($event)"
68 (threadCreated)="onThreadCreated($event)"
69 (resetReply)="onResetReply()"
70 (timestampClicked)="handleTimestampClicked($event)"
71 [redraftValue]="commentReplyRedraftValue"
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
78 <ng-template #hasAuthorComments>
79 <ng-container *ngIf="comment.totalReplies !== comment.totalRepliesFromVideoAuthor; else onlyAuthorComments" i18n>
80 View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}} from {{ video?.account?.displayName || 'the author' }} and others
81 </ng-container>
82 <ng-template i18n #onlyAuthorComments>
83 View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}} from {{ video?.account?.displayName || 'the author' }}
84 </ng-template>
85 </ng-template>
86
87 <ng-template i18n #noAuthorComments>View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}}</ng-template>
88
89 <my-small-loader class="comment-thread-loading ml-1" [loading]="threadLoading[comment.id]"></my-small-loader>
90 </div>
91 </my-video-comment>
92
93 </div>
94 </div>
95 </ng-template>
96
97 <div *ngIf="video.commentsEnabled === false" i18n>
98 Comments are disabled.
99 </div>
100</div>
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.scss b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.scss
new file mode 100644
index 000000000..31aa73937
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.scss
@@ -0,0 +1,60 @@
1@use '_variables' as *;
2@use '_mixins' as *;
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,
15.comment-thread-loading {
16 @include margin-right(5px);
17
18 display: inline-block;
19 font-size: 13px;
20}
21
22.title-block {
23 .title-page {
24 @include margin-right(0);
25 }
26
27 my-feed {
28 @include margin-left(5px);
29
30 display: inline-block;
31 opacity: 0;
32 transition: ease-in .2s opacity;
33 width: 12px;
34 position: relative;
35 top: -3px;
36 }
37
38 &:hover my-feed {
39 opacity: 1;
40 }
41}
42
43#dropdown-sort-comments {
44 font-weight: 600;
45 text-transform: uppercase;
46 border: 0;
47 transform: translateY(-7%);
48}
49
50@media screen and (max-width: 600px) {
51 .view-replies {
52 @include margin-left(46px);
53 }
54}
55
56@media screen and (max-width: 450px) {
57 .view-replies {
58 font-size: 14px;
59 }
60}
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
new file mode 100644
index 000000000..2c39e63fb
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts
@@ -0,0 +1,261 @@
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 { VideoComment, VideoCommentService, VideoCommentThreadTree } from '@app/shared/shared-video-comment'
8
9@Component({
10 selector: 'my-video-comments',
11 templateUrl: './video-comments.component.html',
12 styleUrls: ['./video-comments.component.scss']
13})
14export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
15 @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef
16 @Input() video: VideoDetails
17 @Input() user: User
18
19 @Output() timestampClicked = new EventEmitter<number>()
20
21 comments: VideoComment[] = []
22 highlightedThread: VideoComment
23
24 sort = '-createdAt'
25
26 componentPagination: ComponentPagination = {
27 currentPage: 1,
28 itemsPerPage: 10,
29 totalItems: null
30 }
31 totalNotDeletedComments: number
32
33 inReplyToCommentId: number
34 commentReplyRedraftValue: string
35 commentThreadRedraftValue: string
36
37 threadComments: { [ id: number ]: VideoCommentThreadTree } = {}
38 threadLoading: { [ id: number ]: boolean } = {}
39
40 syndicationItems: Syndication[] = []
41
42 onDataSubject = new Subject<any[]>()
43
44 private sub: Subscription
45
46 constructor (
47 private authService: AuthService,
48 private notifier: Notifier,
49 private confirmService: ConfirmService,
50 private videoCommentService: VideoCommentService,
51 private activatedRoute: ActivatedRoute,
52 private hooks: HooksService
53 ) {}
54
55 ngOnInit () {
56 // Find highlighted comment in params
57 this.sub = this.activatedRoute.params.subscribe(
58 params => {
59 if (params['threadId']) {
60 const highlightedThreadId = +params['threadId']
61 this.processHighlightedThread(highlightedThreadId)
62 }
63 }
64 )
65 }
66
67 ngOnChanges (changes: SimpleChanges) {
68 if (changes['video']) {
69 this.resetVideo()
70 }
71 }
72
73 ngOnDestroy () {
74 if (this.sub) this.sub.unsubscribe()
75 }
76
77 viewReplies (commentId: number, highlightThread = false) {
78 this.threadLoading[commentId] = true
79
80 const params = {
81 videoId: this.video.id,
82 threadId: commentId
83 }
84
85 const obs = this.hooks.wrapObsFun(
86 this.videoCommentService.getVideoThreadComments.bind(this.videoCommentService),
87 params,
88 'video-watch',
89 'filter:api.video-watch.video-thread-replies.list.params',
90 'filter:api.video-watch.video-thread-replies.list.result'
91 )
92
93 obs.subscribe(
94 res => {
95 this.threadComments[commentId] = res
96 this.threadLoading[commentId] = false
97 this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res })
98
99 if (highlightThread) {
100 this.highlightedThread = new VideoComment(res.comment)
101
102 // Scroll to the highlighted thread
103 setTimeout(() => this.commentHighlightBlock.nativeElement.scrollIntoView(), 0)
104 }
105 },
106
107 err => this.notifier.error(err.message)
108 )
109 }
110
111 loadMoreThreads () {
112 const params = {
113 videoId: this.video.id,
114 componentPagination: this.componentPagination,
115 sort: this.sort
116 }
117
118 const obs = this.hooks.wrapObsFun(
119 this.videoCommentService.getVideoCommentThreads.bind(this.videoCommentService),
120 params,
121 'video-watch',
122 'filter:api.video-watch.video-threads.list.params',
123 'filter:api.video-watch.video-threads.list.result'
124 )
125
126 obs.subscribe(
127 res => {
128 this.comments = this.comments.concat(res.data)
129 this.componentPagination.totalItems = res.total
130 this.totalNotDeletedComments = res.totalNotDeletedComments
131
132 this.onDataSubject.next(res.data)
133 this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination })
134 },
135
136 err => this.notifier.error(err.message)
137 )
138 }
139
140 onCommentThreadCreated (comment: VideoComment) {
141 this.comments.unshift(comment)
142 this.commentThreadRedraftValue = undefined
143 }
144
145 onWantedToReply (comment: VideoComment) {
146 this.inReplyToCommentId = comment.id
147 }
148
149 onResetReply () {
150 this.inReplyToCommentId = undefined
151 this.commentReplyRedraftValue = undefined
152 }
153
154 onThreadCreated (commentTree: VideoCommentThreadTree) {
155 this.viewReplies(commentTree.comment.id)
156 }
157
158 handleSortChange (sort: string) {
159 if (this.sort === sort) return
160
161 this.sort = sort
162 this.resetVideo()
163 }
164
165 handleTimestampClicked (timestamp: number) {
166 this.timestampClicked.emit(timestamp)
167 }
168
169 async onWantedToDelete (
170 commentToDelete: VideoComment,
171 title = $localize`Delete`,
172 message = $localize`Do you really want to delete this comment?`
173 ): Promise<boolean> {
174 if (commentToDelete.isLocal || this.video.isLocal) {
175 message += $localize` The deletion will be sent to remote instances so they can reflect the change.`
176 } else {
177 message += $localize` It is a remote comment, so the deletion will only be effective on your instance.`
178 }
179
180 const res = await this.confirmService.confirm(message, title)
181 if (res === false) return false
182
183 this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id)
184 .subscribe(
185 () => {
186 if (this.highlightedThread?.id === commentToDelete.id) {
187 commentToDelete = this.comments.find(c => c.id === commentToDelete.id)
188
189 this.highlightedThread = undefined
190 }
191
192 // Mark the comment as deleted
193 this.softDeleteComment(commentToDelete)
194 },
195
196 err => this.notifier.error(err.message)
197 )
198
199 return true
200 }
201
202 async onWantedToRedraft (commentToRedraft: VideoComment) {
203 const confirm = await this.onWantedToDelete(commentToRedraft, $localize`Delete and re-draft`, $localize`Do you really want to delete and re-draft this comment?`)
204
205 if (confirm) {
206 this.inReplyToCommentId = commentToRedraft.inReplyToCommentId
207
208 // Restore line feed for editing
209 const commentToRedraftText = commentToRedraft.text.replace(/<br.?\/?>/g, '\r\n')
210
211 if (commentToRedraft.threadId === commentToRedraft.id) {
212 this.commentThreadRedraftValue = commentToRedraftText
213 } else {
214 this.commentReplyRedraftValue = commentToRedraftText
215 }
216
217 }
218 }
219
220 isUserLoggedIn () {
221 return this.authService.isLoggedIn()
222 }
223
224 onNearOfBottom () {
225 if (hasMoreItems(this.componentPagination)) {
226 this.componentPagination.currentPage++
227 this.loadMoreThreads()
228 }
229 }
230
231 private softDeleteComment (comment: VideoComment) {
232 comment.isDeleted = true
233 comment.deletedAt = new Date()
234 comment.text = ''
235 comment.account = null
236 }
237
238 private resetVideo () {
239 if (this.video.commentsEnabled === true) {
240 // Reset all our fields
241 this.highlightedThread = null
242 this.comments = []
243 this.threadComments = {}
244 this.threadLoading = {}
245 this.inReplyToCommentId = undefined
246 this.componentPagination.currentPage = 1
247 this.componentPagination.totalItems = null
248 this.totalNotDeletedComments = null
249
250 this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video)
251 this.loadMoreThreads()
252 }
253 }
254
255 private processHighlightedThread (highlightedThreadId: number) {
256 this.highlightedThread = this.comments.find(c => c.id === highlightedThreadId)
257
258 const highlightThread = true
259 this.viewReplies(highlightedThreadId, highlightThread)
260 }
261}