From 911186dae411d78788ccede093c251303187589a Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Tue, 29 Jun 2021 17:18:30 +0200 Subject: Reorganize watch components --- .../comment/video-comment-add.component.html | 96 -------- .../comment/video-comment-add.component.scss | 119 ---------- .../comment/video-comment-add.component.ts | 220 ----------------- .../comment/video-comment.component.html | 94 -------- .../comment/video-comment.component.scss | 199 ---------------- .../comment/video-comment.component.ts | 208 ---------------- .../comment/video-comments.component.html | 100 -------- .../comment/video-comments.component.scss | 60 ----- .../comment/video-comments.component.ts | 261 --------------------- .../recent-videos-recommendation.service.ts | 79 ------- .../recommendations/recommendation-info.model.ts | 4 - .../recommendations/recommendations.module.ts | 35 --- .../recommendations/recommendations.service.ts | 7 - .../recommended-videos.component.html | 26 -- .../recommended-videos.component.scss | 68 ------ .../recommended-videos.component.ts | 95 -------- .../recommendations/recommended-videos.store.ts | 37 --- .../+videos/+video-watch/shared/comment/index.ts | 3 + .../comment/video-comment-add.component.html | 96 ++++++++ .../comment/video-comment-add.component.scss | 119 ++++++++++ .../shared/comment/video-comment-add.component.ts | 220 +++++++++++++++++ .../shared/comment/video-comment.component.html | 94 ++++++++ .../shared/comment/video-comment.component.scss | 199 ++++++++++++++++ .../shared/comment/video-comment.component.ts | 208 ++++++++++++++++ .../shared/comment/video-comments.component.html | 100 ++++++++ .../shared/comment/video-comments.component.scss | 60 +++++ .../shared/comment/video-comments.component.ts | 261 +++++++++++++++++++++ .../src/app/+videos/+video-watch/shared/index.ts | 5 + .../+videos/+video-watch/shared/metadata/index.ts | 3 + .../metadata/video-avatar-channel.component.html | 11 + .../metadata/video-avatar-channel.component.scss | 42 ++++ .../metadata/video-avatar-channel.component.ts | 26 ++ .../metadata/video-description.component.html | 19 ++ .../metadata/video-description.component.scss | 46 ++++ .../shared/metadata/video-description.component.ts | 87 +++++++ .../shared/metadata/video-rate.component.html | 23 ++ .../shared/metadata/video-rate.component.scss | 15 ++ .../shared/metadata/video-rate.component.ts | 142 +++++++++++ .../+videos/+video-watch/shared/playlist/index.ts | 1 + .../playlist/video-watch-playlist.component.html | 49 ++++ .../playlist/video-watch-playlist.component.scss | 83 +++++++ .../playlist/video-watch-playlist.component.ts | 229 ++++++++++++++++++ .../+video-watch/shared/recommendations/index.ts | 5 + .../recent-videos-recommendation.service.ts | 79 +++++++ .../recommendations/recommendation-info.model.ts | 4 + .../recommendations/recommendations.module.ts | 35 +++ .../recommendations/recommendations.service.ts | 7 + .../recommended-videos.component.html | 26 ++ .../recommended-videos.component.scss | 68 ++++++ .../recommended-videos.component.ts | 95 ++++++++ .../recommendations/recommended-videos.store.ts | 37 +++ .../timestamp-route-transformer.directive.ts | 39 +++ .../timestamp-route-transformer.directive.ts | 39 --- .../video-avatar-channel.component.html | 11 - .../video-avatar-channel.component.scss | 42 ---- .../+video-watch/video-avatar-channel.component.ts | 26 -- .../+video-watch/video-description.component.html | 19 -- .../+video-watch/video-description.component.scss | 46 ---- .../+video-watch/video-description.component.ts | 87 ------- .../+videos/+video-watch/video-rate.component.html | 23 -- .../+videos/+video-watch/video-rate.component.scss | 15 -- .../+videos/+video-watch/video-rate.component.ts | 142 ----------- .../video-watch-playlist.component.html | 49 ---- .../video-watch-playlist.component.scss | 83 ------- .../+video-watch/video-watch-playlist.component.ts | 229 ------------------ .../+videos/+video-watch/video-watch.component.ts | 2 +- .../app/+videos/+video-watch/video-watch.module.ts | 20 +- 67 files changed, 2548 insertions(+), 2529 deletions(-) delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.html delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.html delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.scss delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comment.component.ts delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.html delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.scss delete mode 100644 client/src/app/+videos/+video-watch/comment/video-comments.component.ts delete mode 100644 client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts delete mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts delete mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts delete mode 100644 client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts delete mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html delete mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss delete mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts delete mode 100644 client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts create mode 100644 client/src/app/+videos/+video-watch/shared/comment/index.ts create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.html create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.scss create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comment.component.html create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comment.component.scss create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comment.component.ts create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comments.component.html create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comments.component.scss create mode 100644 client/src/app/+videos/+video-watch/shared/comment/video-comments.component.ts create mode 100644 client/src/app/+videos/+video-watch/shared/index.ts create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/index.ts create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.html create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.scss create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-avatar-channel.component.ts create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-description.component.html create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-description.component.scss create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-description.component.ts create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.html create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.scss create mode 100644 client/src/app/+videos/+video-watch/shared/metadata/video-rate.component.ts create mode 100644 client/src/app/+videos/+video-watch/shared/playlist/index.ts create mode 100644 client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.html create mode 100644 client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.scss create mode 100644 client/src/app/+videos/+video-watch/shared/playlist/video-watch-playlist.component.ts create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/index.ts create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/recent-videos-recommendation.service.ts create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/recommendation-info.model.ts create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/recommendations.module.ts create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/recommendations.service.ts create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.html create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.scss create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.component.ts create mode 100644 client/src/app/+videos/+video-watch/shared/recommendations/recommended-videos.store.ts create mode 100644 client/src/app/+videos/+video-watch/shared/timestamp-route-transformer.directive.ts delete mode 100644 client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts delete mode 100644 client/src/app/+videos/+video-watch/video-avatar-channel.component.html delete mode 100644 client/src/app/+videos/+video-watch/video-avatar-channel.component.scss delete mode 100644 client/src/app/+videos/+video-watch/video-avatar-channel.component.ts delete mode 100644 client/src/app/+videos/+video-watch/video-description.component.html delete mode 100644 client/src/app/+videos/+video-watch/video-description.component.scss delete mode 100644 client/src/app/+videos/+video-watch/video-description.component.ts delete mode 100644 client/src/app/+videos/+video-watch/video-rate.component.html delete mode 100644 client/src/app/+videos/+video-watch/video-rate.component.scss delete mode 100644 client/src/app/+videos/+video-watch/video-rate.component.ts delete mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.html delete mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.scss delete mode 100644 client/src/app/+videos/+video-watch/video-watch-playlist.component.ts (limited to 'client/src/app/+videos/+video-watch') diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html deleted file mode 100644 index 3ee818c8b..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.html +++ /dev/null @@ -1,96 +0,0 @@ -
-
- - -
- - - - - Markdown compatible that supports: - -
    -
  • Auto generated links
  • -
  • Break lines
  • -
  • Lists
  • -
  • - Emphasis - **bold** _italic_ -
  • -
  • - Emoji shortcuts - :) <3 -
  • -
  • - Emoji markup - :smile: - -
  • -
