From b29bf61dbd518e5cef0b2f564ddc8f8a0657d089 Mon Sep 17 00:00:00 2001 From: Rigel Kent Date: Mon, 16 Dec 2019 16:21:42 +0100 Subject: [PATCH] Provide native links for description timestamps, and re-clickability for these --- .../timestamp-route-transformer.directive.ts | 45 +++++++++++++++++++ .../app/shared/renderer/markdown.service.ts | 18 ++++---- .../comment/video-comment.component.html | 8 +++- .../comment/video-comment.component.ts | 14 +++--- .../comment/video-comments.component.html | 2 + .../comment/video-comments.component.ts | 8 +++- .../+video-watch/video-watch.component.html | 14 +++++- .../+video-watch/video-watch.component.scss | 4 ++ .../+video-watch/video-watch.component.ts | 13 +++++- .../videos/+video-watch/video-watch.module.ts | 9 +++- 10 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 client/src/app/shared/angular/timestamp-route-transformer.directive.ts diff --git a/client/src/app/shared/angular/timestamp-route-transformer.directive.ts b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts new file mode 100644 index 000000000..d71077d10 --- /dev/null +++ b/client/src/app/shared/angular/timestamp-route-transformer.directive.ts @@ -0,0 +1,45 @@ +import { Directive, ElementRef, HostListener, Output, EventEmitter } from '@angular/core' +import { Router } from '@angular/router' + +type ElementEvent = Omit & { + target: HTMLInputElement +} + +@Directive({ + selector: '[timestampRouteTransformer]' +}) +export class TimestampRouteTransformerDirective { + @Output() timestampClicked = new EventEmitter() + + constructor (private el: ElementRef, private router: Router) { } + + @HostListener('click', ['$event']) + public onClick ($event: ElementEvent) { + if ($event.target.hasAttribute('href')) { + const ngxLink = document.createElement('a') + ngxLink.href = $event.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')) { + 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() + } + + return + } +} diff --git a/client/src/app/shared/renderer/markdown.service.ts b/client/src/app/shared/renderer/markdown.service.ts index f6b71b88a..0d3fde537 100644 --- a/client/src/app/shared/renderer/markdown.service.ts +++ b/client/src/app/shared/renderer/markdown.service.ts @@ -75,6 +75,14 @@ export class MarkdownService { return this.render('completeMarkdownIt', markdown) } + async processVideoTimestamps (html: string) { + return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) { + const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0)) + const url = buildVideoLink({ startTime: t }) + return `${str}` + }) + } + private async render (name: keyof MarkdownParsers, markdown: string) { if (!markdown) return '' @@ -91,14 +99,6 @@ export class MarkdownService { return html } - async processVideoTimestamps (html: string) { - return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) { - const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0)) - const url = buildVideoLink({ startTime: t }) - return `${str}` - }) - } - private async createMarkdownIt (config: MarkdownConfig) { // FIXME: import('...') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default @@ -139,7 +139,7 @@ export class MarkdownService { private avoidTruncatedTags (html: string) { return html.replace(/\*\*?([^*]+)$/, '$1') .replace(/]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...') - .replace(/\[[^\]]+\]?\(?([^\)]+)$/, '$1') + .replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1') .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...

') } } 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 index 04bb1f7a2..df996533d 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.html +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.html @@ -23,7 +23,12 @@ {{ comment.createdAt | myFromNow }} -
+
Reply
@@ -65,6 +70,7 @@ (wantedToReply)="onWantToReply($event)" (wantedToDelete)="onWantToDelete($event)" (resetReply)="onResetReply()" + (timestampClicked)="handleTimestampClicked($event)" >
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 index d5e3ecc17..b2bf3ee1b 100644 --- a/client/src/app/videos/+video-watch/comment/video-comment.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comment.component.ts @@ -4,7 +4,7 @@ import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/v import { AuthService } from '../../../core/auth' import { Video } from '../../../shared/video/video.model' import { VideoComment } from './video-comment.model' -import { HtmlRendererService, MarkdownService } from '@app/shared/renderer' +import { MarkdownService } from '@app/shared/renderer' @Component({ selector: 'my-video-comment', @@ -23,12 +23,12 @@ export class VideoCommentComponent implements OnInit, OnChanges { @Output() wantedToReply = new EventEmitter() @Output() threadCreated = new EventEmitter() @Output() resetReply = new EventEmitter() + @Output() timestampClicked = new EventEmitter() sanitizedCommentHTML = '' newParentComments: VideoComment[] = [] constructor ( - private htmlRenderer: HtmlRendererService, private markdownService: MarkdownService, private authService: AuthService ) {} @@ -78,8 +78,12 @@ export class VideoCommentComponent implements OnInit, OnChanges { this.resetReply.emit() } + handleTimestampClicked (timestamp: number) { + this.timestampClicked.emit(timestamp) + } + isRemovableByUser () { - return this.isUserLoggedIn() && + return this.comment.account && this.isUserLoggedIn() && ( this.user.account.id === this.comment.account.id || this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) @@ -87,8 +91,8 @@ export class VideoCommentComponent implements OnInit, OnChanges { } private async init () { - const safeHTML = await this.htmlRenderer.toSafeHtml(this.comment.text) - this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(safeHTML) + const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true) + this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html) this.newParentComments = this.parentComments.concat([ this.comment ]) } } 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 index 844263ddd..5fabb7dfe 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.html +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.html @@ -40,6 +40,7 @@ (wantedToDelete)="onWantedToDelete($event)" (threadCreated)="onThreadCreated($event)" (resetReply)="onResetReply()" + (timestampClicked)="handleTimestampClicked($event)" > @@ -54,6 +55,7 @@ (wantedToDelete)="onWantedToDelete($event)" (threadCreated)="onThreadCreated($event)" (resetReply)="onResetReply()" + (timestampClicked)="handleTimestampClicked($event)" >
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 index cc8b98b4e..e81401553 100644 --- a/client/src/app/videos/+video-watch/comment/video-comments.component.ts +++ b/client/src/app/videos/+video-watch/comment/video-comments.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core' +import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, Output, EventEmitter } from '@angular/core' import { ActivatedRoute } from '@angular/router' import { ConfirmService, Notifier } from '@app/core' import { Subject, Subscription } from 'rxjs' @@ -24,6 +24,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { @Input() video: VideoDetails @Input() user: User + @Output() timestampClicked = new EventEmitter() + comments: VideoComment[] = [] highlightedThread: VideoComment sort: VideoSortField = '-createdAt' @@ -150,6 +152,10 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy { this.viewReplies(commentTree.comment.id) } + handleTimestampClicked (timestamp: number) { + this.timestampClicked.emit(timestamp) + } + async onWantedToDelete (commentToDelete: VideoComment) { let message = 'Do you really want to delete this comment?' diff --git a/client/src/app/videos/+video-watch/video-watch.component.html b/client/src/app/videos/+video-watch/video-watch.component.html index b3def01fa..77e0b9256 100644 --- a/client/src/app/videos/+video-watch/video-watch.component.html +++ b/client/src/app/videos/+video-watch/video-watch.component.html @@ -162,7 +162,12 @@
-
+
Show more @@ -223,7 +228,12 @@
- + { + this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => { const videoId = queryParams[ 'videoId' ] - if (videoId) this.loadVideo(videoId) + if (videoId) await this.loadVideo(videoId) + + const start = queryParams[ 'start' ] + if (this.player && start) this.player.currentTime(parseInt(start, 10)) }) this.initHotkeys() @@ -284,6 +288,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy { ) } + handleTimestampClicked (timestamp: number) { + if (this.player) this.player.currentTime(timestamp) + scrollToTop() + } + isPlaylistAutoPlayEnabled () { return ( (this.user && this.user.autoPlayNextVideoPlaylist) || 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 2e45e5674..5fa50ecbb 100644 --- a/client/src/app/videos/+video-watch/video-watch.module.ts +++ b/client/src/app/videos/+video-watch/video-watch.module.ts @@ -13,6 +13,7 @@ import { RecommendationsModule } from '@app/videos/recommendations/recommendatio import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' import { QRCodeModule } from 'angularx-qrcode' import { InputSwitchModule } from 'primeng/inputswitch' +import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive' @NgModule({ imports: [ @@ -32,11 +33,15 @@ import { InputSwitchModule } from 'primeng/inputswitch' VideoSupportComponent, VideoCommentsComponent, VideoCommentAddComponent, - VideoCommentComponent + VideoCommentComponent, + + TimestampRouteTransformerDirective ], exports: [ - VideoWatchComponent + VideoWatchComponent, + + TimestampRouteTransformerDirective ], providers: [