feat: collapse mentions (#1034)

This commit is contained in:
Anthony Fu 2023-01-13 01:08:56 +01:00 committed by GitHub
parent d39ea9a6de
commit 36ae8be40a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 145 additions and 15 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({