aboutsummaryrefslogtreecommitdiffhomepage
path: root/client/src/app/+videos/+video-watch/shared
diff options
context:
space:
mode:
Diffstat (limited to 'client/src/app/+videos/+video-watch/shared')
-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
-rw-r--r--client/src/app/+videos/+video-watch/shared/index.ts5
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/index.ts3
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html11
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss42
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts26
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-description.component.html19
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-description.component.scss46
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-description.component.ts87
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.html23
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.scss15
-rw-r--r--client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.ts142
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/index.ts1
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html49
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss83
-rw-r--r--client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts229
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/index.ts5
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts79
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recommendation-info.model.ts4
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recommendations.module.ts35
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recommendations.service.ts7
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html26
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.scss68
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts95
-rw-r--r--client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.store.ts37
-rw-r--r--client/src/app/+videos/+video-watch/shared/timestamp-route-transformer.directive.ts39
35 files changed, 2536 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}
diff --git a/client/src/app/+videos/+video-watch/shared/index.ts b/client/src/app/+videos/+video-watch/shared/index.ts
new file mode 100644
index 000000000..a6c2d75ad
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/index.ts
@@ -0,0 +1,5 @@
1export * from './comment'
2export * from './metadata'
3export * from './playlist'
4export * from './recommendations'
5export * from './timestamp-route-transformer.directive'
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/index.ts b/client/src/app/+videos/+video-watch/shared/metadata/index.ts
new file mode 100644
index 000000000..ba97f7011
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/index.ts
@@ -0,0 +1,3 @@
1export * from './video-avatar-channel.component'
2export * from './video-description.component'
3export * from './video-rate.component'
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html
new file mode 100644
index 000000000..5a7221858
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html
@@ -0,0 +1,11 @@
1<div class="wrapper" [ngClass]="{ 'generic-channel': genericChannel }">
2 <my-actor-avatar
3 class="channel" [channel]="video.channel"
4 [internalHref]="[ '/c', video.byVideoChannel ]" [title]="channelLinkTitle"
5 ></my-actor-avatar>
6
7 <my-actor-avatar
8 class="account" [account]="video.account"
9 [internalHref]="[ '/a', video.byAccount ]" [title]="accountLinkTitle">
10 </my-actor-avatar>
11</div>
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss
new file mode 100644
index 000000000..1ff8fb96e
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss
@@ -0,0 +1,42 @@
1@use '_mixins' as *;
2
3@mixin main {
4 @include actor-avatar-size(35px);
5}
6
7@mixin secondary {
8 height: 60%;
9 width: 60%;
10 position: absolute;
11 bottom: -5px;
12 right: -5px;
13 background-color: rgba(0, 0, 0, 0);
14}
15
16.wrapper {
17 @include actor-avatar-size(35px);
18 @include margin-right(5px);
19
20 position: relative;
21 margin-bottom: 5px;
22
23 &.generic-channel {
24 .account {
25 @include main();
26 }
27
28 .channel {
29 display: none !important;
30 }
31 }
32
33 &:not(.generic-channel) {
34 .account {
35 @include secondary();
36 }
37
38 .channel {
39 @include main();
40 }
41 }
42}
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts
new file mode 100644
index 000000000..63edd7bad
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts
@@ -0,0 +1,26 @@
1import { Component, Input, OnInit } from '@angular/core'
2import { Video } from '@app/shared/shared-main/video'
3
4@Component({
5 selector: 'my-video-avatar-channel',
6 templateUrl: './video-avatar-channel.component.html',
7 styleUrls: [ './video-avatar-channel.component.scss' ]
8})
9export class VideoAvatarChannelComponent implements OnInit {
10 @Input() video: Video
11 @Input() byAccount: string
12
13 @Input() genericChannel: boolean
14
15 channelLinkTitle = ''
16 accountLinkTitle = ''
17
18 ngOnInit () {
19 this.channelLinkTitle = $localize`${this.video.account.name} (channel page)`
20 this.accountLinkTitle = $localize`${this.video.byAccount} (account page)`
21 }
22
23 isChannelAvatarNull () {
24 return this.video.channel.avatar === null
25 }
26}
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.html b/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.html
new file mode 100644
index 000000000..57f682899
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.html
@@ -0,0 +1,19 @@
1<div class="video-info-description">
2 <div
3 class="video-info-description-html"
4 [innerHTML]="videoHTMLDescription"
5 (timestampClicked)="onTimestampClicked($event)"
6 timestampRouteTransformer
7 ></div>
8
9 <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()">
10 <ng-container i18n>Show more</ng-container>
11 <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-down"></span>
12 <my-small-loader class="description-loading" [loading]="descriptionLoading"></my-small-loader>
13 </div>
14
15 <div *ngIf="completeDescriptionShown === true" (click)="showLessDescription()" class="video-info-description-more">
16 <ng-container i18n>Show less</ng-container>
17 <span *ngIf="descriptionLoading === false" class="glyphicon glyphicon-menu-up"></span>
18 </div>
19</div>
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.scss b/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.scss
new file mode 100644
index 000000000..fc8b4574c
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.scss
@@ -0,0 +1,46 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.video-info-description {
5 @include margin-left($video-watch-info-margin-left);
6 @include margin-right(0);
7
8 margin-top: 20px;
9 margin-bottom: 20px;
10 font-size: 15px;
11
12 .video-info-description-html {
13 @include peertube-word-wrap;
14
15 ::ng-deep a {
16 text-decoration: none;
17 }
18 }
19
20 .glyphicon,
21 .description-loading {
22 @include margin-left(3px);
23 }
24
25 .description-loading {
26 display: inline-block;
27 }
28
29 .video-info-description-more {
30 cursor: pointer;
31 font-weight: $font-semibold;
32 color: pvar(--greyForegroundColor);
33 font-size: 14px;
34
35 .glyphicon {
36 position: relative;
37 top: 2px;
38 }
39 }
40}
41
42@media screen and (max-width: 450px) {
43 .video-info-description {
44 font-size: 14px !important;
45 }
46}
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.ts b/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.ts
new file mode 100644
index 000000000..2ea3b206f
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-description.component.ts
@@ -0,0 +1,87 @@
1import { Component, EventEmitter, Inject, Input, LOCALE_ID, OnChanges, Output } from '@angular/core'
2import { MarkdownService, Notifier } from '@app/core'
3import { VideoDetails, VideoService } from '@app/shared/shared-main'
4
5
6@Component({
7 selector: 'my-video-description',
8 templateUrl: './video-description.component.html',
9 styleUrls: [ './video-description.component.scss' ]
10})
11export class VideoDescriptionComponent implements OnChanges {
12 @Input() video: VideoDetails
13
14 @Output() timestampClicked = new EventEmitter<number>()
15
16 descriptionLoading = false
17 completeDescriptionShown = false
18 completeVideoDescription: string
19 shortVideoDescription: string
20 videoHTMLDescription = ''
21
22 constructor (
23 private videoService: VideoService,
24 private notifier: Notifier,
25 private markdownService: MarkdownService,
26 @Inject(LOCALE_ID) private localeId: string
27 ) { }
28
29 ngOnChanges () {
30 this.descriptionLoading = false
31 this.completeDescriptionShown = false
32 this.completeVideoDescription = undefined
33
34 this.setVideoDescriptionHTML()
35 }
36
37 showMoreDescription () {
38 if (this.completeVideoDescription === undefined) {
39 return this.loadCompleteDescription()
40 }
41
42 this.updateVideoDescription(this.completeVideoDescription)
43 this.completeDescriptionShown = true
44 }
45
46 showLessDescription () {
47 this.updateVideoDescription(this.shortVideoDescription)
48 this.completeDescriptionShown = false
49 }
50
51 loadCompleteDescription () {
52 this.descriptionLoading = true
53
54 this.videoService.loadCompleteDescription(this.video.descriptionPath)
55 .subscribe(
56 description => {
57 this.completeDescriptionShown = true
58 this.descriptionLoading = false
59
60 this.shortVideoDescription = this.video.description
61 this.completeVideoDescription = description
62
63 this.updateVideoDescription(this.completeVideoDescription)
64 },
65
66 error => {
67 this.descriptionLoading = false
68 this.notifier.error(error.message)
69 }
70 )
71 }
72
73 onTimestampClicked (timestamp: number) {
74 this.timestampClicked.emit(timestamp)
75 }
76
77 private updateVideoDescription (description: string) {
78 this.video.description = description
79 this.setVideoDescriptionHTML()
80 .catch(err => console.error(err))
81 }
82
83 private async setVideoDescriptionHTML () {
84 const html = await this.markdownService.textMarkdownToHTML(this.video.description)
85 this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html)
86 }
87}
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.html b/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.html
new file mode 100644
index 000000000..7dd9b3678
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.html
@@ -0,0 +1,23 @@
1<ng-template #ratePopoverText>
2 <span [innerHTML]="getRatePopoverText()"></span>
3</ng-template>
4
5<button
6 [ngbPopover]="getRatePopoverText() && ratePopoverText" [ngClass]="{ 'activated': userRating === 'like' }" (click)="setLike()" (keyup.enter)="setLike()"
7 class="action-button action-button-like" [attr.aria-pressed]="userRating === 'like'" [attr.aria-label]="tooltipLike"
8 [ngbTooltip]="tooltipLike"
9 placement="bottom auto"
10>
11 <my-global-icon iconName="like"></my-global-icon>
12 <span *ngIf="video.likes" class="count">{{ video.likes }}</span>
13</button>
14
15<button
16 [ngbPopover]="getRatePopoverText() && ratePopoverText" [ngClass]="{ 'activated': userRating === 'dislike' }" (click)="setDislike()" (keyup.enter)="setDislike()"
17 class="action-button action-button-dislike" [attr.aria-pressed]="userRating === 'dislike'" [attr.aria-label]="tooltipDislike"
18 [ngbTooltip]="tooltipDislike"
19 placement="bottom auto"
20>
21 <my-global-icon iconName="dislike"></my-global-icon>
22 <span *ngIf="video.dislikes" class="count">{{ video.dislikes }}</span>
23</button>
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.scss b/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.scss
new file mode 100644
index 000000000..f4f696f33
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.scss
@@ -0,0 +1,15 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.action-button-like,
5.action-button-dislike {
6 filter: brightness(120%);
7
8 .count {
9 margin: 0 5px;
10 }
11}
12
13.activated {
14 color: pvar(--activatedActionButtonColor) !important;
15}
diff --git a/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.ts b/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.ts
new file mode 100644
index 000000000..89a666a62
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.ts
@@ -0,0 +1,142 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { Observable } from 'rxjs'
3import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'
4import { Notifier, ScreenService } from '@app/core'
5import { VideoDetails, VideoService } from '@app/shared/shared-main'
6import { UserVideoRateType } from '@shared/models'
7
8@Component({
9 selector: 'my-video-rate',
10 templateUrl: './video-rate.component.html',
11 styleUrls: [ './video-rate.component.scss' ]
12})
13export class VideoRateComponent implements OnInit, OnChanges, OnDestroy {
14 @Input() video: VideoDetails
15 @Input() isUserLoggedIn: boolean
16
17 @Output() userRatingLoaded = new EventEmitter<UserVideoRateType>()
18 @Output() rateUpdated = new EventEmitter<UserVideoRateType>()
19
20 userRating: UserVideoRateType
21
22 tooltipLike = ''
23 tooltipDislike = ''
24
25 private hotkeys: Hotkey[]
26
27 constructor (
28 private videoService: VideoService,
29 private notifier: Notifier,
30 private hotkeysService: HotkeysService,
31 private screenService: ScreenService
32 ) { }
33
34 async ngOnInit () {
35 // Hide the tooltips for unlogged users in mobile view, this adds confusion with the popover
36 if (this.isUserLoggedIn || !this.screenService.isInMobileView()) {
37 this.tooltipLike = $localize`Like this video`
38 this.tooltipDislike = $localize`Dislike this video`
39 }
40
41 if (this.isUserLoggedIn) {
42 this.hotkeys = [
43 new Hotkey('shift+l', () => {
44 this.setLike()
45 return false
46 }, undefined, $localize`Like the video`),
47
48 new Hotkey('shift+d', () => {
49 this.setDislike()
50 return false
51 }, undefined, $localize`Dislike the video`)
52 ]
53
54 this.hotkeysService.add(this.hotkeys)
55 }
56 }
57
58 ngOnChanges () {
59 this.checkUserRating()
60 }
61
62 ngOnDestroy () {
63 this.hotkeysService.remove(this.hotkeys)
64 }
65
66 setLike () {
67 if (this.isUserLoggedIn === false) return
68
69 // Already liked this video
70 if (this.userRating === 'like') this.setRating('none')
71 else this.setRating('like')
72 }
73
74 setDislike () {
75 if (this.isUserLoggedIn === false) return
76
77 // Already disliked this video
78 if (this.userRating === 'dislike') this.setRating('none')
79 else this.setRating('dislike')
80 }
81
82 getRatePopoverText () {
83 if (this.isUserLoggedIn) return undefined
84
85 return $localize`You need to be <a href="/login">logged in</a> to rate this video.`
86 }
87
88 private checkUserRating () {
89 // Unlogged users do not have ratings
90 if (this.isUserLoggedIn === false) return
91
92 this.videoService.getUserVideoRating(this.video.id)
93 .subscribe(
94 ratingObject => {
95 if (!ratingObject) return
96
97 this.userRating = ratingObject.rating
98 this.userRatingLoaded.emit(this.userRating)
99 },
100
101 err => this.notifier.error(err.message)
102 )
103 }
104
105 private setRating (nextRating: UserVideoRateType) {
106 const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable<any> } = {
107 like: this.videoService.setVideoLike,
108 dislike: this.videoService.setVideoDislike,
109 none: this.videoService.unsetVideoLike
110 }
111
112 ratingMethods[nextRating].call(this.videoService, this.video.id)
113 .subscribe(
114 () => {
115 // Update the video like attribute
116 this.updateVideoRating(this.userRating, nextRating)
117 this.userRating = nextRating
118 this.rateUpdated.emit(this.userRating)
119 },
120
121 (err: { message: string }) => this.notifier.error(err.message)
122 )
123 }
124
125 private updateVideoRating (oldRating: UserVideoRateType, newRating: UserVideoRateType) {
126 let likesToIncrement = 0
127 let dislikesToIncrement = 0
128
129 if (oldRating) {
130 if (oldRating === 'like') likesToIncrement--
131 if (oldRating === 'dislike') dislikesToIncrement--
132 }
133
134 if (newRating === 'like') likesToIncrement++
135 if (newRating === 'dislike') dislikesToIncrement++
136
137 this.video.likes += likesToIncrement
138 this.video.dislikes += dislikesToIncrement
139
140 this.video.buildLikeAndDislikePercents()
141 }
142}
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/index.ts b/client/src/app/+videos/+video-watch/shared/playlist/index.ts
new file mode 100644
index 000000000..539705508
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/playlist/index.ts
@@ -0,0 +1 @@
export * from './video-watch-playlist.component'
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html
new file mode 100644
index 000000000..c270142a3
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html
@@ -0,0 +1,49 @@
1<div
2 *ngIf="playlist && currentPlaylistPosition" class="playlist"
3 myInfiniteScroller [autoInit]="true" [onItself]="true" (nearOfBottom)="onPlaylistVideosNearOfBottom()"
4>
5 <div class="playlist-info">
6 <div class="playlist-display-name">
7 {{ playlist.displayName }}
8
9 <span *ngIf="isUnlistedPlaylist()" class="badge badge-warning" i18n>Unlisted</span>
10 <span *ngIf="isPrivatePlaylist()" class="badge badge-danger" i18n>Private</span>
11 <span *ngIf="isPublicPlaylist()" class="badge badge-info" i18n>Public</span>
12 </div>
13
14 <div class="playlist-by-index">
15 <div class="playlist-by">{{ playlist.ownerBy }}</div>
16 <div class="playlist-index">
17 <span>{{ currentPlaylistPosition }}</span><span>{{ playlistPagination.totalItems }}</span>
18 </div>
19 </div>
20
21 <div class="playlist-controls">
22 <my-global-icon
23 iconName="videos"
24 [class.active]="autoPlayNextVideoPlaylist"
25 (click)="switchAutoPlayNextVideoPlaylist()"
26 [ngbTooltip]="autoPlayNextVideoPlaylistSwitchText"
27 placement="bottom auto"
28 container="body"
29 ></my-global-icon>
30
31 <my-global-icon
32 iconName="repeat"
33 [class.active]="loopPlaylist"
34 (click)="switchLoopPlaylist()"
35 [ngbTooltip]="loopPlaylistSwitchText"
36 placement="bottom auto"
37 container="body"
38 ></my-global-icon>
39 </div>
40 </div>
41
42 <div *ngFor="let playlistElement of playlistElements" [ngClass]="'element-' + playlistElement.position">
43 <my-video-playlist-element-miniature
44 [playlistElement]="playlistElement" [playlist]="playlist" [owned]="isPlaylistOwned()" (elementRemoved)="onElementRemoved($event)"
45 [playing]="currentPlaylistPosition === playlistElement.position" [accountLink]="false" [position]="playlistElement.position"
46 [touchScreenEditButton]="true"
47 ></my-video-playlist-element-miniature>
48 </div>
49</div>
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss
new file mode 100644
index 000000000..75ed9d901
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss
@@ -0,0 +1,83 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3@use '_bootstrap-variables';
4@use '_miniature' as *;
5
6.playlist {
7 min-width: 200px;
8 max-width: 470px;
9 height: 66vh;
10 background-color: pvar(--mainBackgroundColor);
11 overflow-y: auto;
12 border-bottom: 1px solid $separator-border-color;
13
14 .playlist-info {
15 padding: 5px 30px;
16 background-color: #e4e4e4;
17
18 .playlist-display-name {
19 font-size: 18px;
20 font-weight: $font-semibold;
21 margin-bottom: 5px;
22 }
23
24 .playlist-by-index {
25 color: pvar(--greyForegroundColor);
26 display: flex;
27
28 .playlist-by {
29 @include margin-right(5px);
30 }
31
32 .playlist-index span:first-child::after {
33 content: '/';
34 margin: 0 3px;
35 }
36 }
37
38 .playlist-controls {
39 display: flex;
40 margin: 10px 0;
41
42 my-global-icon:not(:last-child) {
43 @include margin-right(.5rem);
44 }
45
46 my-global-icon {
47 &:not(.active) {
48 opacity: .5;
49 }
50
51 ::ng-deep {
52 cursor: pointer;
53 }
54 }
55 }
56 }
57
58 my-video-playlist-element-miniature {
59 ::ng-deep {
60 .video {
61 .position {
62 @include margin-right(0);
63 }
64
65 .video-info {
66 .video-info-name {
67 font-size: 15px;
68 }
69 }
70 }
71
72 my-video-thumbnail {
73 @include thumbnail-size-component(90px, 50px);
74 }
75
76 .fake-thumbnail {
77 width: 90px;
78 height: 50px;
79 }
80 }
81 }
82}
83
diff --git a/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
new file mode 100644
index 000000000..0a4d6bfd1
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts
@@ -0,0 +1,229 @@
1
2import { Component, EventEmitter, Input, Output } from '@angular/core'
3import { Router } from '@angular/router'
4import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core'
5import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist'
6import { peertubeLocalStorage, peertubeSessionStorage } from '@root-helpers/peertube-web-storage'
7import { VideoPlaylistPrivacy } from '@shared/models'
8
9@Component({
10 selector: 'my-video-watch-playlist',
11 templateUrl: './video-watch-playlist.component.html',
12 styleUrls: [ './video-watch-playlist.component.scss' ]
13})
14export class VideoWatchPlaylistComponent {
15 static LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'auto_play_video_playlist'
16 static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'loop_playlist'
17
18 @Input() playlist: VideoPlaylist
19
20 @Output() videoFound = new EventEmitter<string>()
21
22 playlistElements: VideoPlaylistElement[] = []
23 playlistPagination: ComponentPagination = {
24 currentPage: 1,
25 itemsPerPage: 30,
26 totalItems: null
27 }
28
29 autoPlayNextVideoPlaylist: boolean
30 autoPlayNextVideoPlaylistSwitchText = ''
31 loopPlaylist: boolean
32 loopPlaylistSwitchText = ''
33 noPlaylistVideos = false
34
35 currentPlaylistPosition: number
36
37 constructor (
38 private userService: UserService,
39 private auth: AuthService,
40 private notifier: Notifier,
41 private videoPlaylist: VideoPlaylistService,
42 private localStorageService: LocalStorageService,
43 private sessionStorageService: SessionStorageService,
44 private router: Router
45 ) {
46 // defaults to true
47 this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn()
48 ? this.auth.getUser().autoPlayNextVideoPlaylist
49 : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false'
50
51 this.setAutoPlayNextVideoPlaylistSwitchText()
52
53 // defaults to false
54 this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true'
55 this.setLoopPlaylistSwitchText()
56 }
57
58 onPlaylistVideosNearOfBottom (position?: number) {
59 // Last page
60 if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return
61
62 this.playlistPagination.currentPage += 1
63 this.loadPlaylistElements(this.playlist, false, position)
64 }
65
66 onElementRemoved (playlistElement: VideoPlaylistElement) {
67 this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id)
68
69 this.playlistPagination.totalItems--
70 }
71
72 isPlaylistOwned () {
73 return this.playlist.isLocal === true &&
74 this.auth.isLoggedIn() &&
75 this.playlist.ownerAccount.name === this.auth.getUser().username
76 }
77
78 isUnlistedPlaylist () {
79 return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED
80 }
81
82 isPrivatePlaylist () {
83 return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE
84 }
85
86 isPublicPlaylist () {
87 return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC
88 }
89
90 loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false, position?: number) {
91 this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination)
92 .subscribe(({ total, data }) => {
93 this.playlistElements = this.playlistElements.concat(data)
94 this.playlistPagination.totalItems = total
95
96 const firstAvailableVideo = this.playlistElements.find(e => !!e.video)
97 if (!firstAvailableVideo) {
98 this.noPlaylistVideos = true
99 return
100 }
101
102 if (position) this.updatePlaylistIndex(position)
103
104 if (redirectToFirst) {
105 const extras = {
106 queryParams: {
107 start: firstAvailableVideo.startTimestamp,
108 stop: firstAvailableVideo.stopTimestamp,
109 playlistPosition: firstAvailableVideo.position
110 },
111 replaceUrl: true
112 }
113 this.router.navigate([], extras)
114 }
115 })
116 }
117
118 updatePlaylistIndex (position: number) {
119 if (this.playlistElements.length === 0 || !position) return
120
121 // Handle the reverse index
122 if (position < 0) position = this.playlist.videosLength + position + 1
123
124 for (const playlistElement of this.playlistElements) {
125 // >= if the previous videos were not valid
126 if (playlistElement.video && playlistElement.position >= position) {
127 this.currentPlaylistPosition = playlistElement.position
128
129 this.videoFound.emit(playlistElement.video.uuid)
130
131 setTimeout(() => {
132 document.querySelector('.element-' + this.currentPlaylistPosition).scrollIntoView(false)
133 }, 0)
134
135 return
136 }
137 }
138
139 // Load more videos to find our video
140 this.onPlaylistVideosNearOfBottom(position)
141 }
142
143 navigateToPreviousPlaylistVideo () {
144 const previous = this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous')
145 if (!previous) return
146
147 const start = previous.startTimestamp
148 const stop = previous.stopTimestamp
149 this.router.navigate([],{ queryParams: { playlistPosition: previous.position, start, stop } })
150 }
151
152 findPlaylistVideo (position: number, type: 'previous' | 'next'): VideoPlaylistElement {
153 if (
154 (type === 'next' && position > this.playlistPagination.totalItems) ||
155 (type === 'previous' && position < 1)
156 ) {
157 // End of the playlist: end the recursion if we're not in the loop mode
158 if (!this.loopPlaylist) return
159
160 // Loop mode
161 position = type === 'previous'
162 ? this.playlistPagination.totalItems
163 : 1
164 }
165
166 const found = this.playlistElements.find(e => e.position === position)
167 if (found && found.video) return found
168
169 const newPosition = type === 'previous'
170 ? position - 1
171 : position + 1
172
173 return this.findPlaylistVideo(newPosition, type)
174 }
175
176 navigateToNextPlaylistVideo () {
177 const next = this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next')
178 if (!next) return
179
180 const start = next.startTimestamp
181 const stop = next.stopTimestamp
182 this.router.navigate([],{ queryParams: { playlistPosition: next.position, start, stop } })
183 }
184
185 switchAutoPlayNextVideoPlaylist () {
186 this.autoPlayNextVideoPlaylist = !this.autoPlayNextVideoPlaylist
187 this.setAutoPlayNextVideoPlaylistSwitchText()
188
189 peertubeLocalStorage.setItem(
190 VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST,
191 this.autoPlayNextVideoPlaylist.toString()
192 )
193
194 if (this.auth.isLoggedIn()) {
195 const details = {
196 autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist
197 }
198
199 this.userService.updateMyProfile(details).subscribe(
200 () => {
201 this.auth.refreshUserInformation()
202 },
203 err => this.notifier.error(err.message)
204 )
205 }
206 }
207
208 switchLoopPlaylist () {
209 this.loopPlaylist = !this.loopPlaylist
210 this.setLoopPlaylistSwitchText()
211
212 peertubeSessionStorage.setItem(
213 VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST,
214 this.loopPlaylist.toString()
215 )
216 }
217
218 private setAutoPlayNextVideoPlaylistSwitchText () {
219 this.autoPlayNextVideoPlaylistSwitchText = this.autoPlayNextVideoPlaylist
220 ? $localize`Stop autoplaying next video`
221 : $localize`Autoplay next video`
222 }
223
224 private setLoopPlaylistSwitchText () {
225 this.loopPlaylistSwitchText = this.loopPlaylist
226 ? $localize`Stop looping playlist videos`
227 : $localize`Loop playlist videos`
228 }
229}
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/index.ts b/client/src/app/+videos/+video-watch/shared/recommendations/index.ts
new file mode 100644
index 000000000..ffcf84585
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/index.ts
@@ -0,0 +1,5 @@
1export * from './recent-videos-recommendation.service'
2export * from './recommendation-info.model'
3export * from './recommendations.module'
4export * from './recommended-videos.component'
5export * from './recommended-videos.store'
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts
new file mode 100644
index 000000000..4654da847
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts
@@ -0,0 +1,79 @@
1import { Observable, of } from 'rxjs'
2import { map, switchMap } from 'rxjs/operators'
3import { Injectable } from '@angular/core'
4import { ServerService, UserService } from '@app/core'
5import { Video, VideoService } from '@app/shared/shared-main'
6import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
7import { HTMLServerConfig } from '@shared/models'
8import { RecommendationInfo } from './recommendation-info.model'
9import { RecommendationService } from './recommendations.service'
10
11/**
12 * Provides "recommendations" by providing the most recently uploaded videos.
13 */
14@Injectable()
15export class RecentVideosRecommendationService implements RecommendationService {
16 readonly pageSize = 5
17
18 private config: HTMLServerConfig
19
20 constructor (
21 private videos: VideoService,
22 private searchService: SearchService,
23 private userService: UserService,
24 private serverService: ServerService
25 ) {
26 this.config = this.serverService.getHTMLConfig()
27 }
28
29 getRecommendations (recommendation: RecommendationInfo): Observable<Video[]> {
30
31 return this.fetchPage(1, recommendation)
32 .pipe(
33 map(videos => {
34 const otherVideos = videos.filter(v => v.uuid !== recommendation.uuid)
35 return otherVideos.slice(0, this.pageSize)
36 })
37 )
38 }
39
40 private fetchPage (page: number, recommendation: RecommendationInfo): Observable<Video[]> {
41 const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 }
42 const defaultSubscription = this.videos.getVideos({ videoPagination: pagination, sort: '-createdAt' })
43 .pipe(map(v => v.data))
44
45 const tags = recommendation.tags
46 const searchIndexConfig = this.config.search.searchIndex
47 if (
48 !tags || tags.length === 0 ||
49 (searchIndexConfig.enabled === true && searchIndexConfig.disableLocalSearch === true)
50 ) {
51 return defaultSubscription
52 }
53
54 return this.userService.getAnonymousOrLoggedUser()
55 .pipe(
56 map(user => {
57 return {
58 search: '',
59 componentPagination: pagination,
60 advancedSearch: new AdvancedSearch({
61 tagsOneOf: recommendation.tags.join(','),
62 sort: '-publishedAt',
63 searchTarget: 'local',
64 nsfw: user.nsfwPolicy
65 ? this.videos.nsfwPolicyToParam(user.nsfwPolicy)
66 : undefined
67 })
68 }
69 }),
70 switchMap(params => this.searchService.searchVideos(params)),
71 map(v => v.data),
72 switchMap(videos => {
73 if (videos.length <= 1) return defaultSubscription
74
75 return of(videos)
76 })
77 )
78 }
79}
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommendation-info.model.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommendation-info.model.ts
new file mode 100644
index 000000000..0233563bb
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommendation-info.model.ts
@@ -0,0 +1,4 @@
1export interface RecommendationInfo {
2 uuid: string
3 tags?: string[]
4}
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.module.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.module.ts
new file mode 100644
index 000000000..1417f3e2a
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.module.ts
@@ -0,0 +1,35 @@
1
2import { CommonModule } from '@angular/common'
3import { NgModule } from '@angular/core'
4import { SharedFormModule } from '@app/shared/shared-forms'
5import { SharedMainModule } from '@app/shared/shared-main'
6import { SharedSearchModule } from '@app/shared/shared-search'
7import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature'
8import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist'
9import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
10import { RecommendedVideosComponent } from './recommended-videos.component'
11import { RecommendedVideosStore } from './recommended-videos.store'
12
13@NgModule({
14 imports: [
15 CommonModule,
16
17 SharedMainModule,
18 SharedSearchModule,
19 SharedVideoPlaylistModule,
20 SharedVideoMiniatureModule,
21 SharedFormModule
22 ],
23 declarations: [
24 RecommendedVideosComponent
25 ],
26 exports: [
27 RecommendedVideosComponent
28 ],
29 providers: [
30 RecommendedVideosStore,
31 RecentVideosRecommendationService
32 ]
33})
34export class RecommendationsModule {
35}
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.service.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.service.ts
new file mode 100644
index 000000000..1d79d35f6
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommendations.service.ts
@@ -0,0 +1,7 @@
1import { Observable } from 'rxjs'
2import { Video } from '@app/shared/shared-main'
3import { RecommendationInfo } from './recommendation-info.model'
4
5export interface RecommendationService {
6 getRecommendations (recommendation: RecommendationInfo): Observable<Video[]>
7}
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html
new file mode 100644
index 000000000..e1040fead
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html
@@ -0,0 +1,26 @@
1<div class="other-videos" [ngClass]="{ 'display-as-row': displayAsRow }">
2 <ng-container *ngIf="hasVideos$ | async">
3 <div class="title-page-container">
4 <h2 i18n class="title-page title-page-single">
5 Other videos
6 </h2>
7 <div *ngIf="!playlist" class="title-page-autoplay"
8 [ngbTooltip]="autoPlayNextVideoTooltip" placement="bottom-right auto"
9 >
10 <span i18n>AUTOPLAY</span>
11 <my-input-switch class="small" [(ngModel)]="autoPlayNextVideo" (ngModelChange)="switchAutoPlayNextVideo()"></my-input-switch>
12 </div>
13 </div>
14
15 <ng-container *ngFor="let video of (videos$ | async); let i = index; let length = count">
16 <my-video-miniature
17 [displayOptions]="displayOptions" [video]="video" [user]="userMiniature" [displayAsRow]="displayAsRow"
18 (videoBlocked)="onVideoRemoved()" (videoRemoved)="onVideoRemoved()" (videoAccountMuted)="onVideoRemoved()"
19 actorImageSize="32"
20 >
21 </my-video-miniature>
22
23 <hr *ngIf="!playlist && i == 0 && length > 1" />
24 </ng-container>
25 </ng-container>
26</div>
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.scss b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.scss
new file mode 100644
index 000000000..84ed25ae8
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.scss
@@ -0,0 +1,68 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.title-page-container {
5 display: flex;
6 justify-content: space-between;
7 align-items: baseline;
8 margin-bottom: 25px;
9 flex-wrap: wrap-reverse;
10
11 .title-page.active,
12 .title-page.title-page-single {
13 @include margin-right(.5rem !important);
14
15 margin-bottom: unset;
16 }
17}
18
19.title-page {
20 margin-top: 0;
21}
22
23.title-page-autoplay {
24 @include margin-left(auto);
25
26 display: flex;
27 width: max-content;
28 height: max-content;
29 align-items: center;
30
31 span {
32 @include margin-right(0.3rem);
33
34 text-transform: uppercase;
35 font-size: 85%;
36 font-weight: 600;
37 }
38}
39
40hr {
41 margin-top: 0;
42}
43
44my-video-miniature {
45 display: block;
46}
47
48.other-videos:not(.display-as-row) my-video-miniature {
49 min-width: $video-thumbnail-medium-width;
50 max-width: $video-thumbnail-medium-width;
51}
52
53.display-as-row {
54 my-video-miniature {
55 margin-bottom: 20px;
56 }
57
58 hr {
59 display: none;
60 }
61
62 @media screen and (max-width: $mobile-view) {
63 my-video-miniature {
64 margin-bottom: 10px;
65 }
66 }
67}
68
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts
new file mode 100644
index 000000000..89b9c01b6
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts
@@ -0,0 +1,95 @@
1import { Observable } from 'rxjs'
2import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
3import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core'
4import { Video } from '@app/shared/shared-main'
5import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature'
6import { VideoPlaylist } from '@app/shared/shared-video-playlist'
7import { UserLocalStorageKeys } from '@root-helpers/users'
8import { RecommendationInfo } from './recommendation-info.model'
9import { RecommendedVideosStore } from './recommended-videos.store'
10
11@Component({
12 selector: 'my-recommended-videos',
13 templateUrl: './recommended-videos.component.html',
14 styleUrls: [ './recommended-videos.component.scss' ]
15})
16export class RecommendedVideosComponent implements OnInit, OnChanges {
17 @Input() inputRecommendation: RecommendationInfo
18 @Input() playlist: VideoPlaylist
19 @Input() displayAsRow: boolean
20
21 @Output() gotRecommendations = new EventEmitter<Video[]>()
22
23 autoPlayNextVideo: boolean
24 autoPlayNextVideoTooltip: string
25
26 displayOptions: MiniatureDisplayOptions = {
27 date: true,
28 views: true,
29 by: true,
30 avatar: true
31 }
32
33 userMiniature: User
34
35 readonly hasVideos$: Observable<boolean>
36 readonly videos$: Observable<Video[]>
37
38 constructor (
39 private userService: UserService,
40 private authService: AuthService,
41 private notifier: Notifier,
42 private store: RecommendedVideosStore,
43 private sessionStorageService: SessionStorageService
44 ) {
45 this.videos$ = this.store.recommendations$
46 this.hasVideos$ = this.store.hasRecommendations$
47 this.videos$.subscribe(videos => this.gotRecommendations.emit(videos))
48
49 if (this.authService.isLoggedIn()) {
50 this.autoPlayNextVideo = this.authService.getUser().autoPlayNextVideo
51 } else {
52 this.autoPlayNextVideo = this.sessionStorageService.getItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
53
54 this.sessionStorageService.watch([UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO]).subscribe(
55 () => {
56 this.autoPlayNextVideo = this.sessionStorageService.getItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true'
57 }
58 )
59 }
60
61 this.autoPlayNextVideoTooltip = $localize`When active, the next video is automatically played after the current one.`
62 }
63
64 ngOnInit () {
65 this.userService.getAnonymousOrLoggedUser()
66 .subscribe(user => this.userMiniature = user)
67 }
68
69 ngOnChanges () {
70 if (this.inputRecommendation) {
71 this.store.requestNewRecommendations(this.inputRecommendation)
72 }
73 }
74
75 onVideoRemoved () {
76 this.store.requestNewRecommendations(this.inputRecommendation)
77 }
78
79 switchAutoPlayNextVideo () {
80 this.sessionStorageService.setItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString())
81
82 if (this.authService.isLoggedIn()) {
83 const details = {
84 autoPlayNextVideo: this.autoPlayNextVideo
85 }
86
87 this.userService.updateMyProfile(details).subscribe(
88 () => {
89 this.authService.refreshUserInformation()
90 },
91 err => this.notifier.error(err.message)
92 )
93 }
94 }
95}
diff --git a/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.store.ts b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.store.ts
new file mode 100644
index 000000000..8c3fb6480
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.store.ts
@@ -0,0 +1,37 @@
1import { Observable, ReplaySubject } from 'rxjs'
2import { map, shareReplay, switchMap, take } from 'rxjs/operators'
3import { Inject, Injectable } from '@angular/core'
4import { Video } from '@app/shared/shared-main'
5import { RecentVideosRecommendationService } from './recent-videos-recommendation.service'
6import { RecommendationInfo } from './recommendation-info.model'
7import { RecommendationService } from './recommendations.service'
8
9/**
10 * This store is intended to provide data for the RecommendedVideosComponent.
11 */
12@Injectable()
13export class RecommendedVideosStore {
14 public readonly recommendations$: Observable<Video[]>
15 public readonly hasRecommendations$: Observable<boolean>
16 private readonly requestsForLoad$$ = new ReplaySubject<RecommendationInfo>(1)
17
18 constructor (
19 @Inject(RecentVideosRecommendationService) private recommendations: RecommendationService
20 ) {
21 this.recommendations$ = this.requestsForLoad$$.pipe(
22 switchMap(requestedRecommendation => {
23 return this.recommendations.getRecommendations(requestedRecommendation)
24 .pipe(take(1))
25 }),
26 shareReplay()
27 )
28
29 this.hasRecommendations$ = this.recommendations$.pipe(
30 map(otherVideos => otherVideos.length > 0)
31 )
32 }
33
34 requestNewRecommendations (recommend: RecommendationInfo) {
35 this.requestsForLoad$$.next(recommend)
36 }
37}
diff --git a/client/src/app/+videos/+video-watch/shared/timestamp-route-transformer.directive.ts b/client/src/app/+videos/+video-watch/shared/timestamp-route-transformer.directive.ts
new file mode 100644
index 000000000..45e023695
--- /dev/null
+++ b/client/src/app/+videos/+video-watch/shared/timestamp-route-transformer.directive.ts
@@ -0,0 +1,39 @@
1import { Directive, EventEmitter, HostListener, Output } from '@angular/core'
2
3@Directive({
4 selector: '[timestampRouteTransformer]'
5})
6export class TimestampRouteTransformerDirective {
7 @Output() timestampClicked = new EventEmitter<number>()
8
9 @HostListener('click', ['$event'])
10 public onClick ($event: Event) {
11 const target = $event.target as HTMLLinkElement
12
13 if (target.hasAttribute('href') !== true) return
14
15 const ngxLink = document.createElement('a')
16 ngxLink.href = target.getAttribute('href')
17
18 // we only care about reflective links
19 if (ngxLink.host !== window.location.host) return
20
21 const ngxLinkParams = new URLSearchParams(ngxLink.search)
22 if (ngxLinkParams.has('start') !== true) return
23
24 const separators = ['h', 'm', 's']
25 const start = ngxLinkParams
26 .get('start')
27 .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator
28 .map(t => {
29 if (t.includes('h')) return parseInt(t, 10) * 3600
30 if (t.includes('m')) return parseInt(t, 10) * 60
31 return parseInt(t, 10)
32 })
33 .reduce((acc, t) => acc + t)
34
35 this.timestampClicked.emit(start)
36
37 $event.preventDefault()
38 }
39}