aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
authorRigel Kent <sendmemail@rigelk.eu>2019-12-16 16:21:42 +0100
committerChocobozzz <chocobozzz@cpy.re>2019-12-17 09:45:02 +0100
commitb29bf61dbd518e5cef0b2f564ddc8f8a0657d089 (patch)
tree86d41fb765ea529095d757e292213156cef7d899 /client
parentd68ebf0b4a40f88e53a78de6b3109a41466fa7c6 (diff)
downloadPeerTube-b29bf61dbd518e5cef0b2f564ddc8f8a0657d089.tar.gz
PeerTube-b29bf61dbd518e5cef0b2f564ddc8f8a0657d089.tar.zst
PeerTube-b29bf61dbd518e5cef0b2f564ddc8f8a0657d089.zip
Provide native links for description timestamps, and re-clickability for these
Diffstat (limited to 'client')
-rw-r--r--client/src/app/shared/angular/timestamp-route-transformer.directive.ts45
-rw-r--r--client/src/app/shared/renderer/markdown.service.ts18
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.html8
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comment.component.ts14
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.html2
-rw-r--r--client/src/app/videos/+video-watch/comment/video-comments.component.ts8
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.html14
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.scss4
-rw-r--r--client/src/app/videos/+video-watch/video-watch.component.ts13
-rw-r--r--client/src/app/videos/+video-watch/video-watch.module.ts9
10 files changed, 113 insertions, 22 deletions
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 @@
1import { Directive, ElementRef, HostListener, Output, EventEmitter } from '@angular/core'
2import { Router } from '@angular/router'
3
4type ElementEvent = Omit<Event, 'target'> & {
5 target: HTMLInputElement
6}
7
8@Directive({
9 selector: '[timestampRouteTransformer]'
10})
11export class TimestampRouteTransformerDirective {
12 @Output() timestampClicked = new EventEmitter<number>()
13
14 constructor (private el: ElementRef, private router: Router) { }
15
16 @HostListener('click', ['$event'])
17 public onClick ($event: ElementEvent) {
18 if ($event.target.hasAttribute('href')) {
19 const ngxLink = document.createElement('a')
20 ngxLink.href = $event.target.getAttribute('href')
21
22 // we only care about reflective links
23 if (ngxLink.host !== window.location.host) return
24
25 const ngxLinkParams = new URLSearchParams(ngxLink.search)
26 if (ngxLinkParams.has('start')) {
27 const separators = ['h', 'm', 's']
28 const start = ngxLinkParams
29 .get('start')
30 .match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator
31 .map(t => {
32 if (t.includes('h')) return parseInt(t, 10) * 3600
33 if (t.includes('m')) return parseInt(t, 10) * 60
34 return parseInt(t, 10)
35 })
36 .reduce((acc, t) => acc + t)
37 this.timestampClicked.emit(start)
38 }
39
40 $event.preventDefault()
41 }
42
43 return
44 }
45}
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 {
75 return this.render('completeMarkdownIt', markdown) 75 return this.render('completeMarkdownIt', markdown)
76 } 76 }
77 77
78 async processVideoTimestamps (html: string) {
79 return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
80 const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
81 const url = buildVideoLink({ startTime: t })
82 return `<a class="video-timestamp" href="${url}">${str}</a>`
83 })
84 }
85
78 private async render (name: keyof MarkdownParsers, markdown: string) { 86 private async render (name: keyof MarkdownParsers, markdown: string) {
79 if (!markdown) return '' 87 if (!markdown) return ''
80 88
@@ -91,14 +99,6 @@ export class MarkdownService {
91 return html 99 return html
92 } 100 }
93 101
94 async processVideoTimestamps (html: string) {
95 return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
96 const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
97 const url = buildVideoLink({ startTime: t })
98 return `<a href="${url}">${str}</a>`
99 })
100 }
101
102 private async createMarkdownIt (config: MarkdownConfig) { 102 private async createMarkdownIt (config: MarkdownConfig) {
103 // FIXME: import('...') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function 103 // FIXME: import('...') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
104 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default 104 const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
@@ -139,7 +139,7 @@ export class MarkdownService {
139 private avoidTruncatedTags (html: string) { 139 private avoidTruncatedTags (html: string) {
140 return html.replace(/\*\*?([^*]+)$/, '$1') 140 return html.replace(/\*\*?([^*]+)$/, '$1')
141 .replace(/<a[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...') 141 .replace(/<a[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...')
142 .replace(/\[[^\]]+\]?\(?([^\)]+)$/, '$1') 142 .replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1')
143 .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...</p>') 143 .replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...</p>')
144 } 144 }
145} 145}
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 @@
23 <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]" 23 <a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
24 class="comment-date">{{ comment.createdAt | myFromNow }}</a> 24 class="comment-date">{{ comment.createdAt | myFromNow }}</a>
25 </div> 25 </div>
26 <div class="comment-html" [innerHTML]="sanitizedCommentHTML"></div> 26 <div
27 class="comment-html"
28 [innerHTML]="sanitizedCommentHTML"
29 (timestampClicked)="handleTimestampClicked($event)"
30 timestampRouteTransformer
31 ></div>
27 32
28 <div class="comment-actions"> 33 <div class="comment-actions">
29 <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div> 34 <div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
@@ -65,6 +70,7 @@
65 (wantedToReply)="onWantToReply($event)" 70 (wantedToReply)="onWantToReply($event)"
66 (wantedToDelete)="onWantToDelete($event)" 71 (wantedToDelete)="onWantToDelete($event)"
67 (resetReply)="onResetReply()" 72 (resetReply)="onResetReply()"
73 (timestampClicked)="handleTimestampClicked($event)"
68 ></my-video-comment> 74 ></my-video-comment>
69 </div> 75 </div>
70 </div> 76 </div>
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
4import { AuthService } from '../../../core/auth' 4import { AuthService } from '../../../core/auth'
5import { Video } from '../../../shared/video/video.model' 5import { Video } from '../../../shared/video/video.model'
6import { VideoComment } from './video-comment.model' 6import { VideoComment } from './video-comment.model'
7import { HtmlRendererService, MarkdownService } from '@app/shared/renderer' 7import { MarkdownService } from '@app/shared/renderer'
8 8
9@Component({ 9@Component({
10 selector: 'my-video-comment', 10 selector: 'my-video-comment',
@@ -23,12 +23,12 @@ export class VideoCommentComponent implements OnInit, OnChanges {
23 @Output() wantedToReply = new EventEmitter<VideoComment>() 23 @Output() wantedToReply = new EventEmitter<VideoComment>()
24 @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>() 24 @Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
25 @Output() resetReply = new EventEmitter() 25 @Output() resetReply = new EventEmitter()
26 @Output() timestampClicked = new EventEmitter<number>()
26 27
27 sanitizedCommentHTML = '' 28 sanitizedCommentHTML = ''
28 newParentComments: VideoComment[] = [] 29 newParentComments: VideoComment[] = []
29 30
30 constructor ( 31 constructor (
31 private htmlRenderer: HtmlRendererService,
32 private markdownService: MarkdownService, 32 private markdownService: MarkdownService,
33 private authService: AuthService 33 private authService: AuthService
34 ) {} 34 ) {}
@@ -78,8 +78,12 @@ export class VideoCommentComponent implements OnInit, OnChanges {
78 this.resetReply.emit() 78 this.resetReply.emit()
79 } 79 }
80 80
81 handleTimestampClicked (timestamp: number) {
82 this.timestampClicked.emit(timestamp)
83 }
84
81 isRemovableByUser () { 85 isRemovableByUser () {
82 return this.isUserLoggedIn() && 86 return this.comment.account && this.isUserLoggedIn() &&
83 ( 87 (
84 this.user.account.id === this.comment.account.id || 88 this.user.account.id === this.comment.account.id ||
85 this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) 89 this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
@@ -87,8 +91,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
87 } 91 }
88 92
89 private async init () { 93 private async init () {
90 const safeHTML = await this.htmlRenderer.toSafeHtml(this.comment.text) 94 const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true)
91 this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(safeHTML) 95 this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
92 this.newParentComments = this.parentComments.concat([ this.comment ]) 96 this.newParentComments = this.parentComments.concat([ this.comment ])
93 } 97 }
94} 98}
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 @@
40 (wantedToDelete)="onWantedToDelete($event)" 40 (wantedToDelete)="onWantedToDelete($event)"
41 (threadCreated)="onThreadCreated($event)" 41 (threadCreated)="onThreadCreated($event)"
42 (resetReply)="onResetReply()" 42 (resetReply)="onResetReply()"
43 (timestampClicked)="handleTimestampClicked($event)"
43 ></my-video-comment> 44 ></my-video-comment>
44 </div> 45 </div>
45 46
@@ -54,6 +55,7 @@
54 (wantedToDelete)="onWantedToDelete($event)" 55 (wantedToDelete)="onWantedToDelete($event)"
55 (threadCreated)="onThreadCreated($event)" 56 (threadCreated)="onThreadCreated($event)"
56 (resetReply)="onResetReply()" 57 (resetReply)="onResetReply()"
58 (timestampClicked)="handleTimestampClicked($event)"
57 ></my-video-comment> 59 ></my-video-comment>
58 60
59 <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment.id)" class="view-replies"> 61 <div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment.id)" class="view-replies">
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 @@
1import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core' 1import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, Output, EventEmitter } from '@angular/core'
2import { ActivatedRoute } from '@angular/router' 2import { ActivatedRoute } from '@angular/router'
3import { ConfirmService, Notifier } from '@app/core' 3import { ConfirmService, Notifier } from '@app/core'
4import { Subject, Subscription } from 'rxjs' 4import { Subject, Subscription } from 'rxjs'
@@ -24,6 +24,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
24 @Input() video: VideoDetails 24 @Input() video: VideoDetails
25 @Input() user: User 25 @Input() user: User
26 26
27 @Output() timestampClicked = new EventEmitter<number>()
28
27 comments: VideoComment[] = [] 29 comments: VideoComment[] = []
28 highlightedThread: VideoComment 30 highlightedThread: VideoComment
29 sort: VideoSortField = '-createdAt' 31 sort: VideoSortField = '-createdAt'
@@ -150,6 +152,10 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
150 this.viewReplies(commentTree.comment.id) 152 this.viewReplies(commentTree.comment.id)
151 } 153 }
152 154
155 handleTimestampClicked (timestamp: number) {
156 this.timestampClicked.emit(timestamp)
157 }
158
153 async onWantedToDelete (commentToDelete: VideoComment) { 159 async onWantedToDelete (commentToDelete: VideoComment) {
154 let message = 'Do you really want to delete this comment?' 160 let message = 'Do you really want to delete this comment?'
155 161
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 @@
162 </div> 162 </div>
163 163
164 <div class="video-info-description"> 164 <div class="video-info-description">
165 <div class="video-info-description-html" [innerHTML]="videoHTMLDescription"></div> 165 <div
166 class="video-info-description-html"
167 [innerHTML]="videoHTMLDescription"
168 (timestampClicked)="handleTimestampClicked($event)"
169 timestampRouteTransformer
170 ></div>
166 171
167 <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()"> 172 <div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()">
168 <ng-container i18n>Show more</ng-container> 173 <ng-container i18n>Show more</ng-container>
@@ -223,7 +228,12 @@
223 </div> 228 </div>
224 </div> 229 </div>
225 230
226 <my-video-comments class="border-top" [video]="video" [user]="user"></my-video-comments> 231 <my-video-comments
232 class="border-top"
233 [video]="video"
234 [user]="user"
235 (timestampClicked)="handleTimestampClicked($event)"
236 ></my-video-comments>
227 </div> 237 </div>
228 238
229 <my-recommended-videos 239 <my-recommended-videos
diff --git a/client/src/app/videos/+video-watch/video-watch.component.scss b/client/src/app/videos/+video-watch/video-watch.component.scss
index ddf6cccea..81c319950 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.scss
+++ b/client/src/app/videos/+video-watch/video-watch.component.scss
@@ -330,6 +330,10 @@ $video-info-margin-left: 44px;
330 330
331 .video-info-description-html { 331 .video-info-description-html {
332 @include peertube-word-wrap; 332 @include peertube-word-wrap;
333
334 /deep/ a {
335 text-decoration: none;
336 }
333 } 337 }
334 338
335 .glyphicon, .description-loading { 339 .glyphicon, .description-loading {
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 d9c88e972..3a7629cc6 100644
--- a/client/src/app/videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/videos/+video-watch/video-watch.component.ts
@@ -38,6 +38,7 @@ import { HooksService } from '@app/core/plugins/hooks.service'
38import { PlatformLocation } from '@angular/common' 38import { PlatformLocation } from '@angular/common'
39import { randomInt } from '@shared/core-utils/miscs/miscs' 39import { randomInt } from '@shared/core-utils/miscs/miscs'
40import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component' 40import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component'
41import { scrollToTop } from '@app/shared/misc/utils'
41 42
42@Component({ 43@Component({
43 selector: 'my-video-watch', 44 selector: 'my-video-watch',
@@ -138,9 +139,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
138 if (playlistId) this.loadPlaylist(playlistId) 139 if (playlistId) this.loadPlaylist(playlistId)
139 }) 140 })
140 141
141 this.queryParamsSub = this.route.queryParams.subscribe(queryParams => { 142 this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => {
142 const videoId = queryParams[ 'videoId' ] 143 const videoId = queryParams[ 'videoId' ]
143 if (videoId) this.loadVideo(videoId) 144 if (videoId) await this.loadVideo(videoId)
145
146 const start = queryParams[ 'start' ]
147 if (this.player && start) this.player.currentTime(parseInt(start, 10))
144 }) 148 })
145 149
146 this.initHotkeys() 150 this.initHotkeys()
@@ -284,6 +288,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
284 ) 288 )
285 } 289 }
286 290
291 handleTimestampClicked (timestamp: number) {
292 if (this.player) this.player.currentTime(timestamp)
293 scrollToTop()
294 }
295
287 isPlaylistAutoPlayEnabled () { 296 isPlaylistAutoPlayEnabled () {
288 return ( 297 return (
289 (this.user && this.user.autoPlayNextVideoPlaylist) || 298 (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
13import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' 13import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
14import { QRCodeModule } from 'angularx-qrcode' 14import { QRCodeModule } from 'angularx-qrcode'
15import { InputSwitchModule } from 'primeng/inputswitch' 15import { InputSwitchModule } from 'primeng/inputswitch'
16import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive'
16 17
17@NgModule({ 18@NgModule({
18 imports: [ 19 imports: [
@@ -32,11 +33,15 @@ import { InputSwitchModule } from 'primeng/inputswitch'
32 VideoSupportComponent, 33 VideoSupportComponent,
33 VideoCommentsComponent, 34 VideoCommentsComponent,
34 VideoCommentAddComponent, 35 VideoCommentAddComponent,
35 VideoCommentComponent 36 VideoCommentComponent,
37
38 TimestampRouteTransformerDirective
36 ], 39 ],
37 40
38 exports: [ 41 exports: [
39 VideoWatchComponent 42 VideoWatchComponent,
43
44 TimestampRouteTransformerDirective
40 ], 45 ],
41 46
42 providers: [ 47 providers: [