diff --git a/components/content/ContentMentionGroup.vue b/components/content/ContentMentionGroup.vue
new file mode 100644
index 00000000..cdb9f877
--- /dev/null
+++ b/components/content/ContentMentionGroup.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/components/status/StatusBody.vue b/components/status/StatusBody.vue
index eab2637f..92238be2 100644
--- a/components/status/StatusBody.vue
+++ b/components/status/StatusBody.vue
@@ -19,6 +19,7 @@ const vnode = $computed(() => {
emojis: emojisObject.value,
mentions: 'mentions' in status ? status.mentions : undefined,
markdown: true,
+ collapseMentionLink: !!('inReplyToId' in status && status.inReplyToId),
})
return vnode
})
diff --git a/composables/content-parse.ts b/composables/content-parse.ts
index e2f741e7..d7c7e2e0 100644
--- a/composables/content-parse.ts
+++ b/composables/content-parse.ts
@@ -13,6 +13,7 @@ export interface ContentParseOptions {
replaceUnicodeEmoji?: boolean
astTransforms?: Transform[]
convertMentionLink?: boolean
+ collapseMentionLink?: boolean
}
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,
replaceUnicodeEmoji = true,
convertMentionLink = false,
+ collapseMentionLink = false,
mentions,
} = options
@@ -89,6 +91,9 @@ export function parseMastodonHTML(
transforms.push(transformParagraphs)
+ if (collapseMentionLink)
+ transforms.push(transformCollapseMentions())
+
return transformSync(parse(html), transforms)
}
@@ -174,16 +179,16 @@ export function treeToText(input: Node): string {
// Strings get converted to text nodes.
// The input node's children have been transformed before the node itself
// 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
// by running the given chain of transform functions one-by-one.
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)) {
const children = [] as (Node | string)[]
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))
children.push(...result)
@@ -198,11 +203,11 @@ function transformSync(doc: Node, transforms: Transform[]) {
return value
})
}
- return isRoot ? node : transform(node)
+ return transform(node, root)
}
for (const transform of transforms)
- doc = visit(doc, transform, true) as Node
+ doc = visit(doc, transform, doc) as Node
return doc
}
@@ -376,6 +381,48 @@ function transformParagraphs(node: Node): Node | 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
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 {
if (node.name === 'a' && node.attributes.class?.includes('mention')) {
const href = node.attributes.href
diff --git a/composables/content-render.ts b/composables/content-render.ts
index e082cacd..fe698907 100644
--- a/composables/content-render.ts
+++ b/composables/content-render.ts
@@ -7,6 +7,7 @@ import { decode } from 'tiny-decode'
import type { ContentParseOptions } from './content-parse'
import { parseMastodonHTML } from './content-parse'
import ContentCode from '~/components/content/ContentCode.vue'
+import ContentMentionGroup from '~/components/content/ContentMentionGroup.vue'
import AccountHoverWrapper from '~/components/account/AccountHoverWrapper.vue'
/**
@@ -17,13 +18,16 @@ export function contentToVNode(
options?: ContentParseOptions,
): VNode {
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 {
if (node.type === TEXT_NODE)
return node.value
+ if (node.name === 'mention-group')
+ return h(ContentMentionGroup, node.attributes, () => node.children.map(treeToVNode))
+
if ('children' in node) {
if (node.name === 'a' && (node.attributes.href?.startsWith('/') || node.attributes.href?.startsWith('.'))) {
node.attributes.to = node.attributes.href
diff --git a/styles/global.css b/styles/global.css
index 41b70f06..605943d2 100644
--- a/styles/global.css
+++ b/styles/global.css
@@ -77,6 +77,10 @@ body {
--at-apply: 'op0 hover:op100 transition duration-600';
}
+.zen .zen-none {
+ display: none;
+}
+
.custom-emoji {
display: inline-block;
overflow: hidden;
diff --git a/tests/content-rich.test.ts b/tests/content-rich.test.ts
index 920ab457..f8e5b197 100644
--- a/tests/content-rich.test.ts
+++ b/tests/content-rich.test.ts
@@ -2,11 +2,11 @@
* @vitest-environment jsdom
*/
/* eslint-disable vue/one-component-per-file */
-import type { mastodon } from 'masto'
import { describe, expect, it, vi } from 'vitest'
import { renderToString } from 'vue/server-renderer'
import { format } from 'prettier'
import { contentToVNode } from '~/composables/content-render'
+import type { ContentParseOptions } from '~~/composables/content-parse'
describe('content-rich', () => {
it('empty', async () => {
@@ -26,7 +26,9 @@ describe('content-rich', () => {
})
it('group mention', async () => {
- const { formatted } = await render('
@pilipinas
', undefined, [{ id: '', username: 'pilipinas', url: 'https://lemmy.ml/c/pilipinas', acct: 'pilipinas@lemmy.ml' }])
+ const { formatted } = await render('@pilipinas
', {
+ mentions: [{ id: '', username: 'pilipinas', url: 'https://lemmy.ml/c/pilipinas', acct: 'pilipinas@lemmy.ml' }],
+ })
expect(formatted).toMatchSnapshot('html')
})
@@ -42,11 +44,13 @@ describe('content-rich', () => {
it('custom emoji', async () => {
const { formatted } = await render('Daniel Roe :nuxt:', {
- nuxt: {
- shortcode: 'nuxt',
- url: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/original/c96ba3cb0e0e1eac.png',
- staticUrl: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/static/c96ba3cb0e0e1eac.png',
- visibleInPicker: true,
+ emojis: {
+ nuxt: {
+ shortcode: 'nuxt',
+ url: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/original/c96ba3cb0e0e1eac.png',
+ staticUrl: 'https://media.mas.to/masto-public/cache/custom_emojis/images/000/288/667/static/c96ba3cb0e0e1eac.png',
+ visibleInPicker: true,
+ },
},
})
expect(formatted).toMatchSnapshot()
@@ -72,10 +76,65 @@ describe('content-rich', () => {
const { formatted } = await render('```
```
')
expect(formatted).toMatchSnapshot()
})
+
+ it('collapse metions', async () => {
+ const { formatted } = await render('@elk @elk content @antfu @daniel @sxzz @patak content
', {
+ collapseMentionLink: true,
+ })
+ expect(formatted).toMatchInlineSnapshot(`
+ "
+
+ content
+
+
+
+
+ content
+
+ "
+ `)
+ })
})
-async function render(content: string, emojis?: Record, mentions?: mastodon.v1.StatusMention[]) {
- const vnode = contentToVNode(content, { emojis, mentions })
+async function render(content: string, options?: ContentParseOptions) {
+ const vnode = contentToVNode(content, options)
const html = (await renderToString(vnode))
.replace(//g, '')
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', () => {
return {
default: defineComponent({