Add links to comment mentions

pull/311/head
Chocobozzz 2018-02-21 16:44:18 +01:00
parent 276d03ed1a
commit e8cb44090e
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
13 changed files with 314 additions and 32 deletions

View File

@ -57,6 +57,7 @@
"extract-text-webpack-plugin": "^3.0.2",
"file-loader": "^1.1.5",
"html-webpack-plugin": "^2.19.0",
"linkifyjs": "^2.1.5",
"lodash-es": "^4.17.4",
"markdown-it": "^8.4.0",
"ngx-bootstrap": "2.0.2",

View File

@ -0,0 +1,114 @@
import { Injectable } from '@angular/core'
import { getAbsoluteAPIUrl } from '@app/shared/misc/utils'
import * as linkify from 'linkifyjs'
import * as linkifyHtml from 'linkifyjs/html'
@Injectable()
export class LinkifierService {
static CLASSNAME = 'linkified'
private linkifyOptions = {
className: {
mention: LinkifierService.CLASSNAME + '-mention',
url: LinkifierService.CLASSNAME + '-url'
}
}
constructor () {
// Apply plugin
this.mentionWithDomainPlugin(linkify)
}
linkify (text: string) {
return linkifyHtml(text, this.linkifyOptions)
}
private mentionWithDomainPlugin (linkify: any) {
const TT = linkify.scanner.TOKENS // Text tokens
const { TOKENS: MT, State } = linkify.parser // Multi tokens, state
const MultiToken = MT.Base
const S_START = linkify.parser.start
const TT_AT = TT.AT
const TT_DOMAIN = TT.DOMAIN
const TT_LOCALHOST = TT.LOCALHOST
const TT_NUM = TT.NUM
const TT_COLON = TT.COLON
const TT_SLASH = TT.SLASH
const TT_TLD = TT.TLD
const TT_UNDERSCORE = TT.UNDERSCORE
const TT_DOT = TT.DOT
function MENTION (value) {
this.v = value
}
linkify.inherits(MultiToken, MENTION, {
type: 'mentionWithDomain',
isLink: true,
toHref () {
return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + this.toString().substr(1)
}
})
const S_AT = S_START.jump(TT_AT) // @
const S_AT_SYMS = new State()
const S_MENTION = new State(MENTION)
const S_MENTION_DIVIDER = new State()
const S_MENTION_DIVIDER_SYMS = new State()
// @_,
S_AT.on(TT_UNDERSCORE, S_AT_SYMS)
// @_*
S_AT_SYMS
.on(TT_UNDERSCORE, S_AT_SYMS)
.on(TT_DOT, S_AT_SYMS)
// Valid mention (not made up entirely of symbols)
S_AT
.on(TT_DOMAIN, S_MENTION)
.on(TT_LOCALHOST, S_MENTION)
.on(TT_TLD, S_MENTION)
.on(TT_NUM, S_MENTION)
S_AT_SYMS
.on(TT_DOMAIN, S_MENTION)
.on(TT_LOCALHOST, S_MENTION)
.on(TT_TLD, S_MENTION)
.on(TT_NUM, S_MENTION)
// More valid mentions
S_MENTION
.on(TT_DOMAIN, S_MENTION)
.on(TT_LOCALHOST, S_MENTION)
.on(TT_TLD, S_MENTION)
.on(TT_COLON, S_MENTION)
.on(TT_NUM, S_MENTION)
.on(TT_UNDERSCORE, S_MENTION)
// Mention with a divider
S_MENTION
.on(TT_AT, S_MENTION_DIVIDER)
.on(TT_SLASH, S_MENTION_DIVIDER)
.on(TT_DOT, S_MENTION_DIVIDER)
// Mention _ trailing stash plus syms
S_MENTION_DIVIDER.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
S_MENTION_DIVIDER_SYMS.on(TT_UNDERSCORE, S_MENTION_DIVIDER_SYMS)
// Once we get a word token, mentions can start up again
S_MENTION_DIVIDER
.on(TT_DOMAIN, S_MENTION)
.on(TT_LOCALHOST, S_MENTION)
.on(TT_TLD, S_MENTION)
.on(TT_NUM, S_MENTION)
S_MENTION_DIVIDER_SYMS
.on(TT_DOMAIN, S_MENTION)
.on(TT_LOCALHOST, S_MENTION)
.on(TT_TLD, S_MENTION)
.on(TT_NUM, S_MENTION)
}
}