-
-
-
- {{ formErrors.text }} -
-
-
- -
- - - -
-
- - - - - - - - - - - - - diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss deleted file mode 100644 index fb79991db..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.scss +++ /dev/null @@ -1,119 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; - -$markdown-icon-height: 18px; -$markdown-icon-width: 30px; -$peertube-textarea-height: 60px; - -form { - margin-bottom: 30px; -} - -.avatar-and-textarea { - display: flex; - margin-bottom: 10px; - - my-actor-avatar { - @include margin-right(10px); - } - - .form-group { - flex-grow: 1; - margin: 0; - position: relative; - } - - textarea { - @include peertube-textarea(100%, $peertube-textarea-height); - @include button-focus(pvar(--mainColorLightest)); - @include padding-right($markdown-icon-width + 15px !important); - - min-height: calc(#{$peertube-textarea-height} - 15px * 2); - - @media screen and (max-width: 600px) { - @include padding-right($markdown-icon-width + 19px !important); - } - - &:focus::placeholder { - opacity: 0; - } - } -} - -.markdown-guide { - position: absolute; - top: 5px; - right: 9px; - - // inset-inline is not well supported by web browsers - &.is-rtl { - right: unset; - left: 9px; - } - - ::ng-deep .help-tooltip-button { - my-global-icon { - height: $markdown-icon-height; - width: $markdown-icon-width; - - svg { - color: #C6C6C6; - fill: #C6C6C6; - border-radius: 3px; - } - } - - &:focus, - &:active, - &:hover { - my-global-icon svg { - background-color: #C6C6C6; - color: pvar(--mainBackgroundColor); - fill: pvar(--mainBackgroundColor); - } - } - } -} - -.comment-buttons { - display: flex; - justify-content: flex-end; -} - -.emoji-flex { - display: flex; - flex-flow: row wrap; - align-items: center; - - .emoji-flex-item { - text-align: left; - margin: auto; - min-width: 227px; - flex: 1; - - code { - @include margin-left(5px); - - display: inline-block; - vertical-align: middle; - } - } -} - -@media screen and (max-width: 600px) { - textarea, - .comment-buttons button { - font-size: 14px !important; - } - - textarea { - padding: 5px !important; - } -} - -.modal-body { - > span { - float: left; - margin-bottom: 20px; - } -} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts deleted file mode 100644 index 78efe1684..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment-add.component.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Observable } from 'rxjs' -import { getLocaleDirection } from '@angular/common' -import { - Component, - ElementRef, - EventEmitter, - Inject, - Input, - LOCALE_ID, - OnChanges, - OnInit, - Output, - SimpleChanges, - ViewChild -} from '@angular/core' -import { Router } from '@angular/router' -import { Notifier, User } from '@app/core' -import { VIDEO_COMMENT_TEXT_VALIDATOR } from '@app/shared/form-validators/video-comment-validators' -import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' -import { Video } from '@app/shared/shared-main' -import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { VideoCommentCreate } from '@shared/models' - -@Component({ - selector: 'my-video-comment-add', - templateUrl: './video-comment-add.component.html', - styleUrls: ['./video-comment-add.component.scss'] -}) -export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { - @Input() user: User - @Input() video: Video - @Input() parentComment?: VideoComment - @Input() parentComments?: VideoComment[] - @Input() focusOnInit = false - @Input() textValue?: string - - @Output() commentCreated = new EventEmitter() - @Output() cancel = new EventEmitter() - - @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal - @ViewChild('emojiModal', { static: true }) emojiModal: NgbModal - @ViewChild('textarea', { static: true }) textareaElement: ElementRef - - addingComment = false - addingCommentButtonValue: string - - constructor ( - protected formValidatorService: FormValidatorService, - private notifier: Notifier, - private videoCommentService: VideoCommentService, - private modalService: NgbModal, - private router: Router, - @Inject(LOCALE_ID) private localeId: string - ) { - super() - } - - get emojiMarkupList () { - const emojiMarkupObjectList = require('markdown-it-emoji/lib/data/light.json') - - // Populate emoji-markup-list from object to array to avoid keys alphabetical order - const emojiMarkupArrayList = [] - for (const emojiMarkupName in emojiMarkupObjectList) { - if (emojiMarkupName) { - const emoji = emojiMarkupObjectList[emojiMarkupName] - emojiMarkupArrayList.push([emoji, emojiMarkupName]) - } - } - - return emojiMarkupArrayList - } - - ngOnInit () { - this.buildForm({ - text: VIDEO_COMMENT_TEXT_VALIDATOR - }) - - if (this.user) { - if (!this.parentComment) { - this.addingCommentButtonValue = $localize`Comment` - } else { - this.addingCommentButtonValue = $localize`Reply` - } - - this.initTextValue() - } - } - - ngOnChanges (changes: SimpleChanges) { - // Not initialized yet - if (!this.form) return - - if (changes.textValue && changes.textValue.currentValue && changes.textValue.currentValue !== changes.textValue.previousValue) { - this.patchTextValue(changes.textValue.currentValue, true) - } - } - - onValidKey () { - this.check() - if (!this.form.valid) return - - this.formValidated() - } - - openVisitorModal (event: any) { - if (this.user === null) { // we only open it for visitors - // fixing ng-bootstrap ModalService and the "Expression Changed After It Has Been Checked" Error - event.srcElement.blur() - event.preventDefault() - - this.modalService.open(this.visitorModal) - } - } - - openEmojiModal (event: any) { - event.preventDefault() - this.modalService.open(this.emojiModal, { backdrop: true, size: 'lg' }) - } - - hideModals () { - this.modalService.dismissAll() - } - - formValidated () { - // If we validate very quickly the comment form, we might comment twice - if (this.addingComment) return - - this.addingComment = true - - const commentCreate: VideoCommentCreate = this.form.value - let obs: Observable - - if (this.parentComment) { - obs = this.addCommentReply(commentCreate) - } else { - obs = this.addCommentThread(commentCreate) - } - - obs.subscribe( - comment => { - this.addingComment = false - this.commentCreated.emit(comment) - this.form.reset() - }, - - err => { - this.addingComment = false - - this.notifier.error(err.text) - } - ) - } - - isAddButtonDisplayed () { - return this.form.value['text'] - } - - getUri () { - return window.location.href - } - - gotoLogin () { - this.hideModals() - this.router.navigate([ '/login' ]) - } - - cancelCommentReply () { - this.cancel.emit(null) - this.form.value['text'] = this.textareaElement.nativeElement.value = '' - } - - isRTL () { - return getLocaleDirection(this.localeId) === 'rtl' - } - - private addCommentReply (commentCreate: VideoCommentCreate) { - return this.videoCommentService - .addCommentReply(this.video.id, this.parentComment.id, commentCreate) - } - - private addCommentThread (commentCreate: VideoCommentCreate) { - return this.videoCommentService - .addCommentThread(this.video.id, commentCreate) - } - - private initTextValue () { - if (this.textValue) { - this.patchTextValue(this.textValue, this.focusOnInit) - return - } - - if (this.parentComment) { - const mentions = this.parentComments - .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves - .map(c => '@' + c.by) - - const mentionsSet = new Set(mentions) - const mentionsText = Array.from(mentionsSet).join(' ') + ' ' - - this.patchTextValue(mentionsText, this.focusOnInit) - } - } - - private patchTextValue (text: string, focus: boolean) { - setTimeout(() => { - if (focus) { - this.textareaElement.nativeElement.focus() - } - - // Scroll to textarea - this.textareaElement.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) - - // Use the native textarea autosize according to the text's break lines - this.textareaElement.nativeElement.dispatchEvent(new Event('input')) - }) - - this.form.patchValue({ text }) - } -} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.html b/client/src/app/+videos/+video-watch/comment/video-comment.component.html deleted file mode 100644 index d8b944b35..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment.component.html +++ /dev/null @@ -1,94 +0,0 @@ -
-
- -
-
- -
-
- -
Highlighted comment
- - - -
- -
-
Reply
- - -
-
- - - - -
- This comment has been deleted -
-
- - - -
-
- -
-
- - -
-
-
- - - - diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.scss b/client/src/app/+videos/+video-watch/comment/video-comment.component.scss deleted file mode 100644 index 87e313d41..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment.component.scss +++ /dev/null @@ -1,199 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; - -.root-comment { - font-size: 15px; - display: flex; - - .left { - @include margin-right(10px); - - display: flex; - flex-direction: column; - align-items: center; - - .vertical-border { - width: 2px; - height: 100%; - background-color: rgba(0, 0, 0, 0.05); - margin: 10px calc(1rem + 1px); - } - } - - .right { - width: 100%; - } -} - -my-actor-avatar { - @include actor-avatar-size(36px); -} - -.comment { - flex-grow: 1; - // Fix word-wrap with flex - min-width: 1px; -} - -.highlighted-comment { - display: inline-block; - background-color: #F5F5F5; - color: #3d3d3d; - padding: 0 5px; - font-size: 13px; - margin-bottom: 5px; - font-weight: $font-semibold; - border-radius: 3px; -} - -.comment-account-date { - display: flex; - margin-bottom: 4px; -} - -.video-author { - @include padding-right(6px); - @include padding-left(6px); - - height: 20px; - background-color: #888888; - border-radius: 12px; - margin-bottom: 2px; - max-width: 100%; - box-sizing: border-box; - flex-direction: row; - align-items: center; - display: inline-flex; - color: #fff !important; -} - -.comment-account { - word-break: break-all; - font-weight: 600; - font-size: 90%; - - a { - @include disable-default-a-behaviour; - - color: pvar(--mainForegroundColor); - - &:hover { - text-decoration: underline; - } - } - - .comment-account-fid { - opacity: .6; - } -} - -.comment-date { - @include margin-left(5px); - - font-size: 90%; - color: pvar(--greyForegroundColor); - text-decoration: none; - - &:hover { - text-decoration: underline; - } -} - -.comment-html { - @include peertube-word-wrap; - - // Mentions - ::ng-deep a { - - &:not(.linkified-url) { - @include disable-default-a-behaviour; - - color: pvar(--mainForegroundColor); - - font-weight: $font-semibold; - } - - } - - // Paragraphs - ::ng-deep p { - margin-bottom: .3rem; - } - - &.comment-html-deleted { - color: pvar(--greyForegroundColor); - margin-bottom: 1rem; - } -} - -.comment-actions { - margin-bottom: 10px; - display: flex; - - ::ng-deep .dropdown-toggle, - .comment-action-reply { - @include margin-right(10px); - - color: pvar(--greyForegroundColor); - cursor: pointer; - - &:hover, - &:active, - &:focus, - &:focus-visible { - color: pvar(--mainForegroundColor); - } - } - - ::ng-deep .action-button { - background-color: transparent; - padding: 0; - font-weight: unset; - } -} - -my-video-comment-add { - ::ng-deep form { - margin-top: 1rem; - margin-bottom: 0; - } -} - -.is-child { - // Reduce avatars size for replies - my-actor-avatar { - @include actor-avatar-size(25px); - } - - .left { - @include margin-right(6px); - } -} - -@media screen and (max-width: 1200px) { - .children { - @include margin-left(-10px); - } -} - -@media screen and (max-width: 600px) { - .children { - @include margin-left(-20px); - - .left { - align-items: flex-start; - - .vertical-border { - @include margin-left(2px); - } - } - } - - .comment-account-date { - flex-direction: column; - - .comment-date { - @include margin-left(0); - } - } -} diff --git a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts b/client/src/app/+videos/+video-watch/comment/video-comment.component.ts deleted file mode 100644 index 04f8f0d58..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comment.component.ts +++ /dev/null @@ -1,208 +0,0 @@ - -import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core' -import { MarkdownService, Notifier, UserService } from '@app/core' -import { AuthService } from '@app/core/auth' -import { Account, DropdownAction, Video } from '@app/shared/shared-main' -import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component' -import { VideoComment, VideoCommentThreadTree } from '@app/shared/shared-video-comment' -import { User, UserRight } from '@shared/models' - -@Component({ - selector: 'my-video-comment', - templateUrl: './video-comment.component.html', - styleUrls: ['./video-comment.component.scss'] -}) -export class VideoCommentComponent implements OnInit, OnChanges { - @ViewChild('commentReportModal') commentReportModal: CommentReportComponent - - @Input() video: Video - @Input() comment: VideoComment - @Input() parentComments: VideoComment[] = [] - @Input() commentTree: VideoCommentThreadTree - @Input() inReplyToCommentId: number - @Input() highlightedComment = false - @Input() firstInThread = false - @Input() redraftValue?: string - - @Output() wantedToReply = new EventEmitter() - @Output() wantedToDelete = new EventEmitter() - @Output() wantedToRedraft = new EventEmitter() - @Output() threadCreated = new EventEmitter() - @Output() resetReply = new EventEmitter() - @Output() timestampClicked = new EventEmitter() - - prependModerationActions: DropdownAction[] - - sanitizedCommentHTML = '' - newParentComments: VideoComment[] = [] - - commentAccount: Account - commentUser: User - - constructor ( - private markdownService: MarkdownService, - private authService: AuthService, - private userService: UserService, - private notifier: Notifier - ) {} - - get user () { - return this.authService.getUser() - } - - ngOnInit () { - this.init() - } - - ngOnChanges () { - this.init() - } - - onCommentReplyCreated (createdComment: VideoComment) { - if (!this.commentTree) { - this.commentTree = { - comment: this.comment, - hasDisplayedChildren: false, - children: [] - } - - this.threadCreated.emit(this.commentTree) - } - - this.commentTree.children.unshift({ - comment: createdComment, - hasDisplayedChildren: false, - children: [] - }) - - this.resetReply.emit() - - this.redraftValue = undefined - } - - onWantToReply (comment?: VideoComment) { - this.wantedToReply.emit(comment || this.comment) - } - - onWantToDelete (comment?: VideoComment) { - this.wantedToDelete.emit(comment || this.comment) - } - - onWantToRedraft (comment?: VideoComment) { - this.wantedToRedraft.emit(comment || this.comment) - } - - isUserLoggedIn () { - return this.authService.isLoggedIn() - } - - onResetReply () { - this.resetReply.emit() - } - - handleTimestampClicked (timestamp: number) { - this.timestampClicked.emit(timestamp) - } - - isRemovableByUser () { - return this.comment.account && this.isUserLoggedIn() && - ( - this.user.account.id === this.comment.account.id || - this.user.account.id === this.video.account.id || - this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) - ) - } - - isRedraftableByUser () { - return ( - this.comment.account && - this.isUserLoggedIn() && - this.user.account.id === this.comment.account.id && - this.comment.totalReplies === 0 - ) - } - - isReportableByUser () { - return ( - this.comment.account && - this.isUserLoggedIn() && - this.comment.isDeleted === false && - this.user.account.id !== this.comment.account.id - ) - } - - isCommentDisplayed () { - // Not deleted - return !this.comment.isDeleted || - this.comment.totalReplies !== 0 || // Or root comment thread has replies - (this.commentTree?.hasDisplayedChildren) // Or this is a reply that have other replies - } - - isChild () { - return this.parentComments.length !== 0 - } - - private getUserIfNeeded (account: Account) { - if (!account.userId) return - if (!this.authService.isLoggedIn()) return - - const user = this.authService.getUser() - if (user.hasRight(UserRight.MANAGE_USERS)) { - this.userService.getUserWithCache(account.userId) - .subscribe( - user => this.commentUser = user, - - err => this.notifier.error(err.message) - ) - } - } - - private async init () { - // Before HTML rendering restore line feed for markdown list compatibility - const commentText = this.comment.text.replace(//g, '\r\n') - const html = await this.markdownService.textMarkdownToHTML(commentText, true, true) - this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html) - this.newParentComments = this.parentComments.concat([ this.comment ]) - - if (this.comment.account) { - this.commentAccount = new Account(this.comment.account) - this.getUserIfNeeded(this.commentAccount) - } else { - this.comment.account = null - } - - this.prependModerationActions = [] - - if (this.isReportableByUser()) { - this.prependModerationActions.push({ - label: $localize`Report this comment`, - iconName: 'flag', - handler: () => this.showReportModal() - }) - } - - if (this.isRemovableByUser()) { - this.prependModerationActions.push({ - label: $localize`Remove`, - iconName: 'delete', - handler: () => this.onWantToDelete() - }) - } - - if (this.isRedraftableByUser()) { - this.prependModerationActions.push({ - label: $localize`Remove & re-draft`, - iconName: 'edit', - handler: () => this.onWantToRedraft() - }) - } - - if (this.prependModerationActions.length === 0) { - this.prependModerationActions = undefined - } - } - - private showReportModal () { - this.commentReportModal.show() - } -} diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.html b/client/src/app/+videos/+video-watch/comment/video-comments.component.html deleted file mode 100644 index 9e6fde2e0..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comments.component.html +++ /dev/null @@ -1,100 +0,0 @@ -
-
-

- {totalNotDeletedComments, plural, =0 {Comments} =1 {1 Comment} other {{{totalNotDeletedComments}} Comments}} -

- - - - -
- - - - -
No comments.
- -
-
-
- -
- -
- -
- - - - - - - View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}} from {{ video?.account?.displayName || 'the author' }} and others - - - View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}} from {{ video?.account?.displayName || 'the author' }} - - - - View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}} - - -
-
- -
-
-
- -
- Comments are disabled. -
-
diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.scss b/client/src/app/+videos/+video-watch/comment/video-comments.component.scss deleted file mode 100644 index 31aa73937..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comments.component.scss +++ /dev/null @@ -1,60 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; - -#highlighted-comment { - margin-bottom: 25px; -} - -.view-replies { - font-weight: $font-semibold; - font-size: 15px; - cursor: pointer; -} - -.glyphicon, -.comment-thread-loading { - @include margin-right(5px); - - display: inline-block; - font-size: 13px; -} - -.title-block { - .title-page { - @include margin-right(0); - } - - my-feed { - @include margin-left(5px); - - display: inline-block; - opacity: 0; - transition: ease-in .2s opacity; - width: 12px; - position: relative; - top: -3px; - } - - &:hover my-feed { - opacity: 1; - } -} - -#dropdown-sort-comments { - font-weight: 600; - text-transform: uppercase; - border: 0; - transform: translateY(-7%); -} - -@media screen and (max-width: 600px) { - .view-replies { - @include margin-left(46px); - } -} - -@media screen and (max-width: 450px) { - .view-replies { - font-size: 14px; - } -} diff --git a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts b/client/src/app/+videos/+video-watch/comment/video-comments.component.ts deleted file mode 100644 index 2c39e63fb..000000000 --- a/client/src/app/+videos/+video-watch/comment/video-comments.component.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { Subject, Subscription } from 'rxjs' -import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core' -import { ActivatedRoute } from '@angular/router' -import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' -import { HooksService } from '@app/core/plugins/hooks.service' -import { Syndication, VideoDetails } from '@app/shared/shared-main' -import { VideoComment, VideoCommentService, VideoCommentThreadTree } from '@app/shared/shared-video-comment' - -@Component({ - selector: 'my-video-comments', - templateUrl: './video-comments.component.html', - styleUrls: ['./video-comments.component.scss'] -}) -export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { - @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef - @Input() video: VideoDetails - @Input() user: User - - @Output() timestampClicked = new EventEmitter() - - comments: VideoComment[] = [] - highlightedThread: VideoComment - - sort = '-createdAt' - - componentPagination: ComponentPagination = { - currentPage: 1, - itemsPerPage: 10, - totalItems: null - } - totalNotDeletedComments: number - - inReplyToCommentId: number - commentReplyRedraftValue: string - commentThreadRedraftValue: string - - threadComments: { [ id: number ]: VideoCommentThreadTree } = {} - threadLoading: { [ id: number ]: boolean } = {} - - syndicationItems: Syndication[] = [] - - onDataSubject = new Subject() - - private sub: Subscription - - constructor ( - private authService: AuthService, - private notifier: Notifier, - private confirmService: ConfirmService, - private videoCommentService: VideoCommentService, - private activatedRoute: ActivatedRoute, - private hooks: HooksService - ) {} - - ngOnInit () { - // Find highlighted comment in params - this.sub = this.activatedRoute.params.subscribe( - params => { - if (params['threadId']) { - const highlightedThreadId = +params['threadId'] - this.processHighlightedThread(highlightedThreadId) - } - } - ) - } - - ngOnChanges (changes: SimpleChanges) { - if (changes['video']) { - this.resetVideo() - } - } - - ngOnDestroy () { - if (this.sub) this.sub.unsubscribe() - } - - viewReplies (commentId: number, highlightThread = false) { - this.threadLoading[commentId] = true - - const params = { - videoId: this.video.id, - threadId: commentId - } - - const obs = this.hooks.wrapObsFun( - this.videoCommentService.getVideoThreadComments.bind(this.videoCommentService), - params, - 'video-watch', - 'filter:api.video-watch.video-thread-replies.list.params', - 'filter:api.video-watch.video-thread-replies.list.result' - ) - - obs.subscribe( - res => { - this.threadComments[commentId] = res - this.threadLoading[commentId] = false - this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res }) - - if (highlightThread) { - this.highlightedThread = new VideoComment(res.comment) - - // Scroll to the highlighted thread - setTimeout(() => this.commentHighlightBlock.nativeElement.scrollIntoView(), 0) - } - }, - - err => this.notifier.error(err.message) - ) - } - - loadMoreThreads () { - const params = { - videoId: this.video.id, - componentPagination: this.componentPagination, - sort: this.sort - } - - const obs = this.hooks.wrapObsFun( - this.videoCommentService.getVideoCommentThreads.bind(this.videoCommentService), - params, - 'video-watch', - 'filter:api.video-watch.video-threads.list.params', - 'filter:api.video-watch.video-threads.list.result' - ) - - obs.subscribe( - res => { - this.comments = this.comments.concat(res.data) - this.componentPagination.totalItems = res.total - this.totalNotDeletedComments = res.totalNotDeletedComments - - this.onDataSubject.next(res.data) - this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination }) - }, - - err => this.notifier.error(err.message) - ) - } - - onCommentThreadCreated (comment: VideoComment) { - this.comments.unshift(comment) - this.commentThreadRedraftValue = undefined - } - - onWantedToReply (comment: VideoComment) { - this.inReplyToCommentId = comment.id - } - - onResetReply () { - this.inReplyToCommentId = undefined - this.commentReplyRedraftValue = undefined - } - - onThreadCreated (commentTree: VideoCommentThreadTree) { - this.viewReplies(commentTree.comment.id) - } - - handleSortChange (sort: string) { - if (this.sort === sort) return - - this.sort = sort - this.resetVideo() - } - - handleTimestampClicked (timestamp: number) { - this.timestampClicked.emit(timestamp) - } - - async onWantedToDelete ( - commentToDelete: VideoComment, - title = $localize`Delete`, - message = $localize`Do you really want to delete this comment?` - ): Promise { - if (commentToDelete.isLocal || this.video.isLocal) { - message += $localize` The deletion will be sent to remote instances so they can reflect the change.` - } else { - message += $localize` It is a remote comment, so the deletion will only be effective on your instance.` - } - - const res = await this.confirmService.confirm(message, title) - if (res === false) return false - - this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id) - .subscribe( - () => { - if (this.highlightedThread?.id === commentToDelete.id) { - commentToDelete = this.comments.find(c => c.id === commentToDelete.id) - - this.highlightedThread = undefined - } - - // Mark the comment as deleted - this.softDeleteComment(commentToDelete) - }, - - err => this.notifier.error(err.message) - ) - - return true - } - - async onWantedToRedraft (commentToRedraft: VideoComment) { - const confirm = await this.onWantedToDelete(commentToRedraft, $localize`Delete and re-draft`, $localize`Do you really want to delete and re-draft this comment?`) - - if (confirm) { - this.inReplyToCommentId = commentToRedraft.inReplyToCommentId - - // Restore line feed for editing - const commentToRedraftText = commentToRedraft.text.replace(//g, '\r\n') - - if (commentToRedraft.threadId === commentToRedraft.id) { - this.commentThreadRedraftValue = commentToRedraftText - } else { - this.commentReplyRedraftValue = commentToRedraftText - } - - } - } - - isUserLoggedIn () { - return this.authService.isLoggedIn() - } - - onNearOfBottom () { - if (hasMoreItems(this.componentPagination)) { - this.componentPagination.currentPage++ - this.loadMoreThreads() - } - } - - private softDeleteComment (comment: VideoComment) { - comment.isDeleted = true - comment.deletedAt = new Date() - comment.text = '' - comment.account = null - } - - private resetVideo () { - if (this.video.commentsEnabled === true) { - // Reset all our fields - this.highlightedThread = null - this.comments = [] - this.threadComments = {} - this.threadLoading = {} - this.inReplyToCommentId = undefined - this.componentPagination.currentPage = 1 - this.componentPagination.totalItems = null - this.totalNotDeletedComments = null - - this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video) - this.loadMoreThreads() - } - } - - private processHighlightedThread (highlightedThreadId: number) { - this.highlightedThread = this.comments.find(c => c.id === highlightedThreadId) - - const highlightThread = true - this.viewReplies(highlightedThreadId, highlightThread) - } -} diff --git a/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts b/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts deleted file mode 100644 index 4654da847..000000000 --- a/client/src/app/+videos/+video-watch/recommendations/recent-videos-recommendation.service.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Observable, of } from 'rxjs' -import { map, switchMap } from 'rxjs/operators' -import { Injectable } from '@angular/core' -import { ServerService, UserService } from '@app/core' -import { Video, VideoService } from '@app/shared/shared-main' -import { AdvancedSearch, SearchService } from '@app/shared/shared-search' -import { HTMLServerConfig } from '@shared/models' -import { RecommendationInfo } from './recommendation-info.model' -import { RecommendationService } from './recommendations.service' - -/** - * Provides "recommendations" by providing the most recently uploaded videos. - */ -@Injectable() -export class RecentVideosRecommendationService implements RecommendationService { - readonly pageSize = 5 - - private config: HTMLServerConfig - - constructor ( - private videos: VideoService, - private searchService: SearchService, - private userService: UserService, - private serverService: ServerService - ) { - this.config = this.serverService.getHTMLConfig() - } - - getRecommendations (recommendation: RecommendationInfo): Observable { - - return this.fetchPage(1, recommendation) - .pipe( - map(videos => { - const otherVideos = videos.filter(v => v.uuid !== recommendation.uuid) - return otherVideos.slice(0, this.pageSize) - }) - ) - } - - private fetchPage (page: number, recommendation: RecommendationInfo): Observable { - const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 } - const defaultSubscription = this.videos.getVideos({ videoPagination: pagination, sort: '-createdAt' }) - .pipe(map(v => v.data)) - - const tags = recommendation.tags - const searchIndexConfig = this.config.search.searchIndex - if ( - !tags || tags.length === 0 || - (searchIndexConfig.enabled === true && searchIndexConfig.disableLocalSearch === true) - ) { - return defaultSubscription - } - - return this.userService.getAnonymousOrLoggedUser() - .pipe( - map(user => { - return { - search: '', - componentPagination: pagination, - advancedSearch: new AdvancedSearch({ - tagsOneOf: recommendation.tags.join(','), - sort: '-publishedAt', - searchTarget: 'local', - nsfw: user.nsfwPolicy - ? this.videos.nsfwPolicyToParam(user.nsfwPolicy) - : undefined - }) - } - }), - switchMap(params => this.searchService.searchVideos(params)), - map(v => v.data), - switchMap(videos => { - if (videos.length <= 1) return defaultSubscription - - return of(videos) - }) - ) - } -} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts b/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts deleted file mode 100644 index 0233563bb..000000000 --- a/client/src/app/+videos/+video-watch/recommendations/recommendation-info.model.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface RecommendationInfo { - uuid: string - tags?: string[] -} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts b/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts deleted file mode 100644 index 1417f3e2a..000000000 --- a/client/src/app/+videos/+video-watch/recommendations/recommendations.module.ts +++ /dev/null @@ -1,35 +0,0 @@ - -import { CommonModule } from '@angular/common' -import { NgModule } from '@angular/core' -import { SharedFormModule } from '@app/shared/shared-forms' -import { SharedMainModule } from '@app/shared/shared-main' -import { SharedSearchModule } from '@app/shared/shared-search' -import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' -import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' -import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' -import { RecommendedVideosComponent } from './recommended-videos.component' -import { RecommendedVideosStore } from './recommended-videos.store' - -@NgModule({ - imports: [ - CommonModule, - - SharedMainModule, - SharedSearchModule, - SharedVideoPlaylistModule, - SharedVideoMiniatureModule, - SharedFormModule - ], - declarations: [ - RecommendedVideosComponent - ], - exports: [ - RecommendedVideosComponent - ], - providers: [ - RecommendedVideosStore, - RecentVideosRecommendationService - ] -}) -export class RecommendationsModule { -} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts b/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts deleted file mode 100644 index 1d79d35f6..000000000 --- a/client/src/app/+videos/+video-watch/recommendations/recommendations.service.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Observable } from 'rxjs' -import { Video } from '@app/shared/shared-main' -import { RecommendationInfo } from './recommendation-info.model' - -export interface RecommendationService { - getRecommendations (recommendation: RecommendationInfo): Observable -} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html deleted file mode 100644 index e1040fead..000000000 --- a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.html +++ /dev/null @@ -1,26 +0,0 @@ -
- -
-

