feat: collapse mentions (#1034)
parent
d39ea9a6de
commit
36ae8be40a
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<p flex="~ gap-1" items-center text-sm class="zen-none">
|
||||||
|
<span i-ri-arrow-right-line ml--1 text-secondary-light /><slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
|
@ -19,6 +19,7 @@ const vnode = $computed(() => {
|
||||||
emojis: emojisObject.value,
|
emojis: emojisObject.value,
|
||||||
mentions: 'mentions' in status ? status.mentions : undefined,
|
mentions: 'mentions' in status ? status.mentions : undefined,
|
||||||
markdown: true,
|
markdown: true,
|
||||||
|
collapseMentionLink: !!('inReplyToId' in status && status.inReplyToId),
|
||||||
})
|
})
|
||||||
return vnode
|
return vnode
|
||||||
})
|
})
|
||||||
|
|
|
@ -13,6 +13,7 @@ export interface ContentParseOptions {
|
||||||
replaceUnicodeEmoji?: boolean
|
replaceUnicodeEmoji?: boolean
|
||||||
astTransforms?: Transform[]
|
astTransforms?: Transform[]
|
||||||
convertMentionLink?: boolean
|
convertMentionLink?: boolean
|
||||||
|
collapseMentionLink?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizerBasicClasses = filterClasses(/^(h-\S*|p-\S*|u-\S*|dt-\S*|e-\S*|mention|hashtag|ellipsis|invisible)$/u)
|
const sanitizerBasicClasses = filterClasses(/^(h-\S*|p-\S*|u-\S*|dt-\S*|e-\S*|mention|hashtag|ellipsis|invisible)$/u)
|
||||||
|
@ -48,6 +49,7 @@ export function parseMastodonHTML(
|
||||||
markdown = true,
|
markdown = true,
|
||||||
replaceUnicodeEmoji = true,
|
replaceUnicodeEmoji = true,
|
||||||
convertMentionLink = false,
|
convertMentionLink = false,
|
||||||
|
collapseMentionLink = false,
|
||||||
mentions,
|
mentions,
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
|
@ -89,6 +91,9 @@ export function parseMastodonHTML(
|
||||||
|
|
||||||
transforms.push(transformParagraphs)
|
transforms.push(transformParagraphs)
|
||||||
|
|
||||||
|
if (collapseMentionLink)
|
||||||
|
transforms.push(transformCollapseMentions())
|
||||||
|
|
||||||
return transformSync(parse(html), transforms)
|
return transformSync(parse(html), transforms)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,16 +179,16 @@ export function treeToText(input: Node): string {
|
||||||
// Strings get converted to text nodes.
|
// Strings get converted to text nodes.
|
||||||
// The input node's children have been transformed before the node itself
|
// The input node's children have been transformed before the node itself
|
||||||
// gets transformed.
|
// gets transformed.
|
||||||
type Transform = (node: Node) => (Node | string)[] | Node | string | null
|
type Transform = (node: Node, root: Node) => (Node | string)[] | Node | string | null
|
||||||
|
|
||||||
// Helpers for transforming (filtering, modifying, ...) a parsed HTML tree
|
// Helpers for transforming (filtering, modifying, ...) a parsed HTML tree
|
||||||
// by running the given chain of transform functions one-by-one.
|
// by running the given chain of transform functions one-by-one.
|
||||||
function transformSync(doc: Node, transforms: Transform[]) {
|
function transformSync(doc: Node, transforms: Transform[]) {
|
||||||
function visit(node: Node, transform: Transform, isRoot = false) {
|
function visit(node: Node, transform: Transform, root: Node) {
|
||||||
if (Array.isArray(node.children)) {
|
if (Array.isArray(node.children)) {
|
||||||
const children = [] as (Node | string)[]
|
const children = [] as (Node | string)[]
|
||||||
for (let i = 0; i < node.children.length; i++) {
|
for (let i = 0; i < node.children.length; i++) {
|
||||||
const result = visit(node.children[i], transform)
|
const result = visit(node.children[i], transform, root)
|
||||||
if (Array.isArray(result))
|
if (Array.isArray(result))
|
||||||
children.push(...result)
|
children.push(...result)
|
||||||
|
|
||||||
|
@ -198,11 +203,11 @@ function transformSync(doc: Node, transforms: Transform[]) {
|
||||||
return value
|
return value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return isRoot ? node : transform(node)
|
return transform(node, root)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const transform of transforms)
|
for (const transform of transforms)
|
||||||
doc = visit(doc, transform, true) as Node
|
doc = visit(doc, transform, doc) as Node
|
||||||
|
|
||||||
return doc
|
return doc
|
||||||
}
|
}
|
||||||
|
@ -376,6 +381,48 @@ function transformParagraphs(node: Node): Node | Node[] {
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transformCollapseMentions() {
|
||||||
|
let processed = false
|
||||||
|
function isMention(node: Node) {
|
||||||
|
const child = node.children?.length === 1 ? node.children[0] : null
|
||||||
|
return Boolean(child?.name === 'a' && child.attributes.class?.includes('mention'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (node: Node, root: Node): Node | Node[] => {
|
||||||
|
if (processed || node.parent !== root)
|
||||||
|
return node
|
||||||
|
const metions: (Node | undefined)[] = []
|
||||||
|
const children = node.children as Node[]
|
||||||
|
for (const child of children) {
|
||||||
|
// metion
|
||||||
|
if (isMention(child)) {
|
||||||
|
metions.push(child)
|
||||||
|
}
|
||||||
|
// spaces in between
|
||||||
|
else if (child.type === TEXT_NODE && !child.value.trim()) {
|
||||||
|
metions.push(child)
|
||||||
|
}
|
||||||
|
// other content, stop collapsing
|
||||||
|
else {
|
||||||
|
if (child.type === TEXT_NODE)
|
||||||
|
child.value = child.value.trimStart()
|
||||||
|
// remove <br> after mention
|
||||||
|
if (child.name === 'br')
|
||||||
|
metions.push(undefined)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processed = true
|
||||||
|
if (metions.length === 0)
|
||||||
|
return node
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: [h('mention-group', null, ...metions.filter(Boolean)), ...children.slice(metions.length)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function transformMentionLink(node: Node): string | Node | (string | Node)[] | null {
|
function transformMentionLink(node: Node): string | Node | (string | Node)[] | null {
|
||||||
if (node.name === 'a' && node.attributes.class?.includes('mention')) {
|
if (node.name === 'a' && node.attributes.class?.includes('mention')) {
|
||||||
const href = node.attributes.href
|
const href = node.attributes.href
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { decode } from 'tiny-decode'
|
||||||
import type { ContentParseOptions } from './content-parse'
|
import type { ContentParseOptions } from './content-parse'
|
||||||
import { parseMastodonHTML } from './content-parse'
|
import { parseMastodonHTML } from './content-parse'
|
||||||
import ContentCode from '~/components/content/ContentCode.vue'
|
import ContentCode from '~/components/content/ContentCode.vue'
|
||||||
|
import ContentMentionGroup from '~/components/content/ContentMentionGroup.vue'
|
||||||
import AccountHoverWrapper from '~/components/account/AccountHoverWrapper.vue'
|
import AccountHoverWrapper from '~/components/account/AccountHoverWrapper.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -17,13 +18,16 @@ export function contentToVNode(
|
||||||
options?: ContentParseOptions,
|
options?: ContentParseOptions,
|
||||||
): VNode {
|
): VNode {
|
||||||
const tree = parseMastodonHTML(content, options)
|
const tree = parseMastodonHTML(content, options)
|
||||||
return h(Fragment, (tree.children as Node[]).map(n => treeToVNode(n)))
|
return h(Fragment, (tree.children as Node[] || []).map(n => treeToVNode(n)))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nodeToVNode(node: Node): VNode | string | null {
|
export function nodeToVNode(node: Node): VNode | string | null {
|
||||||
if (node.type === TEXT_NODE)
|
if (node.type === TEXT_NODE)
|
||||||
return node.value
|
return node.value
|
||||||
|
|
||||||
|
if (node.name === 'mention-group')
|
||||||
|
return h(ContentMentionGroup, node.attributes, () => node.children.map(treeToVNode))
|
||||||
|
|
||||||
if ('children' in node) {
|
if ('children' in node) {
|
||||||
if (node.name === 'a' && (node.attributes.href?.startsWith('/') || node.attributes.href?.startsWith('.'))) {
|
if (node.name === 'a' && (node.attributes.href?.startsWith('/') || node.attributes.href?.startsWith('.'))) {
|
||||||
node.attributes.to = node.attributes.href
|
node.attributes.to = node.attributes.href
|
||||||
|
|
|
@ -77,6 +77,10 @@ body {
|
||||||
--at-apply: 'op0 hover:op100 transition duration-600';
|
--at-apply: 'op0 hover:op100 transition duration-600';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zen .zen-none {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-emoji {
|
.custom-emoji {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
* @vitest-environment jsdom
|
* @vitest-environment jsdom
|
||||||
*/
|
*/
|
||||||
/* eslint-disable vue/one-component-per-file */
|
/* eslint-disable vue/one-component-per-file */
|
||||||
import type { mastodon } from 'masto'
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { renderToString } from 'vue/server-renderer'
|
import { renderToString } from 'vue/server-renderer'
|
||||||
import { format } from 'prettier'
|
import { format } from 'prettier'
|
||||||
import { contentToVNode } from '~/composables/content-render'
|
import { contentToVNode } from '~/composables/content-render'
|
||||||
|
import type { ContentParseOptions } from '~~/composables/content-parse'
|
||||||
|
|
||||||
describe('content-rich', () => {
|
describe('content-rich', () => {
|
||||||
it('empty', async () => {
|
it('empty', async () => {
|
||||||
|
@ -26,7 +26,9 @@ describe('content-rich', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('group mention', async () => {
|
it('group mention', async () => {
|
||||||
const { formatted } = await render('<p><span class="h-card"><a href="https://lemmy.ml/c/pilipinas" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>pilipinas</span></a></span></p>', undefined, [{ id: '', username: 'pilipinas', url: 'https://lemmy.ml/c/pilipinas', acct: 'pilipinas@lemmy.ml' }])
|
const { formatted } = await render('<p><span class="h-card"><a href="https://lemmy.ml/c/pilipinas" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>pilipinas</span></a></span></p>', {
|
||||||
|
mentions: [{ id: '', username: 'pilipinas', url: 'https://lemmy.ml/c/pilipinas', acct: 'pilipinas@lemmy.ml' }],
|
||||||
|
})
|
||||||
expect(formatted).toMatchSnapshot('html')
|
expect(formatted).toMatchSnapshot('html')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -42,11 +44,13 @@ describe('content-rich', () => {
|
||||||
|
|
||||||
it('custom emoji', async () => {
|
it('custom emoji', async () => {
|
||||||
const { formatted } = await render('Daniel Roe :nuxt:', {
|
const { formatted } = await render('Daniel Roe :nuxt:', {
|
||||||
nuxt: {
|
emojis: {
|
||||||
shortcode: 'nuxt',
|
nuxt: {
|
||||||
url: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/original/c96ba3cb0e0e1eac.png',
|
shortcode: 'nuxt',
|
||||||
staticUrl: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/static/c96ba3cb0e0e1eac.png',
|
url: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/original/c96ba3cb0e0e1eac.png',
|
||||||
visibleInPicker: true,
|
staticUrl: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/static/c96ba3cb0e0e1eac.png',
|
||||||
|
visibleInPicker: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(formatted).toMatchSnapshot()
|
expect(formatted).toMatchSnapshot()
|
||||||
|
@ -72,10 +76,65 @@ describe('content-rich', () => {
|
||||||
const { formatted } = await render('<p>```<br /><br />```<br /></p>')
|
const { formatted } = await render('<p>```<br /><br />```<br /></p>')
|
||||||
expect(formatted).toMatchSnapshot()
|
expect(formatted).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('collapse metions', async () => {
|
||||||
|
const { formatted } = await render('<p><span class="h-card"><a href="https://m.webtoo.ls/@elk" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>elk</span></a></span> <span class="h-card"><a href="https://m.webtoo.ls/@elk" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>elk</span></a></span> content <span class="h-card"><a href="https://m.webtoo.ls/@antfu" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>antfu</span></a></span> <span class="h-card"><a href="https://mastodon.roe.dev/@daniel" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>daniel</span></a></span> <span class="h-card"><a href="https://m.webtoo.ls/@sxzz" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>sxzz</span></a></span> <span class="h-card"><a href="https://m.webtoo.ls/@patak" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>patak</span></a></span> content</p>', {
|
||||||
|
collapseMentionLink: true,
|
||||||
|
})
|
||||||
|
expect(formatted).toMatchInlineSnapshot(`
|
||||||
|
"<p>
|
||||||
|
<mention-group
|
||||||
|
><span class=\\"h-card\\"
|
||||||
|
><a
|
||||||
|
class=\\"u-url mention\\"
|
||||||
|
rel=\\"nofollow noopener noreferrer\\"
|
||||||
|
to=\\"/m.webtoo.ls/@elk\\"
|
||||||
|
></a
|
||||||
|
></span>
|
||||||
|
<span class=\\"h-card\\"
|
||||||
|
><a
|
||||||
|
class=\\"u-url mention\\"
|
||||||
|
rel=\\"nofollow noopener noreferrer\\"
|
||||||
|
to=\\"/m.webtoo.ls/@elk\\"
|
||||||
|
></a></span></mention-group
|
||||||
|
>content
|
||||||
|
<span class=\\"h-card\\"
|
||||||
|
><a
|
||||||
|
class=\\"u-url mention\\"
|
||||||
|
rel=\\"nofollow noopener noreferrer\\"
|
||||||
|
to=\\"/m.webtoo.ls/@antfu\\"
|
||||||
|
></a
|
||||||
|
></span>
|
||||||
|
<span class=\\"h-card\\"
|
||||||
|
><a
|
||||||
|
class=\\"u-url mention\\"
|
||||||
|
rel=\\"nofollow noopener noreferrer\\"
|
||||||
|
to=\\"/mastodon.roe.dev/@daniel\\"
|
||||||
|
></a
|
||||||
|
></span>
|
||||||
|
<span class=\\"h-card\\"
|
||||||
|
><a
|
||||||
|
class=\\"u-url mention\\"
|
||||||
|
rel=\\"nofollow noopener noreferrer\\"
|
||||||
|
to=\\"/m.webtoo.ls/@sxzz\\"
|
||||||
|
></a
|
||||||
|
></span>
|
||||||
|
<span class=\\"h-card\\"
|
||||||
|
><a
|
||||||
|
class=\\"u-url mention\\"
|
||||||
|
rel=\\"nofollow noopener noreferrer\\"
|
||||||
|
to=\\"/m.webtoo.ls/@patak\\"
|
||||||
|
></a
|
||||||
|
></span>
|
||||||
|
content
|
||||||
|
</p>
|
||||||
|
"
|
||||||
|
`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
async function render(content: string, emojis?: Record<string, mastodon.v1.CustomEmoji>, mentions?: mastodon.v1.StatusMention[]) {
|
async function render(content: string, options?: ContentParseOptions) {
|
||||||
const vnode = contentToVNode(content, { emojis, mentions })
|
const vnode = contentToVNode(content, options)
|
||||||
const html = (await renderToString(vnode))
|
const html = (await renderToString(vnode))
|
||||||
.replace(/<!--[\[\]]-->/g, '')
|
.replace(/<!--[\[\]]-->/g, '')
|
||||||
let formatted = ''
|
let formatted = ''
|
||||||
|
@ -129,6 +188,16 @@ vi.mock('~/components/content/ContentCode.vue', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock('~/components/content/ContentMentionGroup.vue', () => {
|
||||||
|
return {
|
||||||
|
default: defineComponent({
|
||||||
|
setup(props, { slots }) {
|
||||||
|
return () => h('mention-group', null, { default: () => slots?.default?.() })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
vi.mock('~/components/account/AccountHoverWrapper.vue', () => {
|
vi.mock('~/components/account/AccountHoverWrapper.vue', () => {
|
||||||
return {
|
return {
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
|
|
Loading…
Reference in New Issue