From 36ae8be40a59c6d75984588d200a08ef9f17f1b7 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Fri, 13 Jan 2023 01:08:56 +0100 Subject: [PATCH] feat: collapse mentions (#1034) --- components/content/ContentMentionGroup.vue | 5 ++ components/status/StatusBody.vue | 1 + composables/content-parse.ts | 57 ++++++++++++-- composables/content-render.ts | 6 +- styles/global.css | 4 + tests/content-rich.test.ts | 87 +++++++++++++++++++--- 6 files changed, 145 insertions(+), 15 deletions(-) create mode 100644 components/content/ContentMentionGroup.vue 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({