diff options
author | Rigel Kent <sendmemail@rigelk.eu> | 2019-12-16 16:21:42 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2019-12-17 09:45:02 +0100 |
commit | b29bf61dbd518e5cef0b2f564ddc8f8a0657d089 (patch) | |
tree | 86d41fb765ea529095d757e292213156cef7d899 | |
parent | d68ebf0b4a40f88e53a78de6b3109a41466fa7c6 (diff) | |
download | PeerTube-b29bf61dbd518e5cef0b2f564ddc8f8a0657d089.tar.gz PeerTube-b29bf61dbd518e5cef0b2f564ddc8f8a0657d089.tar.zst PeerTube-b29bf61dbd518e5cef0b2f564ddc8f8a0657d089.zip |
Provide native links for description timestamps, and re-clickability for these
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 @@ | |||
1 | import { Directive, ElementRef, HostListener, Output, EventEmitter } from '@angular/core' | ||
2 | import { Router } from '@angular/router' | ||
3 | |||
4 | type ElementEvent = Omit<Event, 'target'> & { | ||
5 | target: HTMLInputElement | ||
6 | } | ||
7 | |||
8 | @Directive({ | ||
9 | selector: '[timestampRouteTransformer]' | ||
10 | }) | ||
11 | export 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 | |||
4 | import { AuthService } from '../../../core/auth' | 4 | import { AuthService } from '../../../core/auth' |
5 | import { Video } from '../../../shared/video/video.model' | 5 | import { Video } from '../../../shared/video/video.model' |
6 | import { VideoComment } from './video-comment.model' | 6 | import { VideoComment } from './video-comment.model' |
7 | import { HtmlRendererService, MarkdownService } from '@app/shared/renderer' | 7 | import { 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 @@ | |||
1 | import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, Output, EventEmitter } from '@angular/core' |
2 | import { ActivatedRoute } from '@angular/router' | 2 | import { ActivatedRoute } from '@angular/router' |
3 | import { ConfirmService, Notifier } from '@app/core' | 3 | import { ConfirmService, Notifier } from '@app/core' |
4 | import { Subject, Subscription } from 'rxjs' | 4 | import { 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' | |||
38 | import { PlatformLocation } from '@angular/common' | 38 | import { PlatformLocation } from '@angular/common' |
39 | import { randomInt } from '@shared/core-utils/miscs/miscs' | 39 | import { randomInt } from '@shared/core-utils/miscs/miscs' |
40 | import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component' | 40 | import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component' |
41 | import { 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 | |||
13 | import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' | 13 | import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component' |
14 | import { QRCodeModule } from 'angularx-qrcode' | 14 | import { QRCodeModule } from 'angularx-qrcode' |
15 | import { InputSwitchModule } from 'primeng/inputswitch' | 15 | import { InputSwitchModule } from 'primeng/inputswitch' |
16 | import { 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: [ |