- Other videos -

-
- AUTOPLAY - -
-
- - - - - -
-
-
-
diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss deleted file mode 100644 index 84ed25ae8..000000000 --- a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.scss +++ /dev/null @@ -1,68 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; - -.title-page-container { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: 25px; - flex-wrap: wrap-reverse; - - .title-page.active, - .title-page.title-page-single { - @include margin-right(.5rem !important); - - margin-bottom: unset; - } -} - -.title-page { - margin-top: 0; -} - -.title-page-autoplay { - @include margin-left(auto); - - display: flex; - width: max-content; - height: max-content; - align-items: center; - - span { - @include margin-right(0.3rem); - - text-transform: uppercase; - font-size: 85%; - font-weight: 600; - } -} - -hr { - margin-top: 0; -} - -my-video-miniature { - display: block; -} - -.other-videos:not(.display-as-row) my-video-miniature { - min-width: $video-thumbnail-medium-width; - max-width: $video-thumbnail-medium-width; -} - -.display-as-row { - my-video-miniature { - margin-bottom: 20px; - } - - hr { - display: none; - } - - @media screen and (max-width: $mobile-view) { - my-video-miniature { - margin-bottom: 10px; - } - } -} - diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts deleted file mode 100644 index 89b9c01b6..000000000 --- a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.component.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Observable } from 'rxjs' -import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' -import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core' -import { Video } from '@app/shared/shared-main' -import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' -import { VideoPlaylist } from '@app/shared/shared-video-playlist' -import { UserLocalStorageKeys } from '@root-helpers/users' -import { RecommendationInfo } from './recommendation-info.model' -import { RecommendedVideosStore } from './recommended-videos.store' - -@Component({ - selector: 'my-recommended-videos', - templateUrl: './recommended-videos.component.html', - styleUrls: [ './recommended-videos.component.scss' ] -}) -export class RecommendedVideosComponent implements OnInit, OnChanges { - @Input() inputRecommendation: RecommendationInfo - @Input() playlist: VideoPlaylist - @Input() displayAsRow: boolean - - @Output() gotRecommendations = new EventEmitter() - - autoPlayNextVideo: boolean - autoPlayNextVideoTooltip: string - - displayOptions: MiniatureDisplayOptions = { - date: true, - views: true, - by: true, - avatar: true - } - - userMiniature: User - - readonly hasVideos$: Observable - readonly videos$: Observable - - constructor ( - private userService: UserService, - private authService: AuthService, - private notifier: Notifier, - private store: RecommendedVideosStore, - private sessionStorageService: SessionStorageService - ) { - this.videos$ = this.store.recommendations$ - this.hasVideos$ = this.store.hasRecommendations$ - this.videos$.subscribe(videos => this.gotRecommendations.emit(videos)) - - if (this.authService.isLoggedIn()) { - this.autoPlayNextVideo = this.authService.getUser().autoPlayNextVideo - } else { - this.autoPlayNextVideo = this.sessionStorageService.getItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' - - this.sessionStorageService.watch([UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO]).subscribe( - () => { - this.autoPlayNextVideo = this.sessionStorageService.getItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' - } - ) - } - - this.autoPlayNextVideoTooltip = $localize`When active, the next video is automatically played after the current one.` - } - - ngOnInit () { - this.userService.getAnonymousOrLoggedUser() - .subscribe(user => this.userMiniature = user) - } - - ngOnChanges () { - if (this.inputRecommendation) { - this.store.requestNewRecommendations(this.inputRecommendation) - } - } - - onVideoRemoved () { - this.store.requestNewRecommendations(this.inputRecommendation) - } - - switchAutoPlayNextVideo () { - this.sessionStorageService.setItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString()) - - if (this.authService.isLoggedIn()) { - const details = { - autoPlayNextVideo: this.autoPlayNextVideo - } - - this.userService.updateMyProfile(details).subscribe( - () => { - this.authService.refreshUserInformation() - }, - err => this.notifier.error(err.message) - ) - } - } -} diff --git a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts b/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts deleted file mode 100644 index 8c3fb6480..000000000 --- a/client/src/app/+videos/+video-watch/recommendations/recommended-videos.store.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Observable, ReplaySubject } from 'rxjs' -import { map, shareReplay, switchMap, take } from 'rxjs/operators' -import { Inject, Injectable } from '@angular/core' -import { Video } from '@app/shared/shared-main' -import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' -import { RecommendationInfo } from './recommendation-info.model' -import { RecommendationService } from './recommendations.service' - -/** - * This store is intended to provide data for the RecommendedVideosComponent. - */ -@Injectable() -export class RecommendedVideosStore { - public readonly recommendations$: Observable - public readonly hasRecommendations$: Observable - private readonly requestsForLoad$$ = new ReplaySubject(1) - - constructor ( - @Inject(RecentVideosRecommendationService) private recommendations: RecommendationService - ) { - this.recommendations$ = this.requestsForLoad$$.pipe( - switchMap(requestedRecommendation => { - return this.recommendations.getRecommendations(requestedRecommendation) - .pipe(take(1)) - }), - shareReplay() - ) - - this.hasRecommendations$ = this.recommendations$.pipe( - map(otherVideos => otherVideos.length > 0) - ) - } - - requestNewRecommendations (recommend: RecommendationInfo) { - this.requestsForLoad$$.next(recommend) - } -} 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 @@ +export * from './video-comment-add.component' +export * from './video-comment.component' +export * 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 @@ +
+
+ + +
+ + + + + Markdown compatible that supports: + +
    +
  • Auto generated links
  • +
  • Break lines
  • +
  • Lists
  • +
  • + Emphasis + **bold** _italic_ +
  • +
  • + Emoji shortcuts + :) <3 +
  • +
  • + Emoji markup + :smile: + +
  • +
+
+
+
+ {{ formErrors.text }} +
+
+
+ +
+ + + +
+
+ + + + + + + + + + + + + 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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +$markdown-icon-height: 18px; +$markdown-icon-width: 30px; +$peertube-textarea-height: 60px; + +form { + margin-bottom: 30px; +} + +.avatar-and-textarea { + display: flex; + margin-bottom: 10px; + + my-actor-avatar { + @include margin-right(10px); + } + + .form-group { + flex-grow: 1; + margin: 0; + position: relative; + } + + textarea { + @include peertube-textarea(100%, $peertube-textarea-height); + @include button-focus(pvar(--mainColorLightest)); + @include padding-right($markdown-icon-width + 15px !important); + + min-height: calc(#{$peertube-textarea-height} - 15px * 2); + + @media screen and (max-width: 600px) { + @include padding-right($markdown-icon-width + 19px !important); + } + + &:focus::placeholder { + opacity: 0; + } + } +} + +.markdown-guide { + position: absolute; + top: 5px; + right: 9px; + + // inset-inline is not well supported by web browsers + &.is-rtl { + right: unset; + left: 9px; + } + + ::ng-deep .help-tooltip-button { + my-global-icon { + height: $markdown-icon-height; + width: $markdown-icon-width; + + svg { + color: #C6C6C6; + fill: #C6C6C6; + border-radius: 3px; + } + } + + &:focus, + &:active, + &:hover { + my-global-icon svg { + background-color: #C6C6C6; + color: pvar(--mainBackgroundColor); + fill: pvar(--mainBackgroundColor); + } + } + } +} + +.comment-buttons { + display: flex; + justify-content: flex-end; +} + +.emoji-flex { + display: flex; + flex-flow: row wrap; + align-items: center; + + .emoji-flex-item { + text-align: left; + margin: auto; + min-width: 227px; + flex: 1; + + code { + @include margin-left(5px); + + display: inline-block; + vertical-align: middle; + } + } +} + +@media screen and (max-width: 600px) { + textarea, + .comment-buttons button { + font-size: 14px !important; + } + + textarea { + padding: 5px !important; + } +} + +.modal-body { + > span { + float: left; + margin-bottom: 20px; + } +} 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 @@ +import { Observable } from 'rxjs' +import { getLocaleDirection } from '@angular/common' +import { + Component, + ElementRef, + EventEmitter, + Inject, + Input, + LOCALE_ID, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewChild +} from '@angular/core' +import { Router } from '@angular/router' +import { Notifier, User } from '@app/core' +import { VIDEO_COMMENT_TEXT_VALIDATOR } from '@app/shared/form-validators/video-comment-validators' +import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' +import { Video } from '@app/shared/shared-main' +import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { VideoCommentCreate } from '@shared/models' + +@Component({ + selector: 'my-video-comment-add', + templateUrl: './video-comment-add.component.html', + styleUrls: ['./video-comment-add.component.scss'] +}) +export class VideoCommentAddComponent extends FormReactive implements OnChanges, OnInit { + @Input() user: User + @Input() video: Video + @Input() parentComment?: VideoComment + @Input() parentComments?: VideoComment[] + @Input() focusOnInit = false + @Input() textValue?: string + + @Output() commentCreated = new EventEmitter() + @Output() cancel = new EventEmitter() + + @ViewChild('visitorModal', { static: true }) visitorModal: NgbModal + @ViewChild('emojiModal', { static: true }) emojiModal: NgbModal + @ViewChild('textarea', { static: true }) textareaElement: ElementRef + + addingComment = false + addingCommentButtonValue: string + + constructor ( + protected formValidatorService: FormValidatorService, + private notifier: Notifier, + private videoCommentService: VideoCommentService, + private modalService: NgbModal, + private router: Router, + @Inject(LOCALE_ID) private localeId: string + ) { + super() + } + + get emojiMarkupList () { + const emojiMarkupObjectList = require('markdown-it-emoji/lib/data/light.json') + + // Populate emoji-markup-list from object to array to avoid keys alphabetical order + const emojiMarkupArrayList = [] + for (const emojiMarkupName in emojiMarkupObjectList) { + if (emojiMarkupName) { + const emoji = emojiMarkupObjectList[emojiMarkupName] + emojiMarkupArrayList.push([emoji, emojiMarkupName]) + } + } + + return emojiMarkupArrayList + } + + ngOnInit () { + this.buildForm({ + text: VIDEO_COMMENT_TEXT_VALIDATOR + }) + + if (this.user) { + if (!this.parentComment) { + this.addingCommentButtonValue = $localize`Comment` + } else { + this.addingCommentButtonValue = $localize`Reply` + } + + this.initTextValue() + } + } + + ngOnChanges (changes: SimpleChanges) { + // Not initialized yet + if (!this.form) return + + if (changes.textValue && changes.textValue.currentValue && changes.textValue.currentValue !== changes.textValue.previousValue) { + this.patchTextValue(changes.textValue.currentValue, true) + } + } + + onValidKey () { + this.check() + if (!this.form.valid) return + + this.formValidated() + } + + openVisitorModal (event: any) { + if (this.user === null) { // we only open it for visitors + // fixing ng-bootstrap ModalService and the "Expression Changed After It Has Been Checked" Error + event.srcElement.blur() + event.preventDefault() + + this.modalService.open(this.visitorModal) + } + } + + openEmojiModal (event: any) { + event.preventDefault() + this.modalService.open(this.emojiModal, { backdrop: true, size: 'lg' }) + } + + hideModals () { + this.modalService.dismissAll() + } + + formValidated () { + // If we validate very quickly the comment form, we might comment twice + if (this.addingComment) return + + this.addingComment = true + + const commentCreate: VideoCommentCreate = this.form.value + let obs: Observable + + if (this.parentComment) { + obs = this.addCommentReply(commentCreate) + } else { + obs = this.addCommentThread(commentCreate) + } + + obs.subscribe( + comment => { + this.addingComment = false + this.commentCreated.emit(comment) + this.form.reset() + }, + + err => { + this.addingComment = false + + this.notifier.error(err.text) + } + ) + } + + isAddButtonDisplayed () { + return this.form.value['text'] + } + + getUri () { + return window.location.href + } + + gotoLogin () { + this.hideModals() + this.router.navigate([ '/login' ]) + } + + cancelCommentReply () { + this.cancel.emit(null) + this.form.value['text'] = this.textareaElement.nativeElement.value = '' + } + + isRTL () { + return getLocaleDirection(this.localeId) === 'rtl' + } + + private addCommentReply (commentCreate: VideoCommentCreate) { + return this.videoCommentService + .addCommentReply(this.video.id, this.parentComment.id, commentCreate) + } + + private addCommentThread (commentCreate: VideoCommentCreate) { + return this.videoCommentService + .addCommentThread(this.video.id, commentCreate) + } + + private initTextValue () { + if (this.textValue) { + this.patchTextValue(this.textValue, this.focusOnInit) + return + } + + if (this.parentComment) { + const mentions = this.parentComments + .filter(c => c.account && c.account.id !== this.user.account.id) // Don't add mention of ourselves + .map(c => '@' + c.by) + + const mentionsSet = new Set(mentions) + const mentionsText = Array.from(mentionsSet).join(' ') + ' ' + + this.patchTextValue(mentionsText, this.focusOnInit) + } + } + + private patchTextValue (text: string, focus: boolean) { + setTimeout(() => { + if (focus) { + this.textareaElement.nativeElement.focus() + } + + // Scroll to textarea + this.textareaElement.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) + + // Use the native textarea autosize according to the text's break lines + this.textareaElement.nativeElement.dispatchEvent(new Event('input')) + }) + + this.form.patchValue({ text }) + } +} 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 @@ +
+
+ +
+
+ +
+
+ +
Highlighted comment
+ + + +
+ +
+
Reply
+ + +
+
+ + + + +
+ This comment has been deleted +
+
+ + + +
+
+ +
+
+ + +
+
+
+ + + + 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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.root-comment { + font-size: 15px; + display: flex; + + .left { + @include margin-right(10px); + + display: flex; + flex-direction: column; + align-items: center; + + .vertical-border { + width: 2px; + height: 100%; + background-color: rgba(0, 0, 0, 0.05); + margin: 10px calc(1rem + 1px); + } + } + + .right { + width: 100%; + } +} + +my-actor-avatar { + @include actor-avatar-size(36px); +} + +.comment { + flex-grow: 1; + // Fix word-wrap with flex + min-width: 1px; +} + +.highlighted-comment { + display: inline-block; + background-color: #F5F5F5; + color: #3d3d3d; + padding: 0 5px; + font-size: 13px; + margin-bottom: 5px; + font-weight: $font-semibold; + border-radius: 3px; +} + +.comment-account-date { + display: flex; + margin-bottom: 4px; +} + +.video-author { + @include padding-right(6px); + @include padding-left(6px); + + height: 20px; + background-color: #888888; + border-radius: 12px; + margin-bottom: 2px; + max-width: 100%; + box-sizing: border-box; + flex-direction: row; + align-items: center; + display: inline-flex; + color: #fff !important; +} + +.comment-account { + word-break: break-all; + font-weight: 600; + font-size: 90%; + + a { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); + + &:hover { + text-decoration: underline; + } + } + + .comment-account-fid { + opacity: .6; + } +} + +.comment-date { + @include margin-left(5px); + + font-size: 90%; + color: pvar(--greyForegroundColor); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.comment-html { + @include peertube-word-wrap; + + // Mentions + ::ng-deep a { + + &:not(.linkified-url) { + @include disable-default-a-behaviour; + + color: pvar(--mainForegroundColor); + + font-weight: $font-semibold; + } + + } + + // Paragraphs + ::ng-deep p { + margin-bottom: .3rem; + } + + &.comment-html-deleted { + color: pvar(--greyForegroundColor); + margin-bottom: 1rem; + } +} + +.comment-actions { + margin-bottom: 10px; + display: flex; + + ::ng-deep .dropdown-toggle, + .comment-action-reply { + @include margin-right(10px); + + color: pvar(--greyForegroundColor); + cursor: pointer; + + &:hover, + &:active, + &:focus, + &:focus-visible { + color: pvar(--mainForegroundColor); + } + } + + ::ng-deep .action-button { + background-color: transparent; + padding: 0; + font-weight: unset; + } +} + +my-video-comment-add { + ::ng-deep form { + margin-top: 1rem; + margin-bottom: 0; + } +} + +.is-child { + // Reduce avatars size for replies + my-actor-avatar { + @include actor-avatar-size(25px); + } + + .left { + @include margin-right(6px); + } +} + +@media screen and (max-width: 1200px) { + .children { + @include margin-left(-10px); + } +} + +@media screen and (max-width: 600px) { + .children { + @include margin-left(-20px); + + .left { + align-items: flex-start; + + .vertical-border { + @include margin-left(2px); + } + } + } + + .comment-account-date { + flex-direction: column; + + .comment-date { + @include margin-left(0); + } + } +} 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 @@ + +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, ViewChild } from '@angular/core' +import { MarkdownService, Notifier, UserService } from '@app/core' +import { AuthService } from '@app/core/auth' +import { Account, DropdownAction, Video } from '@app/shared/shared-main' +import { CommentReportComponent } from '@app/shared/shared-moderation/report-modals/comment-report.component' +import { VideoComment, VideoCommentThreadTree } from '@app/shared/shared-video-comment' +import { User, UserRight } from '@shared/models' + +@Component({ + selector: 'my-video-comment', + templateUrl: './video-comment.component.html', + styleUrls: ['./video-comment.component.scss'] +}) +export class VideoCommentComponent implements OnInit, OnChanges { + @ViewChild('commentReportModal') commentReportModal: CommentReportComponent + + @Input() video: Video + @Input() comment: VideoComment + @Input() parentComments: VideoComment[] = [] + @Input() commentTree: VideoCommentThreadTree + @Input() inReplyToCommentId: number + @Input() highlightedComment = false + @Input() firstInThread = false + @Input() redraftValue?: string + + @Output() wantedToReply = new EventEmitter() + @Output() wantedToDelete = new EventEmitter() + @Output() wantedToRedraft = new EventEmitter() + @Output() threadCreated = new EventEmitter() + @Output() resetReply = new EventEmitter() + @Output() timestampClicked = new EventEmitter() + + prependModerationActions: DropdownAction[] + + sanitizedCommentHTML = '' + newParentComments: VideoComment[] = [] + + commentAccount: Account + commentUser: User + + constructor ( + private markdownService: MarkdownService, + private authService: AuthService, + private userService: UserService, + private notifier: Notifier + ) {} + + get user () { + return this.authService.getUser() + } + + ngOnInit () { + this.init() + } + + ngOnChanges () { + this.init() + } + + onCommentReplyCreated (createdComment: VideoComment) { + if (!this.commentTree) { + this.commentTree = { + comment: this.comment, + hasDisplayedChildren: false, + children: [] + } + + this.threadCreated.emit(this.commentTree) + } + + this.commentTree.children.unshift({ + comment: createdComment, + hasDisplayedChildren: false, + children: [] + }) + + this.resetReply.emit() + + this.redraftValue = undefined + } + + onWantToReply (comment?: VideoComment) { + this.wantedToReply.emit(comment || this.comment) + } + + onWantToDelete (comment?: VideoComment) { + this.wantedToDelete.emit(comment || this.comment) + } + + onWantToRedraft (comment?: VideoComment) { + this.wantedToRedraft.emit(comment || this.comment) + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + onResetReply () { + this.resetReply.emit() + } + + handleTimestampClicked (timestamp: number) { + this.timestampClicked.emit(timestamp) + } + + isRemovableByUser () { + return this.comment.account && this.isUserLoggedIn() && + ( + this.user.account.id === this.comment.account.id || + this.user.account.id === this.video.account.id || + this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) + ) + } + + isRedraftableByUser () { + return ( + this.comment.account && + this.isUserLoggedIn() && + this.user.account.id === this.comment.account.id && + this.comment.totalReplies === 0 + ) + } + + isReportableByUser () { + return ( + this.comment.account && + this.isUserLoggedIn() && + this.comment.isDeleted === false && + this.user.account.id !== this.comment.account.id + ) + } + + isCommentDisplayed () { + // Not deleted + return !this.comment.isDeleted || + this.comment.totalReplies !== 0 || // Or root comment thread has replies + (this.commentTree?.hasDisplayedChildren) // Or this is a reply that have other replies + } + + isChild () { + return this.parentComments.length !== 0 + } + + private getUserIfNeeded (account: Account) { + if (!account.userId) return + if (!this.authService.isLoggedIn()) return + + const user = this.authService.getUser() + if (user.hasRight(UserRight.MANAGE_USERS)) { + this.userService.getUserWithCache(account.userId) + .subscribe( + user => this.commentUser = user, + + err => this.notifier.error(err.message) + ) + } + } + + private async init () { + // Before HTML rendering restore line feed for markdown list compatibility + const commentText = this.comment.text.replace(//g, '\r\n') + const html = await this.markdownService.textMarkdownToHTML(commentText, true, true) + this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html) + this.newParentComments = this.parentComments.concat([ this.comment ]) + + if (this.comment.account) { + this.commentAccount = new Account(this.comment.account) + this.getUserIfNeeded(this.commentAccount) + } else { + this.comment.account = null + } + + this.prependModerationActions = [] + + if (this.isReportableByUser()) { + this.prependModerationActions.push({ + label: $localize`Report this comment`, + iconName: 'flag', + handler: () => this.showReportModal() + }) + } + + if (this.isRemovableByUser()) { + this.prependModerationActions.push({ + label: $localize`Remove`, + iconName: 'delete', + handler: () => this.onWantToDelete() + }) + } + + if (this.isRedraftableByUser()) { + this.prependModerationActions.push({ + label: $localize`Remove & re-draft`, + iconName: 'edit', + handler: () => this.onWantToRedraft() + }) + } + + if (this.prependModerationActions.length === 0) { + this.prependModerationActions = undefined + } + } + + private showReportModal () { + this.commentReportModal.show() + } +} 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 @@ +
+
+

+ {totalNotDeletedComments, plural, =0 {Comments} =1 {1 Comment} other {{{totalNotDeletedComments}} Comments}} +

+ + + + +
+ + + + +
No comments.
+ +
+
+
+ +
+ +
+ +
+ + + + + + + View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}} from {{ video?.account?.displayName || 'the author' }} and others + + + View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}} from {{ video?.account?.displayName || 'the author' }} + + + + View {comment.totalReplies, plural, =1 {1 reply} other {{{ comment.totalReplies }} replies}} + + +
+
+ +
+
+
+ +
+ Comments are disabled. +
+
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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +#highlighted-comment { + margin-bottom: 25px; +} + +.view-replies { + font-weight: $font-semibold; + font-size: 15px; + cursor: pointer; +} + +.glyphicon, +.comment-thread-loading { + @include margin-right(5px); + + display: inline-block; + font-size: 13px; +} + +.title-block { + .title-page { + @include margin-right(0); + } + + my-feed { + @include margin-left(5px); + + display: inline-block; + opacity: 0; + transition: ease-in .2s opacity; + width: 12px; + position: relative; + top: -3px; + } + + &:hover my-feed { + opacity: 1; + } +} + +#dropdown-sort-comments { + font-weight: 600; + text-transform: uppercase; + border: 0; + transform: translateY(-7%); +} + +@media screen and (max-width: 600px) { + .view-replies { + @include margin-left(46px); + } +} + +@media screen and (max-width: 450px) { + .view-replies { + font-size: 14px; + } +} 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 @@ +import { Subject, Subscription } from 'rxjs' +import { Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core' +import { ActivatedRoute } from '@angular/router' +import { AuthService, ComponentPagination, ConfirmService, hasMoreItems, Notifier, User } from '@app/core' +import { HooksService } from '@app/core/plugins/hooks.service' +import { Syndication, VideoDetails } from '@app/shared/shared-main' +import { VideoComment, VideoCommentService, VideoCommentThreadTree } from '@app/shared/shared-video-comment' + +@Component({ + selector: 'my-video-comments', + templateUrl: './video-comments.component.html', + styleUrls: ['./video-comments.component.scss'] +}) +export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { + @ViewChild('commentHighlightBlock') commentHighlightBlock: ElementRef + @Input() video: VideoDetails + @Input() user: User + + @Output() timestampClicked = new EventEmitter() + + comments: VideoComment[] = [] + highlightedThread: VideoComment + + sort = '-createdAt' + + componentPagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 10, + totalItems: null + } + totalNotDeletedComments: number + + inReplyToCommentId: number + commentReplyRedraftValue: string + commentThreadRedraftValue: string + + threadComments: { [ id: number ]: VideoCommentThreadTree } = {} + threadLoading: { [ id: number ]: boolean } = {} + + syndicationItems: Syndication[] = [] + + onDataSubject = new Subject() + + private sub: Subscription + + constructor ( + private authService: AuthService, + private notifier: Notifier, + private confirmService: ConfirmService, + private videoCommentService: VideoCommentService, + private activatedRoute: ActivatedRoute, + private hooks: HooksService + ) {} + + ngOnInit () { + // Find highlighted comment in params + this.sub = this.activatedRoute.params.subscribe( + params => { + if (params['threadId']) { + const highlightedThreadId = +params['threadId'] + this.processHighlightedThread(highlightedThreadId) + } + } + ) + } + + ngOnChanges (changes: SimpleChanges) { + if (changes['video']) { + this.resetVideo() + } + } + + ngOnDestroy () { + if (this.sub) this.sub.unsubscribe() + } + + viewReplies (commentId: number, highlightThread = false) { + this.threadLoading[commentId] = true + + const params = { + videoId: this.video.id, + threadId: commentId + } + + const obs = this.hooks.wrapObsFun( + this.videoCommentService.getVideoThreadComments.bind(this.videoCommentService), + params, + 'video-watch', + 'filter:api.video-watch.video-thread-replies.list.params', + 'filter:api.video-watch.video-thread-replies.list.result' + ) + + obs.subscribe( + res => { + this.threadComments[commentId] = res + this.threadLoading[commentId] = false + this.hooks.runAction('action:video-watch.video-thread-replies.loaded', 'video-watch', { data: res }) + + if (highlightThread) { + this.highlightedThread = new VideoComment(res.comment) + + // Scroll to the highlighted thread + setTimeout(() => this.commentHighlightBlock.nativeElement.scrollIntoView(), 0) + } + }, + + err => this.notifier.error(err.message) + ) + } + + loadMoreThreads () { + const params = { + videoId: this.video.id, + componentPagination: this.componentPagination, + sort: this.sort + } + + const obs = this.hooks.wrapObsFun( + this.videoCommentService.getVideoCommentThreads.bind(this.videoCommentService), + params, + 'video-watch', + 'filter:api.video-watch.video-threads.list.params', + 'filter:api.video-watch.video-threads.list.result' + ) + + obs.subscribe( + res => { + this.comments = this.comments.concat(res.data) + this.componentPagination.totalItems = res.total + this.totalNotDeletedComments = res.totalNotDeletedComments + + this.onDataSubject.next(res.data) + this.hooks.runAction('action:video-watch.video-threads.loaded', 'video-watch', { data: this.componentPagination }) + }, + + err => this.notifier.error(err.message) + ) + } + + onCommentThreadCreated (comment: VideoComment) { + this.comments.unshift(comment) + this.commentThreadRedraftValue = undefined + } + + onWantedToReply (comment: VideoComment) { + this.inReplyToCommentId = comment.id + } + + onResetReply () { + this.inReplyToCommentId = undefined + this.commentReplyRedraftValue = undefined + } + + onThreadCreated (commentTree: VideoCommentThreadTree) { + this.viewReplies(commentTree.comment.id) + } + + handleSortChange (sort: string) { + if (this.sort === sort) return + + this.sort = sort + this.resetVideo() + } + + handleTimestampClicked (timestamp: number) { + this.timestampClicked.emit(timestamp) + } + + async onWantedToDelete ( + commentToDelete: VideoComment, + title = $localize`Delete`, + message = $localize`Do you really want to delete this comment?` + ): Promise { + if (commentToDelete.isLocal || this.video.isLocal) { + message += $localize` The deletion will be sent to remote instances so they can reflect the change.` + } else { + message += $localize` It is a remote comment, so the deletion will only be effective on your instance.` + } + + const res = await this.confirmService.confirm(message, title) + if (res === false) return false + + this.videoCommentService.deleteVideoComment(commentToDelete.videoId, commentToDelete.id) + .subscribe( + () => { + if (this.highlightedThread?.id === commentToDelete.id) { + commentToDelete = this.comments.find(c => c.id === commentToDelete.id) + + this.highlightedThread = undefined + } + + // Mark the comment as deleted + this.softDeleteComment(commentToDelete) + }, + + err => this.notifier.error(err.message) + ) + + return true + } + + async onWantedToRedraft (commentToRedraft: VideoComment) { + const confirm = await this.onWantedToDelete(commentToRedraft, $localize`Delete and re-draft`, $localize`Do you really want to delete and re-draft this comment?`) + + if (confirm) { + this.inReplyToCommentId = commentToRedraft.inReplyToCommentId + + // Restore line feed for editing + const commentToRedraftText = commentToRedraft.text.replace(//g, '\r\n') + + if (commentToRedraft.threadId === commentToRedraft.id) { + this.commentThreadRedraftValue = commentToRedraftText + } else { + this.commentReplyRedraftValue = commentToRedraftText + } + + } + } + + isUserLoggedIn () { + return this.authService.isLoggedIn() + } + + onNearOfBottom () { + if (hasMoreItems(this.componentPagination)) { + this.componentPagination.currentPage++ + this.loadMoreThreads() + } + } + + private softDeleteComment (comment: VideoComment) { + comment.isDeleted = true + comment.deletedAt = new Date() + comment.text = '' + comment.account = null + } + + private resetVideo () { + if (this.video.commentsEnabled === true) { + // Reset all our fields + this.highlightedThread = null + this.comments = [] + this.threadComments = {} + this.threadLoading = {} + this.inReplyToCommentId = undefined + this.componentPagination.currentPage = 1 + this.componentPagination.totalItems = null + this.totalNotDeletedComments = null + + this.syndicationItems = this.videoCommentService.getVideoCommentsFeeds(this.video) + this.loadMoreThreads() + } + } + + private processHighlightedThread (highlightedThreadId: number) { + this.highlightedThread = this.comments.find(c => c.id === highlightedThreadId) + + const highlightThread = true + this.viewReplies(highlightedThreadId, highlightThread) + } +} 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 @@ +export * from './comment' +export * from './metadata' +export * from './playlist' +export * from './recommendations' +export * 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 @@ +export * from './video-avatar-channel.component' +export * from './video-description.component' +export * 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 @@ +
+ + + +
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 @@ +@use '_mixins' as *; + +@mixin main { + @include actor-avatar-size(35px); +} + +@mixin secondary { + height: 60%; + width: 60%; + position: absolute; + bottom: -5px; + right: -5px; + background-color: rgba(0, 0, 0, 0); +} + +.wrapper { + @include actor-avatar-size(35px); + @include margin-right(5px); + + position: relative; + margin-bottom: 5px; + + &.generic-channel { + .account { + @include main(); + } + + .channel { + display: none !important; + } + } + + &:not(.generic-channel) { + .account { + @include secondary(); + } + + .channel { + @include main(); + } + } +} 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 @@ +import { Component, Input, OnInit } from '@angular/core' +import { Video } from '@app/shared/shared-main/video' + +@Component({ + selector: 'my-video-avatar-channel', + templateUrl: './video-avatar-channel.component.html', + styleUrls: [ './video-avatar-channel.component.scss' ] +}) +export class VideoAvatarChannelComponent implements OnInit { + @Input() video: Video + @Input() byAccount: string + + @Input() genericChannel: boolean + + channelLinkTitle = '' + accountLinkTitle = '' + + ngOnInit () { + this.channelLinkTitle = $localize`${this.video.account.name} (channel page)` + this.accountLinkTitle = $localize`${this.video.byAccount} (account page)` + } + + isChannelAvatarNull () { + return this.video.channel.avatar === null + } +} 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 @@ +
+
+ +
+ Show more + + +
+ +
+ Show less + +
+
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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.video-info-description { + @include margin-left($video-watch-info-margin-left); + @include margin-right(0); + + margin-top: 20px; + margin-bottom: 20px; + font-size: 15px; + + .video-info-description-html { + @include peertube-word-wrap; + + ::ng-deep a { + text-decoration: none; + } + } + + .glyphicon, + .description-loading { + @include margin-left(3px); + } + + .description-loading { + display: inline-block; + } + + .video-info-description-more { + cursor: pointer; + font-weight: $font-semibold; + color: pvar(--greyForegroundColor); + font-size: 14px; + + .glyphicon { + position: relative; + top: 2px; + } + } +} + +@media screen and (max-width: 450px) { + .video-info-description { + font-size: 14px !important; + } +} 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 @@ +import { Component, EventEmitter, Inject, Input, LOCALE_ID, OnChanges, Output } from '@angular/core' +import { MarkdownService, Notifier } from '@app/core' +import { VideoDetails, VideoService } from '@app/shared/shared-main' + + +@Component({ + selector: 'my-video-description', + templateUrl: './video-description.component.html', + styleUrls: [ './video-description.component.scss' ] +}) +export class VideoDescriptionComponent implements OnChanges { + @Input() video: VideoDetails + + @Output() timestampClicked = new EventEmitter() + + descriptionLoading = false + completeDescriptionShown = false + completeVideoDescription: string + shortVideoDescription: string + videoHTMLDescription = '' + + constructor ( + private videoService: VideoService, + private notifier: Notifier, + private markdownService: MarkdownService, + @Inject(LOCALE_ID) private localeId: string + ) { } + + ngOnChanges () { + this.descriptionLoading = false + this.completeDescriptionShown = false + this.completeVideoDescription = undefined + + this.setVideoDescriptionHTML() + } + + showMoreDescription () { + if (this.completeVideoDescription === undefined) { + return this.loadCompleteDescription() + } + + this.updateVideoDescription(this.completeVideoDescription) + this.completeDescriptionShown = true + } + + showLessDescription () { + this.updateVideoDescription(this.shortVideoDescription) + this.completeDescriptionShown = false + } + + loadCompleteDescription () { + this.descriptionLoading = true + + this.videoService.loadCompleteDescription(this.video.descriptionPath) + .subscribe( + description => { + this.completeDescriptionShown = true + this.descriptionLoading = false + + this.shortVideoDescription = this.video.description + this.completeVideoDescription = description + + this.updateVideoDescription(this.completeVideoDescription) + }, + + error => { + this.descriptionLoading = false + this.notifier.error(error.message) + } + ) + } + + onTimestampClicked (timestamp: number) { + this.timestampClicked.emit(timestamp) + } + + private updateVideoDescription (description: string) { + this.video.description = description + this.setVideoDescriptionHTML() + .catch(err => console.error(err)) + } + + private async setVideoDescriptionHTML () { + const html = await this.markdownService.textMarkdownToHTML(this.video.description) + this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html) + } +} 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 @@ + + + + + + + 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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.action-button-like, +.action-button-dislike { + filter: brightness(120%); + + .count { + margin: 0 5px; + } +} + +.activated { + color: pvar(--activatedActionButtonColor) !important; +} 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 @@ +import { Hotkey, HotkeysService } from 'angular2-hotkeys' +import { Observable } from 'rxjs' +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core' +import { Notifier, ScreenService } from '@app/core' +import { VideoDetails, VideoService } from '@app/shared/shared-main' +import { UserVideoRateType } from '@shared/models' + +@Component({ + selector: 'my-video-rate', + templateUrl: './video-rate.component.html', + styleUrls: [ './video-rate.component.scss' ] +}) +export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { + @Input() video: VideoDetails + @Input() isUserLoggedIn: boolean + + @Output() userRatingLoaded = new EventEmitter() + @Output() rateUpdated = new EventEmitter() + + userRating: UserVideoRateType + + tooltipLike = '' + tooltipDislike = '' + + private hotkeys: Hotkey[] + + constructor ( + private videoService: VideoService, + private notifier: Notifier, + private hotkeysService: HotkeysService, + private screenService: ScreenService + ) { } + + async ngOnInit () { + // Hide the tooltips for unlogged users in mobile view, this adds confusion with the popover + if (this.isUserLoggedIn || !this.screenService.isInMobileView()) { + this.tooltipLike = $localize`Like this video` + this.tooltipDislike = $localize`Dislike this video` + } + + if (this.isUserLoggedIn) { + this.hotkeys = [ + new Hotkey('shift+l', () => { + this.setLike() + return false + }, undefined, $localize`Like the video`), + + new Hotkey('shift+d', () => { + this.setDislike() + return false + }, undefined, $localize`Dislike the video`) + ] + + this.hotkeysService.add(this.hotkeys) + } + } + + ngOnChanges () { + this.checkUserRating() + } + + ngOnDestroy () { + this.hotkeysService.remove(this.hotkeys) + } + + setLike () { + if (this.isUserLoggedIn === false) return + + // Already liked this video + if (this.userRating === 'like') this.setRating('none') + else this.setRating('like') + } + + setDislike () { + if (this.isUserLoggedIn === false) return + + // Already disliked this video + if (this.userRating === 'dislike') this.setRating('none') + else this.setRating('dislike') + } + + getRatePopoverText () { + if (this.isUserLoggedIn) return undefined + + return $localize`You need to be logged in to rate this video.` + } + + private checkUserRating () { + // Unlogged users do not have ratings + if (this.isUserLoggedIn === false) return + + this.videoService.getUserVideoRating(this.video.id) + .subscribe( + ratingObject => { + if (!ratingObject) return + + this.userRating = ratingObject.rating + this.userRatingLoaded.emit(this.userRating) + }, + + err => this.notifier.error(err.message) + ) + } + + private setRating (nextRating: UserVideoRateType) { + const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable } = { + like: this.videoService.setVideoLike, + dislike: this.videoService.setVideoDislike, + none: this.videoService.unsetVideoLike + } + + ratingMethods[nextRating].call(this.videoService, this.video.id) + .subscribe( + () => { + // Update the video like attribute + this.updateVideoRating(this.userRating, nextRating) + this.userRating = nextRating + this.rateUpdated.emit(this.userRating) + }, + + (err: { message: string }) => this.notifier.error(err.message) + ) + } + + private updateVideoRating (oldRating: UserVideoRateType, newRating: UserVideoRateType) { + let likesToIncrement = 0 + let dislikesToIncrement = 0 + + if (oldRating) { + if (oldRating === 'like') likesToIncrement-- + if (oldRating === 'dislike') dislikesToIncrement-- + } + + if (newRating === 'like') likesToIncrement++ + if (newRating === 'dislike') dislikesToIncrement++ + + this.video.likes += likesToIncrement + this.video.dislikes += dislikesToIncrement + + this.video.buildLikeAndDislikePercents() + } +} 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 @@ +
+
+
+ {{ playlist.displayName }} + + Unlisted + Private + Public +
+ +
+
{{ playlist.ownerBy }}
+
+ {{ currentPlaylistPosition }}{{ playlistPagination.totalItems }} +
+
+ +
+ + + +
+
+ +
+ +
+
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 @@ +@use '_variables' as *; +@use '_mixins' as *; +@use '_bootstrap-variables'; +@use '_miniature' as *; + +.playlist { + min-width: 200px; + max-width: 470px; + height: 66vh; + background-color: pvar(--mainBackgroundColor); + overflow-y: auto; + border-bottom: 1px solid $separator-border-color; + + .playlist-info { + padding: 5px 30px; + background-color: #e4e4e4; + + .playlist-display-name { + font-size: 18px; + font-weight: $font-semibold; + margin-bottom: 5px; + } + + .playlist-by-index { + color: pvar(--greyForegroundColor); + display: flex; + + .playlist-by { + @include margin-right(5px); + } + + .playlist-index span:first-child::after { + content: '/'; + margin: 0 3px; + } + } + + .playlist-controls { + display: flex; + margin: 10px 0; + + my-global-icon:not(:last-child) { + @include margin-right(.5rem); + } + + my-global-icon { + &:not(.active) { + opacity: .5; + } + + ::ng-deep { + cursor: pointer; + } + } + } + } + + my-video-playlist-element-miniature { + ::ng-deep { + .video { + .position { + @include margin-right(0); + } + + .video-info { + .video-info-name { + font-size: 15px; + } + } + } + + my-video-thumbnail { + @include thumbnail-size-component(90px, 50px); + } + + .fake-thumbnail { + width: 90px; + height: 50px; + } + } + } +} + 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 @@ + +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { Router } from '@angular/router' +import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core' +import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist' +import { peertubeLocalStorage, peertubeSessionStorage } from '@root-helpers/peertube-web-storage' +import { VideoPlaylistPrivacy } from '@shared/models' + +@Component({ + selector: 'my-video-watch-playlist', + templateUrl: './video-watch-playlist.component.html', + styleUrls: [ './video-watch-playlist.component.scss' ] +}) +export class VideoWatchPlaylistComponent { + static LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'auto_play_video_playlist' + static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'loop_playlist' + + @Input() playlist: VideoPlaylist + + @Output() videoFound = new EventEmitter() + + playlistElements: VideoPlaylistElement[] = [] + playlistPagination: ComponentPagination = { + currentPage: 1, + itemsPerPage: 30, + totalItems: null + } + + autoPlayNextVideoPlaylist: boolean + autoPlayNextVideoPlaylistSwitchText = '' + loopPlaylist: boolean + loopPlaylistSwitchText = '' + noPlaylistVideos = false + + currentPlaylistPosition: number + + constructor ( + private userService: UserService, + private auth: AuthService, + private notifier: Notifier, + private videoPlaylist: VideoPlaylistService, + private localStorageService: LocalStorageService, + private sessionStorageService: SessionStorageService, + private router: Router + ) { + // defaults to true + this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn() + ? this.auth.getUser().autoPlayNextVideoPlaylist + : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false' + + this.setAutoPlayNextVideoPlaylistSwitchText() + + // defaults to false + this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' + this.setLoopPlaylistSwitchText() + } + + onPlaylistVideosNearOfBottom (position?: number) { + // Last page + if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return + + this.playlistPagination.currentPage += 1 + this.loadPlaylistElements(this.playlist, false, position) + } + + onElementRemoved (playlistElement: VideoPlaylistElement) { + this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id) + + this.playlistPagination.totalItems-- + } + + isPlaylistOwned () { + return this.playlist.isLocal === true && + this.auth.isLoggedIn() && + this.playlist.ownerAccount.name === this.auth.getUser().username + } + + isUnlistedPlaylist () { + return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED + } + + isPrivatePlaylist () { + return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE + } + + isPublicPlaylist () { + return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC + } + + loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false, position?: number) { + this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination) + .subscribe(({ total, data }) => { + this.playlistElements = this.playlistElements.concat(data) + this.playlistPagination.totalItems = total + + const firstAvailableVideo = this.playlistElements.find(e => !!e.video) + if (!firstAvailableVideo) { + this.noPlaylistVideos = true + return + } + + if (position) this.updatePlaylistIndex(position) + + if (redirectToFirst) { + const extras = { + queryParams: { + start: firstAvailableVideo.startTimestamp, + stop: firstAvailableVideo.stopTimestamp, + playlistPosition: firstAvailableVideo.position + }, + replaceUrl: true + } + this.router.navigate([], extras) + } + }) + } + + updatePlaylistIndex (position: number) { + if (this.playlistElements.length === 0 || !position) return + + // Handle the reverse index + if (position < 0) position = this.playlist.videosLength + position + 1 + + for (const playlistElement of this.playlistElements) { + // >= if the previous videos were not valid + if (playlistElement.video && playlistElement.position >= position) { + this.currentPlaylistPosition = playlistElement.position + + this.videoFound.emit(playlistElement.video.uuid) + + setTimeout(() => { + document.querySelector('.element-' + this.currentPlaylistPosition).scrollIntoView(false) + }, 0) + + return + } + } + + // Load more videos to find our video + this.onPlaylistVideosNearOfBottom(position) + } + + navigateToPreviousPlaylistVideo () { + const previous = this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous') + if (!previous) return + + const start = previous.startTimestamp + const stop = previous.stopTimestamp + this.router.navigate([],{ queryParams: { playlistPosition: previous.position, start, stop } }) + } + + findPlaylistVideo (position: number, type: 'previous' | 'next'): VideoPlaylistElement { + if ( + (type === 'next' && position > this.playlistPagination.totalItems) || + (type === 'previous' && position < 1) + ) { + // End of the playlist: end the recursion if we're not in the loop mode + if (!this.loopPlaylist) return + + // Loop mode + position = type === 'previous' + ? this.playlistPagination.totalItems + : 1 + } + + const found = this.playlistElements.find(e => e.position === position) + if (found && found.video) return found + + const newPosition = type === 'previous' + ? position - 1 + : position + 1 + + return this.findPlaylistVideo(newPosition, type) + } + + navigateToNextPlaylistVideo () { + const next = this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next') + if (!next) return + + const start = next.startTimestamp + const stop = next.stopTimestamp + this.router.navigate([],{ queryParams: { playlistPosition: next.position, start, stop } }) + } + + switchAutoPlayNextVideoPlaylist () { + this.autoPlayNextVideoPlaylist = !this.autoPlayNextVideoPlaylist + this.setAutoPlayNextVideoPlaylistSwitchText() + + peertubeLocalStorage.setItem( + VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, + this.autoPlayNextVideoPlaylist.toString() + ) + + if (this.auth.isLoggedIn()) { + const details = { + autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist + } + + this.userService.updateMyProfile(details).subscribe( + () => { + this.auth.refreshUserInformation() + }, + err => this.notifier.error(err.message) + ) + } + } + + switchLoopPlaylist () { + this.loopPlaylist = !this.loopPlaylist + this.setLoopPlaylistSwitchText() + + peertubeSessionStorage.setItem( + VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, + this.loopPlaylist.toString() + ) + } + + private setAutoPlayNextVideoPlaylistSwitchText () { + this.autoPlayNextVideoPlaylistSwitchText = this.autoPlayNextVideoPlaylist + ? $localize`Stop autoplaying next video` + : $localize`Autoplay next video` + } + + private setLoopPlaylistSwitchText () { + this.loopPlaylistSwitchText = this.loopPlaylist + ? $localize`Stop looping playlist videos` + : $localize`Loop playlist videos` + } +} 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 @@ +export * from './recent-videos-recommendation.service' +export * from './recommendation-info.model' +export * from './recommendations.module' +export * from './recommended-videos.component' +export * 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 @@ +import { Observable, of } from 'rxjs' +import { map, switchMap } from 'rxjs/operators' +import { Injectable } from '@angular/core' +import { ServerService, UserService } from '@app/core' +import { Video, VideoService } from '@app/shared/shared-main' +import { AdvancedSearch, SearchService } from '@app/shared/shared-search' +import { HTMLServerConfig } from '@shared/models' +import { RecommendationInfo } from './recommendation-info.model' +import { RecommendationService } from './recommendations.service' + +/** + * Provides "recommendations" by providing the most recently uploaded videos. + */ +@Injectable() +export class RecentVideosRecommendationService implements RecommendationService { + readonly pageSize = 5 + + private config: HTMLServerConfig + + constructor ( + private videos: VideoService, + private searchService: SearchService, + private userService: UserService, + private serverService: ServerService + ) { + this.config = this.serverService.getHTMLConfig() + } + + getRecommendations (recommendation: RecommendationInfo): Observable { + + return this.fetchPage(1, recommendation) + .pipe( + map(videos => { + const otherVideos = videos.filter(v => v.uuid !== recommendation.uuid) + return otherVideos.slice(0, this.pageSize) + }) + ) + } + + private fetchPage (page: number, recommendation: RecommendationInfo): Observable { + const pagination = { currentPage: page, itemsPerPage: this.pageSize + 1 } + const defaultSubscription = this.videos.getVideos({ videoPagination: pagination, sort: '-createdAt' }) + .pipe(map(v => v.data)) + + const tags = recommendation.tags + const searchIndexConfig = this.config.search.searchIndex + if ( + !tags || tags.length === 0 || + (searchIndexConfig.enabled === true && searchIndexConfig.disableLocalSearch === true) + ) { + return defaultSubscription + } + + return this.userService.getAnonymousOrLoggedUser() + .pipe( + map(user => { + return { + search: '', + componentPagination: pagination, + advancedSearch: new AdvancedSearch({ + tagsOneOf: recommendation.tags.join(','), + sort: '-publishedAt', + searchTarget: 'local', + nsfw: user.nsfwPolicy + ? this.videos.nsfwPolicyToParam(user.nsfwPolicy) + : undefined + }) + } + }), + switchMap(params => this.searchService.searchVideos(params)), + map(v => v.data), + switchMap(videos => { + if (videos.length <= 1) return defaultSubscription + + return of(videos) + }) + ) + } +} 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 @@ +export interface RecommendationInfo { + uuid: string + tags?: string[] +} 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 @@ + +import { CommonModule } from '@angular/common' +import { NgModule } from '@angular/core' +import { SharedFormModule } from '@app/shared/shared-forms' +import { SharedMainModule } from '@app/shared/shared-main' +import { SharedSearchModule } from '@app/shared/shared-search' +import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' +import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' +import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' +import { RecommendedVideosComponent } from './recommended-videos.component' +import { RecommendedVideosStore } from './recommended-videos.store' + +@NgModule({ + imports: [ + CommonModule, + + SharedMainModule, + SharedSearchModule, + SharedVideoPlaylistModule, + SharedVideoMiniatureModule, + SharedFormModule + ], + declarations: [ + RecommendedVideosComponent + ], + exports: [ + RecommendedVideosComponent + ], + providers: [ + RecommendedVideosStore, + RecentVideosRecommendationService + ] +}) +export class RecommendationsModule { +} 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 @@ +import { Observable } from 'rxjs' +import { Video } from '@app/shared/shared-main' +import { RecommendationInfo } from './recommendation-info.model' + +export interface RecommendationService { + getRecommendations (recommendation: RecommendationInfo): Observable +} 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 @@ +
+ +
+

+ Other videos +

+
+ AUTOPLAY + +
+
+ + + + + +
+
+
+
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 @@ +@use '_variables' as *; +@use '_mixins' as *; + +.title-page-container { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 25px; + flex-wrap: wrap-reverse; + + .title-page.active, + .title-page.title-page-single { + @include margin-right(.5rem !important); + + margin-bottom: unset; + } +} + +.title-page { + margin-top: 0; +} + +.title-page-autoplay { + @include margin-left(auto); + + display: flex; + width: max-content; + height: max-content; + align-items: center; + + span { + @include margin-right(0.3rem); + + text-transform: uppercase; + font-size: 85%; + font-weight: 600; + } +} + +hr { + margin-top: 0; +} + +my-video-miniature { + display: block; +} + +.other-videos:not(.display-as-row) my-video-miniature { + min-width: $video-thumbnail-medium-width; + max-width: $video-thumbnail-medium-width; +} + +.display-as-row { + my-video-miniature { + margin-bottom: 20px; + } + + hr { + display: none; + } + + @media screen and (max-width: $mobile-view) { + my-video-miniature { + margin-bottom: 10px; + } + } +} + 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 @@ +import { Observable } from 'rxjs' +import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core' +import { AuthService, Notifier, SessionStorageService, User, UserService } from '@app/core' +import { Video } from '@app/shared/shared-main' +import { MiniatureDisplayOptions } from '@app/shared/shared-video-miniature' +import { VideoPlaylist } from '@app/shared/shared-video-playlist' +import { UserLocalStorageKeys } from '@root-helpers/users' +import { RecommendationInfo } from './recommendation-info.model' +import { RecommendedVideosStore } from './recommended-videos.store' + +@Component({ + selector: 'my-recommended-videos', + templateUrl: './recommended-videos.component.html', + styleUrls: [ './recommended-videos.component.scss' ] +}) +export class RecommendedVideosComponent implements OnInit, OnChanges { + @Input() inputRecommendation: RecommendationInfo + @Input() playlist: VideoPlaylist + @Input() displayAsRow: boolean + + @Output() gotRecommendations = new EventEmitter() + + autoPlayNextVideo: boolean + autoPlayNextVideoTooltip: string + + displayOptions: MiniatureDisplayOptions = { + date: true, + views: true, + by: true, + avatar: true + } + + userMiniature: User + + readonly hasVideos$: Observable + readonly videos$: Observable + + constructor ( + private userService: UserService, + private authService: AuthService, + private notifier: Notifier, + private store: RecommendedVideosStore, + private sessionStorageService: SessionStorageService + ) { + this.videos$ = this.store.recommendations$ + this.hasVideos$ = this.store.hasRecommendations$ + this.videos$.subscribe(videos => this.gotRecommendations.emit(videos)) + + if (this.authService.isLoggedIn()) { + this.autoPlayNextVideo = this.authService.getUser().autoPlayNextVideo + } else { + this.autoPlayNextVideo = this.sessionStorageService.getItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' + + this.sessionStorageService.watch([UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO]).subscribe( + () => { + this.autoPlayNextVideo = this.sessionStorageService.getItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO) === 'true' + } + ) + } + + this.autoPlayNextVideoTooltip = $localize`When active, the next video is automatically played after the current one.` + } + + ngOnInit () { + this.userService.getAnonymousOrLoggedUser() + .subscribe(user => this.userMiniature = user) + } + + ngOnChanges () { + if (this.inputRecommendation) { + this.store.requestNewRecommendations(this.inputRecommendation) + } + } + + onVideoRemoved () { + this.store.requestNewRecommendations(this.inputRecommendation) + } + + switchAutoPlayNextVideo () { + this.sessionStorageService.setItem(UserLocalStorageKeys.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO, this.autoPlayNextVideo.toString()) + + if (this.authService.isLoggedIn()) { + const details = { + autoPlayNextVideo: this.autoPlayNextVideo + } + + this.userService.updateMyProfile(details).subscribe( + () => { + this.authService.refreshUserInformation() + }, + err => this.notifier.error(err.message) + ) + } + } +} 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 @@ +import { Observable, ReplaySubject } from 'rxjs' +import { map, shareReplay, switchMap, take } from 'rxjs/operators' +import { Inject, Injectable } from '@angular/core' +import { Video } from '@app/shared/shared-main' +import { RecentVideosRecommendationService } from './recent-videos-recommendation.service' +import { RecommendationInfo } from './recommendation-info.model' +import { RecommendationService } from './recommendations.service' + +/** + * This store is intended to provide data for the RecommendedVideosComponent. + */ +@Injectable() +export class RecommendedVideosStore { + public readonly recommendations$: Observable + public readonly hasRecommendations$: Observable + private readonly requestsForLoad$$ = new ReplaySubject(1) + + constructor ( + @Inject(RecentVideosRecommendationService) private recommendations: RecommendationService + ) { + this.recommendations$ = this.requestsForLoad$$.pipe( + switchMap(requestedRecommendation => { + return this.recommendations.getRecommendations(requestedRecommendation) + .pipe(take(1)) + }), + shareReplay() + ) + + this.hasRecommendations$ = this.recommendations$.pipe( + map(otherVideos => otherVideos.length > 0) + ) + } + + requestNewRecommendations (recommend: RecommendationInfo) { + this.requestsForLoad$$.next(recommend) + } +} 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 @@ +import { Directive, EventEmitter, HostListener, Output } from '@angular/core' + +@Directive({ + selector: '[timestampRouteTransformer]' +}) +export class TimestampRouteTransformerDirective { + @Output() timestampClicked = new EventEmitter() + + @HostListener('click', ['$event']) + public onClick ($event: Event) { + const target = $event.target as HTMLLinkElement + + if (target.hasAttribute('href') !== true) return + + const ngxLink = document.createElement('a') + ngxLink.href = target.getAttribute('href') + + // we only care about reflective links + if (ngxLink.host !== window.location.host) return + + const ngxLinkParams = new URLSearchParams(ngxLink.search) + if (ngxLinkParams.has('start') !== true) return + + const separators = ['h', 'm', 's'] + const start = ngxLinkParams + .get('start') + .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator + .map(t => { + if (t.includes('h')) return parseInt(t, 10) * 3600 + if (t.includes('m')) return parseInt(t, 10) * 60 + return parseInt(t, 10) + }) + .reduce((acc, t) => acc + t) + + this.timestampClicked.emit(start) + + $event.preventDefault() + } +} diff --git a/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts b/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts deleted file mode 100644 index 45e023695..000000000 --- a/client/src/app/+videos/+video-watch/timestamp-route-transformer.directive.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Directive, EventEmitter, HostListener, Output } from '@angular/core' - -@Directive({ - selector: '[timestampRouteTransformer]' -}) -export class TimestampRouteTransformerDirective { - @Output() timestampClicked = new EventEmitter() - - @HostListener('click', ['$event']) - public onClick ($event: Event) { - const target = $event.target as HTMLLinkElement - - if (target.hasAttribute('href') !== true) return - - const ngxLink = document.createElement('a') - ngxLink.href = target.getAttribute('href') - - // we only care about reflective links - if (ngxLink.host !== window.location.host) return - - const ngxLinkParams = new URLSearchParams(ngxLink.search) - if (ngxLinkParams.has('start') !== true) return - - const separators = ['h', 'm', 's'] - const start = ngxLinkParams - .get('start') - .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator - .map(t => { - if (t.includes('h')) return parseInt(t, 10) * 3600 - if (t.includes('m')) return parseInt(t, 10) * 60 - return parseInt(t, 10) - }) - .reduce((acc, t) => acc + t) - - this.timestampClicked.emit(start) - - $event.preventDefault() - } -} diff --git a/client/src/app/+videos/+video-watch/video-avatar-channel.component.html b/client/src/app/+videos/+video-watch/video-avatar-channel.component.html deleted file mode 100644 index 5a7221858..000000000 --- a/client/src/app/+videos/+video-watch/video-avatar-channel.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
- - - -
diff --git a/client/src/app/+videos/+video-watch/video-avatar-channel.component.scss b/client/src/app/+videos/+video-watch/video-avatar-channel.component.scss deleted file mode 100644 index 1ff8fb96e..000000000 --- a/client/src/app/+videos/+video-watch/video-avatar-channel.component.scss +++ /dev/null @@ -1,42 +0,0 @@ -@use '_mixins' as *; - -@mixin main { - @include actor-avatar-size(35px); -} - -@mixin secondary { - height: 60%; - width: 60%; - position: absolute; - bottom: -5px; - right: -5px; - background-color: rgba(0, 0, 0, 0); -} - -.wrapper { - @include actor-avatar-size(35px); - @include margin-right(5px); - - position: relative; - margin-bottom: 5px; - - &.generic-channel { - .account { - @include main(); - } - - .channel { - display: none !important; - } - } - - &:not(.generic-channel) { - .account { - @include secondary(); - } - - .channel { - @include main(); - } - } -} diff --git a/client/src/app/+videos/+video-watch/video-avatar-channel.component.ts b/client/src/app/+videos/+video-watch/video-avatar-channel.component.ts deleted file mode 100644 index 63edd7bad..000000000 --- a/client/src/app/+videos/+video-watch/video-avatar-channel.component.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Component, Input, OnInit } from '@angular/core' -import { Video } from '@app/shared/shared-main/video' - -@Component({ - selector: 'my-video-avatar-channel', - templateUrl: './video-avatar-channel.component.html', - styleUrls: [ './video-avatar-channel.component.scss' ] -}) -export class VideoAvatarChannelComponent implements OnInit { - @Input() video: Video - @Input() byAccount: string - - @Input() genericChannel: boolean - - channelLinkTitle = '' - accountLinkTitle = '' - - ngOnInit () { - this.channelLinkTitle = $localize`${this.video.account.name} (channel page)` - this.accountLinkTitle = $localize`${this.video.byAccount} (account page)` - } - - isChannelAvatarNull () { - return this.video.channel.avatar === null - } -} diff --git a/client/src/app/+videos/+video-watch/video-description.component.html b/client/src/app/+videos/+video-watch/video-description.component.html deleted file mode 100644 index 57f682899..000000000 --- a/client/src/app/+videos/+video-watch/video-description.component.html +++ /dev/null @@ -1,19 +0,0 @@ -
-
- -
- Show more - - -
- -
- Show less - -
-
diff --git a/client/src/app/+videos/+video-watch/video-description.component.scss b/client/src/app/+videos/+video-watch/video-description.component.scss deleted file mode 100644 index fc8b4574c..000000000 --- a/client/src/app/+videos/+video-watch/video-description.component.scss +++ /dev/null @@ -1,46 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; - -.video-info-description { - @include margin-left($video-watch-info-margin-left); - @include margin-right(0); - - margin-top: 20px; - margin-bottom: 20px; - font-size: 15px; - - .video-info-description-html { - @include peertube-word-wrap; - - ::ng-deep a { - text-decoration: none; - } - } - - .glyphicon, - .description-loading { - @include margin-left(3px); - } - - .description-loading { - display: inline-block; - } - - .video-info-description-more { - cursor: pointer; - font-weight: $font-semibold; - color: pvar(--greyForegroundColor); - font-size: 14px; - - .glyphicon { - position: relative; - top: 2px; - } - } -} - -@media screen and (max-width: 450px) { - .video-info-description { - font-size: 14px !important; - } -} diff --git a/client/src/app/+videos/+video-watch/video-description.component.ts b/client/src/app/+videos/+video-watch/video-description.component.ts deleted file mode 100644 index 2ea3b206f..000000000 --- a/client/src/app/+videos/+video-watch/video-description.component.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Component, EventEmitter, Inject, Input, LOCALE_ID, OnChanges, Output } from '@angular/core' -import { MarkdownService, Notifier } from '@app/core' -import { VideoDetails, VideoService } from '@app/shared/shared-main' - - -@Component({ - selector: 'my-video-description', - templateUrl: './video-description.component.html', - styleUrls: [ './video-description.component.scss' ] -}) -export class VideoDescriptionComponent implements OnChanges { - @Input() video: VideoDetails - - @Output() timestampClicked = new EventEmitter() - - descriptionLoading = false - completeDescriptionShown = false - completeVideoDescription: string - shortVideoDescription: string - videoHTMLDescription = '' - - constructor ( - private videoService: VideoService, - private notifier: Notifier, - private markdownService: MarkdownService, - @Inject(LOCALE_ID) private localeId: string - ) { } - - ngOnChanges () { - this.descriptionLoading = false - this.completeDescriptionShown = false - this.completeVideoDescription = undefined - - this.setVideoDescriptionHTML() - } - - showMoreDescription () { - if (this.completeVideoDescription === undefined) { - return this.loadCompleteDescription() - } - - this.updateVideoDescription(this.completeVideoDescription) - this.completeDescriptionShown = true - } - - showLessDescription () { - this.updateVideoDescription(this.shortVideoDescription) - this.completeDescriptionShown = false - } - - loadCompleteDescription () { - this.descriptionLoading = true - - this.videoService.loadCompleteDescription(this.video.descriptionPath) - .subscribe( - description => { - this.completeDescriptionShown = true - this.descriptionLoading = false - - this.shortVideoDescription = this.video.description - this.completeVideoDescription = description - - this.updateVideoDescription(this.completeVideoDescription) - }, - - error => { - this.descriptionLoading = false - this.notifier.error(error.message) - } - ) - } - - onTimestampClicked (timestamp: number) { - this.timestampClicked.emit(timestamp) - } - - private updateVideoDescription (description: string) { - this.video.description = description - this.setVideoDescriptionHTML() - .catch(err => console.error(err)) - } - - private async setVideoDescriptionHTML () { - const html = await this.markdownService.textMarkdownToHTML(this.video.description) - this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html) - } -} diff --git a/client/src/app/+videos/+video-watch/video-rate.component.html b/client/src/app/+videos/+video-watch/video-rate.component.html deleted file mode 100644 index 7dd9b3678..000000000 --- a/client/src/app/+videos/+video-watch/video-rate.component.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - diff --git a/client/src/app/+videos/+video-watch/video-rate.component.scss b/client/src/app/+videos/+video-watch/video-rate.component.scss deleted file mode 100644 index f4f696f33..000000000 --- a/client/src/app/+videos/+video-watch/video-rate.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; - -.action-button-like, -.action-button-dislike { - filter: brightness(120%); - - .count { - margin: 0 5px; - } -} - -.activated { - color: pvar(--activatedActionButtonColor) !important; -} diff --git a/client/src/app/+videos/+video-watch/video-rate.component.ts b/client/src/app/+videos/+video-watch/video-rate.component.ts deleted file mode 100644 index 89a666a62..000000000 --- a/client/src/app/+videos/+video-watch/video-rate.component.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Hotkey, HotkeysService } from 'angular2-hotkeys' -import { Observable } from 'rxjs' -import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core' -import { Notifier, ScreenService } from '@app/core' -import { VideoDetails, VideoService } from '@app/shared/shared-main' -import { UserVideoRateType } from '@shared/models' - -@Component({ - selector: 'my-video-rate', - templateUrl: './video-rate.component.html', - styleUrls: [ './video-rate.component.scss' ] -}) -export class VideoRateComponent implements OnInit, OnChanges, OnDestroy { - @Input() video: VideoDetails - @Input() isUserLoggedIn: boolean - - @Output() userRatingLoaded = new EventEmitter() - @Output() rateUpdated = new EventEmitter() - - userRating: UserVideoRateType - - tooltipLike = '' - tooltipDislike = '' - - private hotkeys: Hotkey[] - - constructor ( - private videoService: VideoService, - private notifier: Notifier, - private hotkeysService: HotkeysService, - private screenService: ScreenService - ) { } - - async ngOnInit () { - // Hide the tooltips for unlogged users in mobile view, this adds confusion with the popover - if (this.isUserLoggedIn || !this.screenService.isInMobileView()) { - this.tooltipLike = $localize`Like this video` - this.tooltipDislike = $localize`Dislike this video` - } - - if (this.isUserLoggedIn) { - this.hotkeys = [ - new Hotkey('shift+l', () => { - this.setLike() - return false - }, undefined, $localize`Like the video`), - - new Hotkey('shift+d', () => { - this.setDislike() - return false - }, undefined, $localize`Dislike the video`) - ] - - this.hotkeysService.add(this.hotkeys) - } - } - - ngOnChanges () { - this.checkUserRating() - } - - ngOnDestroy () { - this.hotkeysService.remove(this.hotkeys) - } - - setLike () { - if (this.isUserLoggedIn === false) return - - // Already liked this video - if (this.userRating === 'like') this.setRating('none') - else this.setRating('like') - } - - setDislike () { - if (this.isUserLoggedIn === false) return - - // Already disliked this video - if (this.userRating === 'dislike') this.setRating('none') - else this.setRating('dislike') - } - - getRatePopoverText () { - if (this.isUserLoggedIn) return undefined - - return $localize`You need to be logged in to rate this video.` - } - - private checkUserRating () { - // Unlogged users do not have ratings - if (this.isUserLoggedIn === false) return - - this.videoService.getUserVideoRating(this.video.id) - .subscribe( - ratingObject => { - if (!ratingObject) return - - this.userRating = ratingObject.rating - this.userRatingLoaded.emit(this.userRating) - }, - - err => this.notifier.error(err.message) - ) - } - - private setRating (nextRating: UserVideoRateType) { - const ratingMethods: { [id in UserVideoRateType]: (id: number) => Observable } = { - like: this.videoService.setVideoLike, - dislike: this.videoService.setVideoDislike, - none: this.videoService.unsetVideoLike - } - - ratingMethods[nextRating].call(this.videoService, this.video.id) - .subscribe( - () => { - // Update the video like attribute - this.updateVideoRating(this.userRating, nextRating) - this.userRating = nextRating - this.rateUpdated.emit(this.userRating) - }, - - (err: { message: string }) => this.notifier.error(err.message) - ) - } - - private updateVideoRating (oldRating: UserVideoRateType, newRating: UserVideoRateType) { - let likesToIncrement = 0 - let dislikesToIncrement = 0 - - if (oldRating) { - if (oldRating === 'like') likesToIncrement-- - if (oldRating === 'dislike') dislikesToIncrement-- - } - - if (newRating === 'like') likesToIncrement++ - if (newRating === 'dislike') dislikesToIncrement++ - - this.video.likes += likesToIncrement - this.video.dislikes += dislikesToIncrement - - this.video.buildLikeAndDislikePercents() - } -} diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.html b/client/src/app/+videos/+video-watch/video-watch-playlist.component.html deleted file mode 100644 index c270142a3..000000000 --- a/client/src/app/+videos/+video-watch/video-watch-playlist.component.html +++ /dev/null @@ -1,49 +0,0 @@ -
-
-
- {{ playlist.displayName }} - - Unlisted - Private - Public -
- -
-
{{ playlist.ownerBy }}
-
- {{ currentPlaylistPosition }}{{ playlistPagination.totalItems }} -
-
- -
- - - -
-
- -
- -
-
diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss b/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss deleted file mode 100644 index 75ed9d901..000000000 --- a/client/src/app/+videos/+video-watch/video-watch-playlist.component.scss +++ /dev/null @@ -1,83 +0,0 @@ -@use '_variables' as *; -@use '_mixins' as *; -@use '_bootstrap-variables'; -@use '_miniature' as *; - -.playlist { - min-width: 200px; - max-width: 470px; - height: 66vh; - background-color: pvar(--mainBackgroundColor); - overflow-y: auto; - border-bottom: 1px solid $separator-border-color; - - .playlist-info { - padding: 5px 30px; - background-color: #e4e4e4; - - .playlist-display-name { - font-size: 18px; - font-weight: $font-semibold; - margin-bottom: 5px; - } - - .playlist-by-index { - color: pvar(--greyForegroundColor); - display: flex; - - .playlist-by { - @include margin-right(5px); - } - - .playlist-index span:first-child::after { - content: '/'; - margin: 0 3px; - } - } - - .playlist-controls { - display: flex; - margin: 10px 0; - - my-global-icon:not(:last-child) { - @include margin-right(.5rem); - } - - my-global-icon { - &:not(.active) { - opacity: .5; - } - - ::ng-deep { - cursor: pointer; - } - } - } - } - - my-video-playlist-element-miniature { - ::ng-deep { - .video { - .position { - @include margin-right(0); - } - - .video-info { - .video-info-name { - font-size: 15px; - } - } - } - - my-video-thumbnail { - @include thumbnail-size-component(90px, 50px); - } - - .fake-thumbnail { - width: 90px; - height: 50px; - } - } - } -} - diff --git a/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts b/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts deleted file mode 100644 index 0a4d6bfd1..000000000 --- a/client/src/app/+videos/+video-watch/video-watch-playlist.component.ts +++ /dev/null @@ -1,229 +0,0 @@ - -import { Component, EventEmitter, Input, Output } from '@angular/core' -import { Router } from '@angular/router' -import { AuthService, ComponentPagination, LocalStorageService, Notifier, SessionStorageService, UserService } from '@app/core' -import { VideoPlaylist, VideoPlaylistElement, VideoPlaylistService } from '@app/shared/shared-video-playlist' -import { peertubeLocalStorage, peertubeSessionStorage } from '@root-helpers/peertube-web-storage' -import { VideoPlaylistPrivacy } from '@shared/models' - -@Component({ - selector: 'my-video-watch-playlist', - templateUrl: './video-watch-playlist.component.html', - styleUrls: [ './video-watch-playlist.component.scss' ] -}) -export class VideoWatchPlaylistComponent { - static LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'auto_play_video_playlist' - static SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST = 'loop_playlist' - - @Input() playlist: VideoPlaylist - - @Output() videoFound = new EventEmitter() - - playlistElements: VideoPlaylistElement[] = [] - playlistPagination: ComponentPagination = { - currentPage: 1, - itemsPerPage: 30, - totalItems: null - } - - autoPlayNextVideoPlaylist: boolean - autoPlayNextVideoPlaylistSwitchText = '' - loopPlaylist: boolean - loopPlaylistSwitchText = '' - noPlaylistVideos = false - - currentPlaylistPosition: number - - constructor ( - private userService: UserService, - private auth: AuthService, - private notifier: Notifier, - private videoPlaylist: VideoPlaylistService, - private localStorageService: LocalStorageService, - private sessionStorageService: SessionStorageService, - private router: Router - ) { - // defaults to true - this.autoPlayNextVideoPlaylist = this.auth.isLoggedIn() - ? this.auth.getUser().autoPlayNextVideoPlaylist - : this.localStorageService.getItem(VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) !== 'false' - - this.setAutoPlayNextVideoPlaylistSwitchText() - - // defaults to false - this.loopPlaylist = this.sessionStorageService.getItem(VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST) === 'true' - this.setLoopPlaylistSwitchText() - } - - onPlaylistVideosNearOfBottom (position?: number) { - // Last page - if (this.playlistPagination.totalItems <= (this.playlistPagination.currentPage * this.playlistPagination.itemsPerPage)) return - - this.playlistPagination.currentPage += 1 - this.loadPlaylistElements(this.playlist, false, position) - } - - onElementRemoved (playlistElement: VideoPlaylistElement) { - this.playlistElements = this.playlistElements.filter(e => e.id !== playlistElement.id) - - this.playlistPagination.totalItems-- - } - - isPlaylistOwned () { - return this.playlist.isLocal === true && - this.auth.isLoggedIn() && - this.playlist.ownerAccount.name === this.auth.getUser().username - } - - isUnlistedPlaylist () { - return this.playlist.privacy.id === VideoPlaylistPrivacy.UNLISTED - } - - isPrivatePlaylist () { - return this.playlist.privacy.id === VideoPlaylistPrivacy.PRIVATE - } - - isPublicPlaylist () { - return this.playlist.privacy.id === VideoPlaylistPrivacy.PUBLIC - } - - loadPlaylistElements (playlist: VideoPlaylist, redirectToFirst = false, position?: number) { - this.videoPlaylist.getPlaylistVideos(playlist.uuid, this.playlistPagination) - .subscribe(({ total, data }) => { - this.playlistElements = this.playlistElements.concat(data) - this.playlistPagination.totalItems = total - - const firstAvailableVideo = this.playlistElements.find(e => !!e.video) - if (!firstAvailableVideo) { - this.noPlaylistVideos = true - return - } - - if (position) this.updatePlaylistIndex(position) - - if (redirectToFirst) { - const extras = { - queryParams: { - start: firstAvailableVideo.startTimestamp, - stop: firstAvailableVideo.stopTimestamp, - playlistPosition: firstAvailableVideo.position - }, - replaceUrl: true - } - this.router.navigate([], extras) - } - }) - } - - updatePlaylistIndex (position: number) { - if (this.playlistElements.length === 0 || !position) return - - // Handle the reverse index - if (position < 0) position = this.playlist.videosLength + position + 1 - - for (const playlistElement of this.playlistElements) { - // >= if the previous videos were not valid - if (playlistElement.video && playlistElement.position >= position) { - this.currentPlaylistPosition = playlistElement.position - - this.videoFound.emit(playlistElement.video.uuid) - - setTimeout(() => { - document.querySelector('.element-' + this.currentPlaylistPosition).scrollIntoView(false) - }, 0) - - return - } - } - - // Load more videos to find our video - this.onPlaylistVideosNearOfBottom(position) - } - - navigateToPreviousPlaylistVideo () { - const previous = this.findPlaylistVideo(this.currentPlaylistPosition - 1, 'previous') - if (!previous) return - - const start = previous.startTimestamp - const stop = previous.stopTimestamp - this.router.navigate([],{ queryParams: { playlistPosition: previous.position, start, stop } }) - } - - findPlaylistVideo (position: number, type: 'previous' | 'next'): VideoPlaylistElement { - if ( - (type === 'next' && position > this.playlistPagination.totalItems) || - (type === 'previous' && position < 1) - ) { - // End of the playlist: end the recursion if we're not in the loop mode - if (!this.loopPlaylist) return - - // Loop mode - position = type === 'previous' - ? this.playlistPagination.totalItems - : 1 - } - - const found = this.playlistElements.find(e => e.position === position) - if (found && found.video) return found - - const newPosition = type === 'previous' - ? position - 1 - : position + 1 - - return this.findPlaylistVideo(newPosition, type) - } - - navigateToNextPlaylistVideo () { - const next = this.findPlaylistVideo(this.currentPlaylistPosition + 1, 'next') - if (!next) return - - const start = next.startTimestamp - const stop = next.stopTimestamp - this.router.navigate([],{ queryParams: { playlistPosition: next.position, start, stop } }) - } - - switchAutoPlayNextVideoPlaylist () { - this.autoPlayNextVideoPlaylist = !this.autoPlayNextVideoPlaylist - this.setAutoPlayNextVideoPlaylistSwitchText() - - peertubeLocalStorage.setItem( - VideoWatchPlaylistComponent.LOCAL_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, - this.autoPlayNextVideoPlaylist.toString() - ) - - if (this.auth.isLoggedIn()) { - const details = { - autoPlayNextVideoPlaylist: this.autoPlayNextVideoPlaylist - } - - this.userService.updateMyProfile(details).subscribe( - () => { - this.auth.refreshUserInformation() - }, - err => this.notifier.error(err.message) - ) - } - } - - switchLoopPlaylist () { - this.loopPlaylist = !this.loopPlaylist - this.setLoopPlaylistSwitchText() - - peertubeSessionStorage.setItem( - VideoWatchPlaylistComponent.SESSION_STORAGE_AUTO_PLAY_NEXT_VIDEO_PLAYLIST, - this.loopPlaylist.toString() - ) - } - - private setAutoPlayNextVideoPlaylistSwitchText () { - this.autoPlayNextVideoPlaylistSwitchText = this.autoPlayNextVideoPlaylist - ? $localize`Stop autoplaying next video` - : $localize`Autoplay next video` - } - - private setLoopPlaylistSwitchText () { - this.loopPlaylistSwitchText = this.loopPlaylist - ? $localize`Stop looping playlist videos` - : $localize`Loop playlist videos` - } -} diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts index e6b353220..9bc82d667 100644 --- a/client/src/app/+videos/+video-watch/video-watch.component.ts +++ b/client/src/app/+videos/+video-watch/video-watch.component.ts @@ -53,7 +53,7 @@ import { } from '../../../assets/player/peertube-player-manager' import { isWebRTCDisabled, timeToInt } from '../../../assets/player/utils' import { environment } from '../../../environments/environment' -import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' +import { VideoWatchPlaylistComponent } from './shared' type URLOptions = CustomizationOptions & { playerMode: PlayerMode } diff --git a/client/src/app/+videos/+video-watch/video-watch.module.ts b/client/src/app/+videos/+video-watch/video-watch.module.ts index 93b143542..f4484f1fe 100644 --- a/client/src/app/+videos/+video-watch/video-watch.module.ts +++ b/client/src/app/+videos/+video-watch/video-watch.module.ts @@ -12,16 +12,18 @@ import { SharedVideoMiniatureModule } from '@app/shared/shared-video-miniature' import { SharedVideoPlaylistModule } from '@app/shared/shared-video-playlist' import { SharedActorImageModule } from '../../shared/shared-actor-image/shared-actor-image.module' import { VideoCommentService } from '../../shared/shared-video-comment/video-comment.service' -import { VideoCommentAddComponent } from './comment/video-comment-add.component' -import { VideoCommentComponent } from './comment/video-comment.component' -import { VideoCommentsComponent } from './comment/video-comments.component' import { PlayerStylesComponent } from './player-styles.component' -import { RecommendationsModule } from './recommendations/recommendations.module' -import { TimestampRouteTransformerDirective } from './timestamp-route-transformer.directive' -import { VideoAvatarChannelComponent } from './video-avatar-channel.component' -import { VideoDescriptionComponent } from './video-description.component' -import { VideoRateComponent } from './video-rate.component' -import { VideoWatchPlaylistComponent } from './video-watch-playlist.component' +import { + RecommendationsModule, + VideoAvatarChannelComponent, + VideoDescriptionComponent, + VideoRateComponent, + VideoWatchPlaylistComponent +} from './shared' +import { VideoCommentAddComponent } from './shared/comment/video-comment-add.component' +import { VideoCommentComponent } from './shared/comment/video-comment.component' +import { VideoCommentsComponent } from './shared/comment/video-comments.component' +import { TimestampRouteTransformerDirective } from './shared/timestamp-route-transformer.directive' import { VideoWatchRoutingModule } from './video-watch-routing.module' import { VideoWatchComponent } from './video-watch.component' -- cgit v1.2.3