2022-12-27 19:13:50 +00:00
// @unimport-disable
2023-01-08 06:21:09 +00:00
import type { mastodon } from 'masto'
2022-12-04 22:10:10 +00:00
import type { Node } from 'ultrahtml'
2023-01-07 22:42:17 +00:00
import { DOCUMENT_NODE , ELEMENT_NODE , TEXT_NODE , h , parse , render } from 'ultrahtml'
2023-01-02 04:53:53 +00:00
import { findAndReplaceEmojisInText } from '@iconify/utils'
2023-01-09 22:08:42 +00:00
import { decode } from 'tiny-decode'
2023-01-02 04:53:53 +00:00
import { emojiRegEx , getEmojiAttributes } from '../config/emojis'
2022-12-27 18:37:22 +00:00
2023-01-07 09:31:48 +00:00
export interface ContentParseOptions {
2023-01-08 06:21:09 +00:00
emojis? : Record < string , mastodon.v1.CustomEmoji >
2023-02-05 13:00:25 +00:00
hideEmojis? : boolean
2023-01-11 17:18:06 +00:00
mentions? : mastodon.v1.StatusMention [ ]
2023-01-07 09:31:48 +00:00
markdown? : boolean
replaceUnicodeEmoji? : boolean
astTransforms? : Transform [ ]
2023-01-08 09:39:11 +00:00
convertMentionLink? : boolean
2023-01-13 00:08:56 +00:00
collapseMentionLink? : boolean
2023-01-18 15:59:37 +00:00
status? : mastodon.v1.Status
inReplyToStatus? : mastodon.v1.Status
2023-01-07 09:31:48 +00:00
}
2024-08-16 15:52:08 +01:00
const sanitizerBasicClasses = filterClasses ( / ^ h - \ S * | p - \ S * | u - \ S * | d t - \ S * | e - \ S * | m e n t i o n | h a s h t a g | e l l i p s i s | i n v i s i b l e $ / u )
2023-01-07 09:31:48 +00:00
const sanitizer = sanitize ( {
// Allow basic elements as seen in https://github.com/mastodon/mastodon/blob/17f79082b098e05b68d6f0d38fabb3ac121879a9/lib/sanitize_ext/sanitize_config.rb
br : { } ,
p : { } ,
a : {
href : filterHref ( ) ,
class : sanitizerBasicClasses ,
rel : set ( 'nofollow noopener noreferrer' ) ,
target : set ( '_blank' ) ,
} ,
span : {
class : sanitizerBasicClasses ,
} ,
// Allow elements potentially created for Markdown code blocks above
pre : { } ,
code : {
class : filterClasses ( /^language-\w+$/ ) ,
} ,
2023-01-16 18:04:27 +00:00
// Other elements supported in glitch, as seen in
// https://github.com/glitch-soc/mastodon/blob/13227e1dafd308dfe1a3effc3379b766274809b3/lib/sanitize_ext/sanitize_config.rb#L75
abbr : {
title : keep ,
} ,
del : { } ,
blockquote : {
cite : filterHref ( ) ,
} ,
b : { } ,
strong : { } ,
u : { } ,
sub : { } ,
sup : { } ,
i : { } ,
em : { } ,
2023-01-14 10:40:53 +00:00
h1 : { } ,
2023-01-16 18:04:27 +00:00
h2 : { } ,
h3 : { } ,
h4 : { } ,
h5 : { } ,
2023-01-14 10:40:53 +00:00
ul : { } ,
2023-01-16 18:04:27 +00:00
ol : {
start : keep ,
reversed : keep ,
} ,
li : {
value : keep ,
} ,
2023-01-07 09:31:48 +00:00
} )
2022-11-30 05:27:24 +00:00
/ * *
* Parse raw HTML form Mastodon server to AST ,
* with interop of custom emojis and inline Markdown syntax
2024-01-04 19:51:32 +00:00
* @param html The content to parse
* @param options The parsing options
2022-11-30 05:27:24 +00:00
* /
2023-01-07 09:31:48 +00:00
export function parseMastodonHTML (
html : string ,
options : ContentParseOptions = { } ,
) {
const {
markdown = true ,
replaceUnicodeEmoji = true ,
2023-01-08 09:39:11 +00:00
convertMentionLink = false ,
2023-01-13 00:08:56 +00:00
collapseMentionLink = false ,
2023-02-05 13:00:25 +00:00
hideEmojis = false ,
2023-01-11 17:18:06 +00:00
mentions ,
2023-01-18 15:59:37 +00:00
status ,
inReplyToStatus ,
2023-01-07 09:31:48 +00:00
} = options
2022-12-17 21:01:20 +00:00
if ( markdown ) {
2023-01-05 07:21:09 +00:00
// Handle code blocks
html = html
2024-08-16 15:52:08 +01:00
/* eslint-disable regexp/no-super-linear-backtracking, regexp/no-misleading-capturing-group */
2023-01-07 09:31:48 +00:00
. replace ( />(```|~~~)(\w*)([\s\S]+?)\1/g , ( _1 , _2 , lang : string , raw : string ) = > {
2023-01-11 23:54:45 +00:00
const code = htmlToText ( raw )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
2023-01-17 11:26:36 +00:00
. replace ( /`/g , '`' )
2022-12-17 21:01:20 +00:00
const classes = lang ? ` class="language- ${ lang } " ` : ''
return ` ><pre><code ${ classes } > ${ code } </code></pre> `
} )
2023-01-11 16:24:13 +00:00
. replace ( /`([^`\n]*)`/g , ( _1 , raw ) = > {
return raw ? ` <code> ${ htmlToText ( raw ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) } </code> ` : ''
} )
2022-12-17 21:01:20 +00:00
}
2022-11-30 05:27:24 +00:00
2023-01-05 07:21:09 +00:00
// Always sanitize the raw HTML data *after* it has been modified
2023-01-07 09:31:48 +00:00
const transforms : Transform [ ] = [
sanitizer ,
. . . options . astTransforms || [ ] ,
]
2023-02-05 13:00:25 +00:00
if ( hideEmojis ) {
transforms . push ( removeUnicodeEmoji )
transforms . push ( removeCustomEmoji ( options . emojis ? ? { } ) )
}
else {
2023-02-04 17:02:05 +00:00
if ( replaceUnicodeEmoji )
transforms . push ( transformUnicodeEmoji )
transforms . push ( replaceCustomEmoji ( options . emojis ? ? { } ) )
}
2023-01-07 09:31:48 +00:00
if ( markdown )
transforms . push ( transformMarkdown )
2023-01-11 17:18:06 +00:00
if ( mentions ? . length )
transforms . push ( createTransformNamedMentions ( mentions ) )
2023-01-08 09:39:11 +00:00
if ( convertMentionLink )
transforms . push ( transformMentionLink )
2023-01-07 22:42:17 +00:00
transforms . push ( transformParagraphs )
2023-01-13 00:08:56 +00:00
if ( collapseMentionLink )
2023-01-18 15:59:37 +00:00
transforms . push ( transformCollapseMentions ( status , inReplyToStatus ) )
2023-01-13 00:08:56 +00:00
2023-01-07 09:31:48 +00:00
return transformSync ( parse ( html ) , transforms )
2022-11-30 05:27:24 +00:00
}
2023-01-02 04:53:53 +00:00
/ * *
* Converts raw HTML form Mastodon server to HTML for Tiptap editor
2024-01-04 19:51:32 +00:00
* @param html The content to parse
* @param customEmojis The custom emojis to use
2023-01-02 04:53:53 +00:00
* /
2023-01-08 06:21:09 +00:00
export function convertMastodonHTML ( html : string , customEmojis : Record < string , mastodon.v1.CustomEmoji > = { } ) {
2023-01-07 09:31:48 +00:00
const tree = parseMastodonHTML ( html , {
emojis : customEmojis ,
markdown : true ,
2023-01-08 09:39:11 +00:00
convertMentionLink : true ,
2023-01-07 09:31:48 +00:00
} )
2022-12-17 21:01:20 +00:00
return render ( tree )
2022-11-30 05:27:24 +00:00
}
2023-11-07 09:57:44 +00:00
export function sanitizeEmbeddedIframe ( html : string ) : Node {
const transforms : Transform [ ] = [
sanitize ( {
iframe : {
src : ( src ) = > {
if ( typeof src !== 'string' )
return undefined
const url = new URL ( src )
return url . protocol === 'https:' ? src : undefined
} ,
allowfullscreen : set ( 'true' ) ,
} ,
} ) ,
]
return transformSync ( parse ( html ) , transforms )
}
2022-11-25 16:17:15 +00:00
export function htmlToText ( html : string ) {
2023-01-12 11:13:20 +00:00
try {
const tree = parse ( html )
return ( tree . children as Node [ ] ) . map ( n = > treeToText ( n ) ) . join ( '' ) . trim ( )
}
catch ( err ) {
console . error ( err )
return ''
}
2022-11-24 03:42:03 +00:00
}
2023-03-02 14:27:32 +00:00
export function recursiveTreeToText ( input : Node ) : string {
if ( input && input . children && input . children . length > 0 )
return input . children . map ( ( n : Node ) = > recursiveTreeToText ( n ) ) . join ( '' )
else
return treeToText ( input )
}
2024-08-16 15:52:08 +01:00
const emojiIdNeedsWrappingRE = /^([\w\-])+$/
2023-05-08 20:10:52 +01:00
2022-11-30 06:50:47 +00:00
export function treeToText ( input : Node ) : string {
2022-11-24 03:42:03 +00:00
let pre = ''
2022-11-25 16:17:15 +00:00
let body = ''
let post = ''
2022-11-24 03:42:03 +00:00
2022-12-04 22:10:10 +00:00
if ( input . type === TEXT_NODE )
2023-01-09 22:08:42 +00:00
return decode ( input . value )
2022-11-24 03:42:03 +00:00
2022-12-04 22:10:10 +00:00
if ( input . name === 'br' )
2022-11-24 03:42:03 +00:00
return '\n'
2022-12-04 22:10:10 +00:00
if ( [ 'p' , 'pre' ] . includes ( input . name ) )
2022-11-24 03:42:03 +00:00
pre = '\n'
2023-01-16 12:36:22 +00:00
if ( input . attributes ? . [ 'data-type' ] === 'mention' ) {
const acct = input . attributes [ 'data-id' ]
if ( acct )
return acct . startsWith ( '@' ) ? acct : ` @ ${ acct } `
}
2022-12-04 22:10:10 +00:00
if ( input . name === 'code' ) {
if ( input . parent ? . name === 'pre' ) {
const lang = input . attributes . class ? . replace ( 'language-' , '' )
2022-11-25 19:21:53 +00:00
2022-11-30 04:50:29 +00:00
pre = ` \` \` \` ${ lang || '' } \ n `
post = '\n```'
}
else {
pre = '`'
post = '`'
}
}
2022-12-04 22:10:10 +00:00
else if ( input . name === 'b' || input . name === 'strong' ) {
2022-11-30 04:50:29 +00:00
pre = '**'
post = '**'
}
2022-12-04 22:10:10 +00:00
else if ( input . name === 'i' || input . name === 'em' ) {
2022-11-30 04:50:29 +00:00
pre = '*'
post = '*'
2022-11-25 16:17:15 +00:00
}
2022-12-04 22:10:10 +00:00
else if ( input . name === 'del' ) {
2022-11-30 06:50:47 +00:00
pre = '~~'
post = '~~'
}
2022-11-25 16:17:15 +00:00
2022-12-04 22:10:10 +00:00
if ( 'children' in input )
body = ( input . children as Node [ ] ) . map ( n = > treeToText ( n ) ) . join ( '' )
2022-11-24 03:42:03 +00:00
2023-01-16 00:51:52 +00:00
if ( input . name === 'img' || input . name === 'picture' ) {
2023-03-09 11:18:03 +00:00
if ( input . attributes . class ? . includes ( 'custom-emoji' ) ) {
2023-05-08 20:10:52 +01:00
const id = input . attributes [ 'data-emoji-id' ] ? ? input . attributes . alt ? ? input . attributes . title ? ? 'unknown'
return id . match ( emojiIdNeedsWrappingRE ) ? ` : ${ id } : ` : id
2023-03-09 11:18:03 +00:00
}
2023-01-02 04:53:53 +00:00
if ( input . attributes . class ? . includes ( 'iconify-emoji' ) )
return input . attributes . alt
}
2022-11-30 06:50:47 +00:00
2022-11-25 16:17:15 +00:00
return pre + body + post
2022-11-24 03:42:03 +00:00
}
2023-01-02 04:53:53 +00:00
2023-01-05 07:21:09 +00:00
// A tree transform function takes an ultrahtml Node object and returns
// new content that will replace the given node in the tree.
// Returning a null removes the node from the tree.
// Strings get converted to text nodes.
// The input node's children have been transformed before the node itself
// gets transformed.
2023-01-13 00:08:56 +00:00
type Transform = ( node : Node , root : Node ) = > ( Node | string ) [ ] | Node | string | null
2023-01-05 07:21:09 +00:00
// Helpers for transforming (filtering, modifying, ...) a parsed HTML tree
// by running the given chain of transform functions one-by-one.
function transformSync ( doc : Node , transforms : Transform [ ] ) {
2023-01-13 00:08:56 +00:00
function visit ( node : Node , transform : Transform , root : Node ) {
2023-01-05 07:21:09 +00:00
if ( Array . isArray ( node . children ) ) {
const children = [ ] as ( Node | string ) [ ]
for ( let i = 0 ; i < node . children . length ; i ++ ) {
2023-01-13 00:08:56 +00:00
const result = visit ( node . children [ i ] , transform , root )
2023-01-05 07:21:09 +00:00
if ( Array . isArray ( result ) )
children . push ( . . . result )
else if ( result )
children . push ( result )
}
node . children = children . map ( ( value ) = > {
if ( typeof value === 'string' )
return { type : TEXT_NODE , value , parent : node }
value . parent = node
return value
} )
}
2023-01-13 00:08:56 +00:00
return transform ( node , root )
2023-01-05 07:21:09 +00:00
}
for ( const transform of transforms )
2023-01-13 00:08:56 +00:00
doc = visit ( doc , transform , doc ) as Node
2023-01-05 07:21:09 +00:00
return doc
}
// A tree transform for sanitizing elements & their attributes.
type AttrSanitizers = Record < string , ( value : string | undefined ) = > string | undefined >
function sanitize ( allowedElements : Record < string , AttrSanitizers > ) : Transform {
return ( node ) = > {
if ( node . type !== ELEMENT_NODE )
return node
if ( ! Object . prototype . hasOwnProperty . call ( allowedElements , node . name ) )
return null
const attrSanitizers = allowedElements [ node . name ]
const attrs = { } as Record < string , string >
for ( const [ name , func ] of Object . entries ( attrSanitizers ) ) {
const value = func ( node . attributes [ name ] )
if ( value !== undefined )
attrs [ name ] = value
}
node . attributes = attrs
return node
}
}
function filterClasses ( allowed : RegExp ) {
return ( c : string | undefined ) = > {
if ( ! c )
return undefined
return c . split ( /\s/g ) . filter ( cls = > allowed . test ( cls ) ) . join ( ' ' )
}
}
2023-01-16 18:04:27 +00:00
function keep ( value : string | undefined ) {
return value
}
2023-01-05 07:21:09 +00:00
function set ( value : string ) {
return ( ) = > value
}
function filterHref() {
const LINK_PROTOCOLS = new Set ( [
'http:' ,
'https:' ,
'dat:' ,
'dweb:' ,
'ipfs:' ,
'ipns:' ,
'ssb:' ,
'gopher:' ,
'xmpp:' ,
'magnet:' ,
'gemini:' ,
] )
return ( href : string | undefined ) = > {
if ( href === undefined )
return undefined
// Allow relative links
if ( href . startsWith ( '/' ) || href . startsWith ( '.' ) )
return href
2023-10-22 17:14:37 +01:00
href = href . replace ( /&/g , '&' )
2023-01-05 07:21:09 +00:00
let url
try {
url = new URL ( href )
}
catch ( err ) {
if ( err instanceof TypeError )
return undefined
throw err
}
if ( LINK_PROTOCOLS . has ( url . protocol ) )
return url . toString ( )
return '#'
}
}
2023-02-04 17:02:05 +00:00
function removeUnicodeEmoji ( node : Node ) {
if ( node . type !== TEXT_NODE )
return node
let start = 0
const matches = [ ] as ( string | Node ) [ ]
findAndReplaceEmojisInText ( emojiRegEx , node . value , ( match , result ) = > {
matches . push ( result . slice ( start ) . trimEnd ( ) )
start = result . length + match . match . length
return undefined
} )
if ( matches . length === 0 )
return node
matches . push ( node . value . slice ( start ) )
return matches . filter ( Boolean )
}
2023-01-07 09:31:48 +00:00
function transformUnicodeEmoji ( node : Node ) {
if ( node . type !== TEXT_NODE )
return node
2023-01-05 07:21:09 +00:00
2023-01-07 09:31:48 +00:00
let start = 0
const matches = [ ] as ( string | Node ) [ ]
findAndReplaceEmojisInText ( emojiRegEx , node . value , ( match , result ) = > {
const attrs = getEmojiAttributes ( match )
matches . push ( result . slice ( start ) )
matches . push ( h ( 'img' , { src : attrs.src , alt : attrs.alt , class : attrs . class } ) )
start = result . length + match . match . length
return undefined
} )
if ( matches . length === 0 )
return node
2023-01-05 07:21:09 +00:00
2023-01-07 09:31:48 +00:00
matches . push ( node . value . slice ( start ) )
return matches . filter ( Boolean )
2023-01-05 07:21:09 +00:00
}
2023-02-04 17:02:05 +00:00
function removeCustomEmoji ( customEmojis : Record < string , mastodon.v1.CustomEmoji > ) : Transform {
return ( node ) = > {
if ( node . type !== TEXT_NODE )
return node
2024-08-16 15:52:08 +01:00
const split = node . value . split ( /\s?:([\w-]+):/g )
2023-02-04 17:02:05 +00:00
if ( split . length === 1 )
return node
return split . map ( ( name , i ) = > {
if ( i % 2 === 0 )
return name
const emoji = customEmojis [ name ] as mastodon . v1 . CustomEmoji
if ( ! emoji )
return ` : ${ name } : `
return ''
} ) . filter ( Boolean )
}
}
2023-01-08 06:21:09 +00:00
function replaceCustomEmoji ( customEmojis : Record < string , mastodon.v1.CustomEmoji > ) : Transform {
2023-01-05 07:21:09 +00:00
return ( node ) = > {
if ( node . type !== TEXT_NODE )
return node
2024-08-16 15:52:08 +01:00
const split = node . value . split ( /:([\w-]+):/g )
2023-01-05 07:21:09 +00:00
if ( split . length === 1 )
return node
return split . map ( ( name , i ) = > {
if ( i % 2 === 0 )
return name
2023-01-16 00:51:52 +00:00
const emoji = customEmojis [ name ] as mastodon . v1 . CustomEmoji
2023-01-05 07:21:09 +00:00
if ( ! emoji )
return ` : ${ name } : `
2023-01-16 00:51:52 +00:00
return h (
'picture' ,
{
'alt' : ` : ${ name } : ` ,
'class' : 'custom-emoji' ,
'data-emoji-id' : name ,
} ,
[
h (
'source' ,
{
srcset : emoji.staticUrl ,
media : '(prefers-reduced-motion: reduce)' ,
} ,
) ,
h (
'img' ,
{
src : emoji.url ,
alt : ` : ${ name } : ` ,
} ,
) ,
] ,
)
2023-01-05 07:21:09 +00:00
} ) . filter ( Boolean )
}
}
2023-01-07 09:31:48 +00:00
const _markdownReplacements : [ RegExp , ( c : ( string | Node ) [ ] ) = > Node ] [ ] = [
2023-02-23 07:13:39 +00:00
[ /\*\*\*(.*?)\*\*\*/g , ( [ c ] ) = > h ( 'b' , null , [ h ( 'em' , null , c ) ] ) ] ,
2023-01-07 09:31:48 +00:00
[ /\*\*(.*?)\*\*/g , c = > h ( 'b' , null , c ) ] ,
[ /\*(.*?)\*/g , c = > h ( 'em' , null , c ) ] ,
[ /~~(.*?)~~/g , c = > h ( 'del' , null , c ) ] ,
2024-08-16 15:52:08 +01:00
[ /`([^`]+)`/g , c = > h ( 'code' , null , c ) ] ,
2023-01-08 08:21:35 +00:00
// transform @username@twitter.com as links
2024-08-16 15:52:08 +01:00
[ /\B@(\w+)@twitter\.com\b/gi , c = > h ( 'a' , { href : ` https://twitter.com/ ${ c } ` , target : '_blank' , rel : 'nofollow noopener noreferrer' , class : 'mention external' } , ` @ ${ c } @twitter.com ` ) ] ,
2023-01-07 09:31:48 +00:00
]
2023-01-05 07:21:09 +00:00
2023-01-07 09:31:48 +00:00
function _markdownProcess ( value : string ) {
const results = [ ] as ( string | Node ) [ ]
2023-01-05 07:21:09 +00:00
2023-01-07 09:31:48 +00:00
let start = 0
while ( true ) {
2024-03-05 14:48:58 +00:00
let found : {
match : RegExpMatchArray
replacer : ( c : ( string | Node ) [ ] ) = > Node
} | undefined
2023-01-05 07:21:09 +00:00
2023-01-07 09:31:48 +00:00
for ( const [ re , replacer ] of _markdownReplacements ) {
re . lastIndex = start
2023-01-05 07:21:09 +00:00
2023-01-07 09:31:48 +00:00
const match = re . exec ( value )
if ( match ) {
if ( ! found || match . index < found . match . index ! )
found = { match , replacer }
2023-01-05 07:21:09 +00:00
}
}
2023-01-07 09:31:48 +00:00
if ( ! found )
break
2023-01-05 07:21:09 +00:00
2023-01-07 09:31:48 +00:00
results . push ( value . slice ( start , found . match . index ) )
results . push ( found . replacer ( _markdownProcess ( found . match [ 1 ] ) ) )
start = found . match . index ! + found . match [ 0 ] . length
2023-01-05 07:21:09 +00:00
}
2023-01-07 09:31:48 +00:00
results . push ( value . slice ( start ) )
return results . filter ( Boolean )
}
function transformMarkdown ( node : Node ) {
if ( node . type !== TEXT_NODE )
return node
return _markdownProcess ( node . value )
2023-01-02 04:53:53 +00:00
}
2023-01-07 22:42:17 +00:00
2024-03-05 06:25:58 +00:00
function addBdiParagraphs ( node : Node ) {
if ( node . name === 'p' && ! ( 'dir' in node . attributes ) && node . children ? . length && node . children . length > 1 )
node . attributes . dir = 'auto'
return node
}
2023-01-07 22:42:17 +00:00
function transformParagraphs ( node : Node ) : Node | Node [ ] {
2024-03-05 06:25:58 +00:00
// Add bdi to paragraphs
addBdiParagraphs ( node )
2023-01-07 22:42:17 +00:00
// For top level paragraphs, inject an empty <p> to preserve status paragraphs in our editor (except for the last one)
if ( node . parent ? . type === DOCUMENT_NODE && node . name === 'p' && node . parent . children . at ( - 1 ) !== node )
return [ node , h ( 'p' ) ]
2024-03-05 06:25:58 +00:00
2023-01-07 22:42:17 +00:00
return node
}
2023-01-08 09:39:11 +00:00
2023-01-18 15:59:37 +00:00
function isMention ( node : Node ) {
const child = node . children ? . length === 1 ? node . children [ 0 ] : null
return Boolean ( child ? . name === 'a' && child . attributes . class ? . includes ( 'mention' ) )
}
function isSpacing ( node : Node ) {
return node . type === TEXT_NODE && ! node . value . trim ( )
}
// Extract the username from a known mention node
function getMentionHandle ( node : Node ) : string | undefined {
2023-01-18 18:23:14 +00:00
return hrefToHandle ( node . children ? . [ 0 ] . attributes . href ) ? ? node . children ? . [ 0 ] ? . children ? . [ 0 ] ? . attributes ? . [ 'data-id' ]
2023-01-18 15:59:37 +00:00
}
function transformCollapseMentions ( status? : mastodon.v1.Status , inReplyToStatus? : mastodon.v1.Status ) : Transform {
2023-01-13 00:08:56 +00:00
let processed = false
return ( node : Node , root : Node ) : Node | Node [ ] = > {
2023-01-16 20:41:37 +00:00
if ( processed || node . parent !== root || ! node . children )
2023-01-13 00:08:56 +00:00
return node
2023-01-18 07:01:50 +00:00
const mentions : ( Node | undefined ) [ ] = [ ]
2023-01-13 00:08:56 +00:00
const children = node . children as Node [ ]
2023-01-19 10:26:01 +00:00
let trimContentStart : ( ( ) = > void ) | undefined
2023-01-13 00:08:56 +00:00
for ( const child of children ) {
2023-01-18 15:59:37 +00:00
// mention
2023-01-13 00:08:56 +00:00
if ( isMention ( child ) ) {
2023-01-18 07:01:50 +00:00
mentions . push ( child )
2023-01-13 00:08:56 +00:00
}
// spaces in between
2023-01-18 15:59:37 +00:00
else if ( isSpacing ( child ) ) {
2023-01-18 07:01:50 +00:00
mentions . push ( child )
2023-01-13 00:08:56 +00:00
}
// other content, stop collapsing
else {
2023-01-19 10:26:01 +00:00
if ( child . type === TEXT_NODE ) {
trimContentStart = ( ) = > {
child . value = child . value . trimStart ( )
}
}
2023-01-13 00:08:56 +00:00
// remove <br> after mention
if ( child . name === 'br' )
2023-01-18 07:01:50 +00:00
mentions . push ( undefined )
2023-01-13 00:08:56 +00:00
break
}
}
processed = true
2023-01-18 07:01:50 +00:00
if ( mentions . length === 0 )
2023-01-13 00:08:56 +00:00
return node
2023-01-18 18:23:14 +00:00
let mentionsCount = 0
let contextualMentionsCount = 0
2023-01-18 15:59:37 +00:00
let removeNextSpacing = false
2023-01-19 10:26:01 +00:00
2023-01-18 15:59:37 +00:00
const contextualMentions = mentions . filter ( ( mention ) = > {
if ( ! mention )
return false
if ( removeNextSpacing && isSpacing ( mention ) ) {
removeNextSpacing = false
return false
}
2023-01-18 18:23:14 +00:00
if ( isMention ( mention ) ) {
mentionsCount ++
if ( inReplyToStatus ) {
const mentionHandle = getMentionHandle ( mention )
2023-01-19 10:26:01 +00:00
if ( inReplyToStatus . account . acct === mentionHandle || inReplyToStatus . mentions . some ( m = > m . acct === mentionHandle ) ) {
removeNextSpacing = true
2023-01-18 18:23:14 +00:00
return false
2023-01-19 10:26:01 +00:00
}
2023-01-18 18:23:14 +00:00
}
contextualMentionsCount ++
2023-01-18 15:59:37 +00:00
}
return true
2023-01-19 10:26:01 +00:00
} ) as Node [ ]
2023-01-18 15:59:37 +00:00
// We have a special case for single mentions that are part of a reply.
// We already have the replying to badge in this case or the status is connected to the previous one.
2024-04-01 15:56:30 +01:00
// This is needed because the status doesn't include the in Reply to handle, only the account id.
2023-01-18 15:59:37 +00:00
// But this covers the majority of cases.
2023-01-18 18:23:14 +00:00
const showMentions = ! ( contextualMentionsCount === 0 || ( mentionsCount === 1 && status ? . inReplyToAccountId ) )
2023-01-19 10:26:01 +00:00
const grouped = contextualMentionsCount > 2
2023-01-19 11:12:58 +00:00
if ( ! showMentions || grouped )
2023-01-19 10:26:01 +00:00
trimContentStart ? . ( )
2023-01-18 15:59:37 +00:00
const contextualChildren = children . slice ( mentions . length )
2023-01-19 10:26:01 +00:00
const mentionNodes = showMentions ? ( grouped ? [ h ( 'mention-group' , null , . . . contextualMentions ) ] : contextualMentions ) : [ ]
2023-01-13 00:08:56 +00:00
return {
. . . node ,
2023-01-19 10:26:01 +00:00
children : [ . . . mentionNodes , . . . contextualChildren ] ,
2023-01-13 00:08:56 +00:00
}
}
}
2023-01-18 15:59:37 +00:00
function hrefToHandle ( href : string ) : string | undefined {
const matchUser = href . match ( UserLinkRE )
if ( matchUser ) {
const [ , server , username ] = matchUser
return ` ${ username } @ ${ server . replace ( /(.+\.)(.+\..+)/ , '$2' ) } `
}
}
2023-01-08 09:39:11 +00:00
function transformMentionLink ( node : Node ) : string | Node | ( string | Node ) [ ] | null {
if ( node . name === 'a' && node . attributes . class ? . includes ( 'mention' ) ) {
const href = node . attributes . href
if ( href ) {
2023-01-18 15:59:37 +00:00
const handle = hrefToHandle ( href )
if ( handle ) {
2023-01-16 11:40:47 +00:00
// convert to Tiptap mention node
2023-01-08 09:39:11 +00:00
return h ( 'span' , { 'data-type' : 'mention' , 'data-id' : handle } , handle )
}
}
}
return node
}
2023-01-11 17:18:06 +00:00
function createTransformNamedMentions ( mentions : mastodon.v1.StatusMention [ ] ) {
return ( node : Node ) : string | Node | ( string | Node ) [ ] | null = > {
if ( node . name === 'a' && node . attributes . class ? . includes ( 'mention' ) ) {
const href = node . attributes . href
const mention = href && mentions . find ( m = > m . url === href )
if ( mention ) {
node . attributes . href = ` / ${ currentServer . value } /@ ${ mention . acct } `
node . children = [ h ( 'span' , { 'data-type' : 'mention' , 'data-id' : mention . acct } , ` @ ${ mention . username } ` ) ]
return node
}
}
return node
}
}