Provide native links for description timestamps, and re-clickability for these

pull/2356/head
Rigel Kent 2019-12-16 16:21:42 +01:00 committed by Chocobozzz
parent d68ebf0b4a
commit b29bf61dbd
10 changed files with 113 additions and 22 deletions

View File

@ -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
}
}

View 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>')
}
}

View File

@ -23,7 +23,12 @@
<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>

View 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 ])
}
}

View 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">

View 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?'

View File

@ -162,7 +162,12 @@
</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>
@ -223,7 +228,12 @@
</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

View 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 {

View 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) ||

View 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: [