View File

@ -59,8 +59,12 @@ export class VideoCommentAddComponent extends FormReactive implements OnInit {
if (this.parentComment) {
const mentions = this.parentComments
.filter(c => c.account.id !== this.user.account.id)
.map(c => '@' + c.account.name)
.filter(c => c.account.id !== this.user.account.id) // Don't add mention of ourselves
.map(c => {
if (c.account.host) return '@' + c.account.name + '@' + c.account.host
return c.account.name
})
const mentionsSet = new Set(mentions)
const mentionsText = Array.from(mentionsSet).join(' ') + ' '

View File

@ -46,10 +46,15 @@
.comment-html {
word-break: break-all;
a {
/deep/ a {
@include disable-default-a-behaviour;
color: #000;
// Semi bold mentions
&:not(.linkified-url) {
font-weight: $font-semibold;
}
}
}

View File

@ -1,5 +1,5 @@
import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'
import { MarkdownService } from '@app/videos/shared'
import { LinkifierService } from '@app/videos/+video-watch/comment/linkifier.service'
import * as sanitizeHtml from 'sanitize-html'
import { Account as AccountInterface } from '../../../../../../shared/models/actors'
import { UserRight } from '../../../../../../shared/models/users'
@ -31,8 +31,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
newParentComments = []
constructor (
private authService: AuthService,
private markdownService: MarkdownService
private linkifierService: LinkifierService,
private authService: AuthService
) {}
get user () {
@ -93,13 +93,26 @@ export class VideoCommentComponent implements OnInit, OnChanges {
}
private init () {
this.sanitizedCommentHTML = sanitizeHtml(this.comment.text, {
allowedTags: [ 'a', 'p', 'span', 'br' ],
allowedSchemes: [ 'http', 'https' ]
})
// Convert possible markdown to html
this.sanitizedCommentHTML = this.markdownService.linkify(this.comment.text)
const html = this.linkifierService.linkify(this.comment.text)
this.sanitizedCommentHTML = sanitizeHtml(html, {
allowedTags: [ 'a', 'p', 'span', 'br' ],
allowedSchemes: [ 'http', 'https' ],
allowedAttributes: {
'a': [ 'href', 'class' ]
},
transformTags: {
a: (tagName, attribs) => {
return {
tagName,
attribs: Object.assign(attribs, {
target: '_blank'
})
}
}
}
})
this.newParentComments = this.parentComments.concat([ this.comment ])
}

View File

@ -1,4 +1,5 @@
import { NgModule } from '@angular/core'
import { LinkifierService } from '@app/videos/+video-watch/comment/linkifier.service'
import { VideoSupportComponent } from '@app/videos/+video-watch/modal/video-support.component'
import { TooltipModule } from 'ngx-bootstrap/tooltip'
import { ClipboardModule } from 'ngx-clipboard'
@ -42,6 +43,7 @@ import { VideoWatchComponent } from './video-watch.component'
providers: [
MarkdownService,
LinkifierService,
VideoCommentService
]
})

View File

@ -5,7 +5,6 @@ import * as MarkdownIt from 'markdown-it'
@Injectable()
export class MarkdownService {
private textMarkdownIt: MarkdownIt.MarkdownIt
private linkifier: MarkdownIt.MarkdownIt
private enhancedMarkdownIt: MarkdownIt.MarkdownIt
constructor () {
@ -27,10 +26,6 @@ export class MarkdownService {
.enable('list')
.enable('image')
this.setTargetToLinks(this.enhancedMarkdownIt)
this.linkifier = new MarkdownIt('zero', { linkify: true })
.enable('linkify')
this.setTargetToLinks(this.linkifier)
}
textMarkdownToHTML (markdown: string) {
@ -45,12 +40,6 @@ export class MarkdownService {
return this.avoidTruncatedLinks(html)
}
linkify (text: string) {
const html = this.linkifier.render(text)
return this.avoidTruncatedLinks(html)
}
private setTargetToLinks (markdownIt: MarkdownIt.MarkdownIt) {
// Snippet from markdown-it documentation: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
const defaultRender = markdownIt.renderer.rules.link_open || function (tokens, idx, options, env, self) {

View File

@ -1526,6 +1526,10 @@ copy-webpack-plugin@4.3.0, copy-webpack-plugin@^4.1.1:
pify "^3.0.0"
serialize-javascript "^1.4.0"
core-js@^1.0.0:
version "1.2.7"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
core-js@^2.4.0, core-js@^2.4.1:
version "2.5.3"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e"
@ -2098,6 +2102,12 @@ encodeurl@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
encoding@^0.1.11:
version "0.1.12"
resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb"
dependencies:
iconv-lite "~0.4.13"
end-of-stream@^1.0.0, end-of-stream@^1.1.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43"
@ -2574,6 +2584,18 @@ faye-websocket@~0.11.0:
dependencies:
websocket-driver ">=0.5.1"
fbjs@^0.8.16:
version "0.8.16"
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db"
dependencies:
core-js "^1.0.0"
isomorphic-fetch "^2.1.1"
loose-envify "^1.0.0"
object-assign "^4.1.0"
promise "^7.1.1"
setimmediate "^1.0.5"
ua-parser-js "^0.7.9"
figures@^1.3.5:
version "1.7.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
@ -3269,7 +3291,7 @@ https-browserify@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
iconv-lite@0.4.19:
iconv-lite@0.4.19, iconv-lite@~0.4.13:
version "0.4.19"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b"
@ -3636,7 +3658,7 @@ is-resolvable@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88"
is-stream@^1.1.0:
is-stream@^1.0.1, is-stream@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
@ -3684,6 +3706,13 @@ isobject@^3.0.0, isobject@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
isomorphic-fetch@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9"
dependencies:
node-fetch "^1.0.1"
whatwg-fetch ">=0.10.0"
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@ -3713,6 +3742,10 @@ istanbul-lib-instrument@^1.7.3:
istanbul-lib-coverage "^1.1.1"
semver "^5.3.0"
jquery@>=1.9.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"
js-base64@^2.1.8, js-base64@^2.1.9:
version "2.4.3"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.3.tgz#2e545ec2b0f2957f41356510205214e98fad6582"
@ -3934,6 +3967,14 @@ linkify-it@^2.0.0:
dependencies:
uc.micro "^1.0.1"
linkifyjs@^2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-2.1.5.tgz#effc9f01e4aeafbbdbef21a45feab38b9516f93e"
optionalDependencies:
jquery ">=1.9.0"
react ">=0.14.0"
react-dom ">=0.14.0"
load-ip-set@^1.2.7:
version "1.3.1"
resolved "https://registry.yarnpkg.com/load-ip-set/-/load-ip-set-1.3.1.tgz#cfd050c6916e7ba0ca85d0b566e7854713eb495e"
@ -4122,7 +4163,7 @@ longest@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
loose-envify@^1.0.0:
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
dependencies:
@ -4546,6 +4587,13 @@ node-abi@^2.1.1:
dependencies:
semver "^5.4.1"
node-fetch@^1.0.1:
version "1.7.3"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
dependencies:
encoding "^0.1.11"
is-stream "^1.0.1"
node-forge@0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.1.tgz#9da611ea08982f4b94206b3beb4cc9665f20c300"
@ -5487,6 +5535,14 @@ promise@^7.1.1:
dependencies:
asap "~2.0.3"
prop-types@^15.6.0:
version "15.6.0"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.3.1"
object-assign "^4.1.1"
proxy-addr@~2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.2.tgz#6571504f47bb988ec8180253f85dd7e14952bdec"
@ -5665,6 +5721,24 @@ rc@^1.1.6, rc@^1.1.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-dom@>=0.14.0:
version "16.2.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.2.0.tgz#69003178601c0ca19b709b33a83369fe6124c044"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.0"
react@>=0.14.0:
version "16.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.0"
read-cache@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774"
@ -6253,7 +6327,7 @@ set-value@^2.0.0:
is-plain-object "^2.0.3"
split-string "^3.0.1"
setimmediate@^1.0.4:
setimmediate@^1.0.4, setimmediate@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
@ -7079,6 +7153,10 @@ typescript@2.6, typescript@~2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.6.2.tgz#3c5b6fd7f6de0914269027f03c0946758f7673a4"
ua-parser-js@^0.7.9:
version "0.7.17"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
uc.micro@^1.0.1, uc.micro@^1.0.3:
version "1.0.5"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376"
@ -7597,6 +7675,10 @@ webtorrent@^0.98.0:
xtend "^4.0.1"
zero-fill "^2.2.3"
whatwg-fetch@>=0.10.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"
when@~3.6.x:
version "3.6.4"
resolved "https://registry.yarnpkg.com/when/-/when-3.6.4.tgz#473b517ec159e2b85005497a13983f095412e34e"

View File

@ -1,6 +1,7 @@
import * as express from 'express'
import { CONFIG, EMBED_SIZE, PREVIEWS_SIZE } from '../initializers'
import { asyncMiddleware, oembedValidator } from '../middlewares'
import { accountsNameWithHostGetValidator } from '../middlewares/validators'
import { VideoModel } from '../models/video/video'
const servicesRouter = express.Router()
@ -9,6 +10,10 @@ servicesRouter.use('/oembed',
asyncMiddleware(oembedValidator),
generateOEmbed
)
servicesRouter.use('/redirect/accounts/:nameWithHost',
asyncMiddleware(accountsNameWithHostGetValidator),
redirectToAccountUrl
)
// ---------------------------------------------------------------------------
@ -62,3 +67,7 @@ function generateOEmbed (req: express.Request, res: express.Response, next: expr
return res.json(json)
}
function redirectToAccountUrl (req: express.Request, res: express.Response, next: express.NextFunction) {
return res.redirect(res.locals.account.Actor.url)
}

View File

@ -31,6 +31,16 @@ function isLocalAccountNameExist (name: string, res: Response) {
return isAccountExist(promise, res)
}
function isAccountNameWithHostExist (nameWithDomain: string, res: Response) {
const [ accountName, host ] = nameWithDomain.split('@')
let promise: Bluebird<AccountModel>
if (!host) promise = AccountModel.loadLocalByName(accountName)
else promise = AccountModel.loadLocalByNameAndHost(accountName, host)
return isAccountExist(promise, res)
}
async function isAccountExist (p: Bluebird<AccountModel>, res: Response) {
const account = await p
@ -53,5 +63,6 @@ export {
isAccountIdExist,
isLocalAccountNameExist,
isAccountDescriptionValid,
isAccountNameWithHostExist,
isAccountNameValid
}

View File

@ -59,7 +59,7 @@ const logger = new winston.createLogger({
)
}),
new winston.transports.Console({
handleExcegiptions: true,
handleExceptions: true,
humanReadableUnhandledException: true,
format: winston.format.combine(
timestampFormatter,

View File

@ -1,6 +1,11 @@
import * as express from 'express'
import { param } from 'express-validator/check'
import { isAccountIdExist, isAccountNameValid, isLocalAccountNameExist } from '../../helpers/custom-validators/accounts'
import {
isAccountIdExist,
isAccountNameValid,
isAccountNameWithHostExist,
isLocalAccountNameExist
} from '../../helpers/custom-validators/accounts'
import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc'
import { logger } from '../../helpers/logger'
import { areValidationErrors } from './utils'
@ -31,9 +36,23 @@ const accountsGetValidator = [
}
]
const accountsNameWithHostGetValidator = [
param('nameWithHost').exists().withMessage('Should have an account name with host'),
async (req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.debug('Checking accountsNameWithHostGetValidator parameters', { parameters: req.params })
if (areValidationErrors(req, res)) return
if (!await isAccountNameWithHostExist(req.params.nameWithHost, res)) return
return next()
}
]
// ---------------------------------------------------------------------------
export {
localAccountValidator,
accountsGetValidator
accountsGetValidator,
accountsNameWithHostGetValidator
}

View File

@ -157,7 +157,6 @@ export class AccountModel extends Model<AccountModel> {
static loadLocalByName (name: string) {
const query = {
where: {
name,
[ Sequelize.Op.or ]: [
{
userId: {
@ -170,7 +169,41 @@ export class AccountModel extends Model<AccountModel> {
}
}
]
}
},
include: [
{
model: ActorModel,
required: true,
where: {
preferredUsername: name
}
}
]
}
return AccountModel.findOne(query)
}
static loadLocalByNameAndHost (name: string, host: string) {
const query = {
include: [
{
model: ActorModel,
required: true,
where: {
preferredUsername: name
},
include: [
{
model: ServerModel,
required: true,
where: {
host
}
}
]
}
]
}
return AccountModel.findOne(query)