Provide native links for description timestamps, and re-clickability for these
authorRigel Kent <sendmemail@rigelk.eu>
Mon, 16 Dec 2019 15:21:42 +0000 (16:21 +0100)
committerChocobozzz <chocobozzz@cpy.re>
Tue, 17 Dec 2019 08:45:02 +0000 (09:45 +0100)
client/src/app/shared/angular/timestamp-route-transformer.directive.ts [new file with mode: 0644]
client/src/app/shared/renderer/markdown.service.ts
client/src/app/videos/+video-watch/comment/video-comment.component.html
client/src/app/videos/+video-watch/comment/video-comment.component.ts
client/src/app/videos/+video-watch/comment/video-comments.component.html
client/src/app/videos/+video-watch/comment/video-comments.component.ts
client/src/app/videos/+video-watch/video-watch.component.html
client/src/app/videos/+video-watch/video-watch.component.scss
client/src/app/videos/+video-watch/video-watch.component.ts
client/src/app/videos/+video-watch/video-watch.module.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 (file)
index 0000000..d71077d
--- /dev/null
@@ -0,0 +1,45 @@
+import { Directive, ElementRef, HostListener, Output, EventEmitter } from '@angular/core'
+import { Router } from '@angular/router'
+
+type ElementEvent = Omit<Event, 'target'> & {
+  target: HTMLInputElement
+}
+
+@Directive({
+  selector: '[timestampRouteTransformer]'
+})
+export class TimestampRouteTransformerDirective {
+  @Output() timestampClicked = new EventEmitter<number>()
+
+  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
+  }
+}
index f6b71b88a783490f16a0f9f4bc88e871892eabb4..0d3fde537704f8373aec8c60d711b33e92d5881e 100644 (file)
@@ -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 `<a class="video-timestamp" href="${url}">${str}</a>`
+    })
+  }
+
   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 `<a href="${url}">${str}</a>`
-    })
-  }
-
   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[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...')
-      .replace(/\[[^\]]+\]?\(?([^\)]+)$/, '$1')
+      .replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1')
       .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...</p>')
   }
 }
index 04bb1f7a23e8b923309c186257c118f1c1d09b8b..df996533d8eb1f352ca860d96cb4c0a06c0dce32 100644 (file)
           <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
              class="comment-date">{{ comment.createdAt | myFromNow }}</a>
         </div>
-        <div class="comment-html" [innerHTML]="sanitizedCommentHTML"></div>
+        <div
+          class="comment-html"
+          [innerHTML]="sanitizedCommentHTML"
+          (timestampClicked)="handleTimestampClicked($event)"
+          timestampRouteTransformer
+        ></div>
 
         <div class="comment-actions">
           <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
@@ -65,6 +70,7 @@
             (wantedToReply)="onWantToReply($event)"
             (wantedToDelete)="onWantToDelete($event)"
             (resetReply)="onResetReply()"
+            (timestampClicked)="handleTimestampClicked($event)"
           ></my-video-comment>
         </div>
       </div>
index d5e3ecc1789ca5bf9a4ed6a44ab83f75a7c0d48b..b2bf3ee1be6d75d9670b6dc3a78ec809c5222b09 100644 (file)
@@ -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<VideoComment>()
   @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
   @Output() resetReply = new EventEmitter()
+  @Output() timestampClicked = new EventEmitter<number>()
 
   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 ])
   }
 }
index 844263ddd3e1d9d083951c4e514747a936271aa6..5fabb7dfe70a62df015e88980eeb70b239532bd7 100644 (file)
@@ -40,6 +40,7 @@
           (wantedToDelete)="onWantedToDelete($event)"
           (threadCreated)="onThreadCreated($event)"
           (resetReply)="onResetReply()"
+          (timestampClicked)="handleTimestampClicked($event)"
         ></my-video-comment>
       </div>
 
@@ -54,6 +55,7 @@
           (wantedToDelete)="onWantedToDelete($event)"
           (threadCreated)="onThreadCreated($event)"
           (resetReply)="onResetReply()"
+          (timestampClicked)="handleTimestampClicked($event)"
         ></my-video-comment>
 
         <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment.id)" class="view-replies">
index cc8b98b4e93b9df0d69935a10cfae791efc08e0b..e814015534614c4e6bd9f8c805a9e7914bd3d8ca 100644 (file)
@@ -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<number>()
+
   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?'
 
index b3def01faa36afb2000ff2090289e79bcaac976c..77e0b925624d74f439bf97511c5fe0f6f928fe3f 100644 (file)
       </div>
 
       <div class="video-info-description">
-        <div class="video-info-description-html" [innerHTML]="videoHTMLDescription"></div>
+        <div
+          class="video-info-description-html"
+          [innerHTML]="videoHTMLDescription" 
+          (timestampClicked)="handleTimestampClicked($event)"
+          timestampRouteTransformer
+        ></div>
 
         <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()">
           <ng-container i18n>Show more</ng-container>
         </div>
       </div>
 
-      <my-video-comments class="border-top" [video]="video" [user]="user"></my-video-comments>
+      <my-video-comments
+        class="border-top"
+        [video]="video"
+        [user]="user"
+        (timestampClicked)="handleTimestampClicked($event)"
+      ></my-video-comments>
     </div>
 
     <my-recommended-videos
index ddf6ccceac6e0297d2868b7817b838909e822ae0..81c3199504502b6234567fe1765fe5bd0d29a4d9 100644 (file)
@@ -330,6 +330,10 @@ $video-info-margin-left: 44px;
 
       .video-info-description-html {
         @include peertube-word-wrap;
+
+        /deep/ a {
+          text-decoration: none;
+        }
       }
 
       .glyphicon, .description-loading {
index d9c88e97210a972327302038bbb076ad14869689..3a7629cc64585ee6260b59b771ae5a48e81a92d8 100644 (file)
@@ -38,6 +38,7 @@ import { HooksService } from '@app/core/plugins/hooks.service'
 import { PlatformLocation } from '@angular/common'
 import { randomInt } from '@shared/core-utils/miscs/miscs'
 import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component'
+import { scrollToTop } from '@app/shared/misc/utils'
 
 @Component({
   selector: 'my-video-watch',
@@ -138,9 +139,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
       if (playlistId) this.loadPlaylist(playlistId)
     })
 
-    this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
+    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) ||
index 2e45e5674218cd8f1590c82c5452d65f66e1bf35..5fa50ecbb1fa30a584eb81e8ae9b0ddb4aee59d4 100644 (file)
@@ -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: [