feat: hide redudant mentions (#1293)

This commit is contained in:
patak 2023-01-18 16:59:37 +01:00 committed by GitHub
parent da2f19fb23
commit 3132f4fdea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 186 additions and 30 deletions

View File

@ -3,9 +3,11 @@ import type { mastodon } from 'masto'
const {
status,
newer,
withAction = true,
} = defineProps<{
status: mastodon.v1.Status | mastodon.v1.StatusEdit
newer?: mastodon.v1.Status
withAction?: boolean
}>()
@ -20,13 +22,15 @@ const vnode = $computed(() => {
mentions: 'mentions' in status ? status.mentions : undefined,
markdown: true,
collapseMentionLink: !!('inReplyToId' in status && status.inReplyToId),
status: 'id' in status ? status : undefined,
inReplyToStatus: newer,
})
return vnode
})
</script>
<template>
<div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }">
<div class="status-body" whitespace-pre-wrap break-words :class="{ 'with-action': withAction }" relative>
<span
v-if="status.content"
class="content-rich line-compact" dir="auto"

View File

@ -22,6 +22,7 @@ const props = withDefaults(
}>(),
{ actions: true },
)
const userSettings = useUserSettings()
const status = $computed(() => {
@ -183,7 +184,7 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
</div>
<!-- Content -->
<StatusContent :status="status" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
<StatusContent :status="status" :newer="newer" :context="context" mb2 :class="{ 'mt-2 mb1': isDM }" />
<StatusActions v-if="actions !== false" v-show="!userSettings.zenMode" :status="status" />
</div>
</div>

View File

@ -3,6 +3,7 @@ import type { mastodon } from 'masto'
const { status, context } = defineProps<{
status: mastodon.v1.Status
newer?: mastodon.v1.Status
context?: mastodon.v2.FilterContext | 'details'
}>()
@ -25,7 +26,7 @@ const isFiltered = $computed(() => filterPhrase && (context && context !== 'deta
'ms--3.5 mt--1 ms--1': isDM && context !== 'details',
}"
>
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusBody v-if="!isFiltered && status.sensitive && !status.spoilerText" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered">
<template v-if="filterPhrase" #spoiler>
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p>
@ -33,7 +34,7 @@ const isFiltered = $computed(() => filterPhrase && (context && context !== 'deta
<template v-else-if="status.spoilerText" #spoiler>
<p>{{ status.spoilerText }}</p>
</template>
<StatusBody v-if="!status.sensitive || status.spoilerText" :status="status" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusBody v-if="!status.sensitive || status.spoilerText" :status="status" :newer="newer" :with-action="!isDetails" :class="isDetails ? 'text-xl' : ''" />
<StatusTranslation :status="status" />
<StatusPoll v-if="status.poll" :status="status" />
<StatusMedia

View File

@ -3,6 +3,7 @@ import type { mastodon } from 'masto'
const props = withDefaults(defineProps<{
status: mastodon.v1.Status
newer?: mastodon.v1.Status
command?: boolean
actions?: boolean
}>(), {
@ -36,7 +37,7 @@ const isDM = $computed(() => status.visibility === 'direct')
<AccountInfo :account="status.account" />
</AccountHoverWrapper>
</NuxtLink>
<StatusContent :status="status" context="details" />
<StatusContent :status="status" :newer="newer" context="details" />
<div flex="~ gap-1" items-center text-secondary text-sm>
<div flex>
<div>{{ createdAt }}</div>

View File

@ -14,6 +14,8 @@ export interface ContentParseOptions {
astTransforms?: Transform[]
convertMentionLink?: boolean
collapseMentionLink?: boolean
status?: mastodon.v1.Status
inReplyToStatus?: mastodon.v1.Status
}
const sanitizerBasicClasses = filterClasses(/^(h-\S*|p-\S*|u-\S*|dt-\S*|e-\S*|mention|hashtag|ellipsis|invisible)$/u)
@ -80,6 +82,8 @@ export function parseMastodonHTML(
convertMentionLink = false,
collapseMentionLink = false,
mentions,
status,
inReplyToStatus,
} = options
if (markdown) {
@ -121,7 +125,7 @@ export function parseMastodonHTML(
transforms.push(transformParagraphs)
if (collapseMentionLink)
transforms.push(transformCollapseMentions())
transforms.push(transformCollapseMentions(status, inReplyToStatus))
return transformSync(parse(html), transforms)
}
@ -443,12 +447,22 @@ function transformParagraphs(node: Node): Node | Node[] {
return node
}
function transformCollapseMentions() {
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 {
return hrefToHandle(node.children?.[0].attributes.href) // node.children?.[0]?.children?.[0]?.attributes?.['data-id']
}
function transformCollapseMentions(status?: mastodon.v1.Status, inReplyToStatus?: mastodon.v1.Status): Transform {
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 || !node.children)
@ -456,12 +470,12 @@ function transformCollapseMentions() {
const mentions: (Node | undefined)[] = []
const children = node.children as Node[]
for (const child of children) {
// metion
// mention
if (isMention(child)) {
mentions.push(child)
}
// spaces in between
else if (child.type === TEXT_NODE && !child.value.trim()) {
else if (isSpacing(child)) {
mentions.push(child)
}
// other content, stop collapsing
@ -478,21 +492,55 @@ function transformCollapseMentions() {
if (mentions.length === 0)
return node
let removeNextSpacing = false
const contextualMentions = mentions.filter((mention) => {
if (!mention)
return false
if (removeNextSpacing && isSpacing(mention)) {
removeNextSpacing = false
return false
}
if (isMention(mention) && inReplyToStatus) {
const mentionHandle = getMentionHandle(mention)
if (inReplyToStatus.account.acct === mentionHandle || inReplyToStatus.mentions.some(m => m.acct === mentionHandle))
return false
}
return true
})
const mentionsCount = contextualMentions.filter(m => m && isMention(m)).length
// 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.
// This is needed because the status doesn't included the in Reply to handle, only the account id.
// But this covers the majority of cases.
const showMentions = !(mentionsCount === 0 || (mentionsCount === 1 && status?.inReplyToAccountId))
const contextualChildren = children.slice(mentions.length)
return {
...node,
children: [h('mention-group', null, ...mentions.filter(Boolean)), ...children.slice(mentions.length)],
children: showMentions ? [h('mention-group', null, ...contextualMentions), ...contextualChildren] : contextualChildren,
}
}
}
function hrefToHandle(href: string): string | undefined {
const matchUser = href.match(UserLinkRE)
if (matchUser) {
const [, server, username] = matchUser
return `${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}`
}
}
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) {
const matchUser = href.match(UserLinkRE)
if (matchUser) {
const [, server, username] = matchUser
const handle = `${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}`
const handle = hrefToHandle(href)
if (handle) {
// convert to Tiptap mention node
return h('span', { 'data-type': 'mention', 'data-id': handle }, handle)
}

View File

@ -68,18 +68,19 @@ onReactivated(() => {
<template>
<MainContent back>
<template v-if="!pending">
<template v-if="!pending && !pendingContext">
<div v-if="status" xl:mt-4 border="b base" mb="50vh">
<template v-for="comment of context?.ancestors" :key="comment.id">
<template v-for="comment, i of context?.ancestors" :key="comment.id">
<StatusCard
:status="comment" :actions="comment.visibility !== 'direct'" context="account"
:has-older="true" :has-newer="true"
:has-older="true" :newer="context?.ancestors[i - 1]"
/>
</template>
<StatusDetails
ref="main"
:status="status"
:newer="context?.ancestors.at(-1)"
command
style="scroll-margin-top: 60px"
/>
@ -105,7 +106,7 @@ onReactivated(() => {
:status="item"
context="account"
:older="context?.descendants[index + 1]"
:newer="context?.descendants[index - 1]"
:newer="index > 0 ? context?.descendants[index - 1] : status"
:has-newer="index === 0"
:main="status"
/>

View File

@ -92,6 +92,25 @@ exports[`html-parse > empty > html 1`] = `""`;
exports[`html-parse > empty > text 1`] = `""`;
exports[`html-parse > hide mentions in context > html 1`] = `
"<p>
<mention-group
><span class=\\"h-card\\"
><a
href=\\"/@haoqun@webtoo.ls\\"
class=\\"u-url mention\\"
rel=\\"nofollow noopener noreferrer\\"
target=\\"_blank\\"
>@<span>haoqun</span></a
></span
></mention-group
>Great to see this happening
</p>
"
`;
exports[`html-parse > hide mentions in context > text 1`] = `"@haoqunGreat to see this happening"`;
exports[`html-parse > html entities > html 1`] = `
"<p>Hello &lt;World /&gt;.</p>
"
@ -142,3 +161,50 @@ exports[`html-parse > link + mention > html 1`] = `
`;
exports[`html-parse > link + mention > text 1`] = `"Happy 🤗 were now using @vitest (migrated from chai+mocha) https://github.com/ayoayco/astro-reactive-library/pull/203"`;
exports[`html-parse > mentions without context > html 1`] = `
"<p>
<mention-group
><span class=\\"h-card\\"
><a
href=\\"/@haoqun@webtoo.ls\\"
class=\\"u-url mention\\"
rel=\\"nofollow noopener noreferrer\\"
target=\\"_blank\\"
>@<span>haoqun</span></a
></span
></mention-group
>Great to see this happening
</p>
"
`;
exports[`html-parse > mentions without context > text 1`] = `"@haoqunGreat to see this happening"`;
exports[`html-parse > show mentions in context > html 1`] = `
"<p>
<mention-group
><span class=\\"h-card\\"
><a
href=\\"/@haoqun@webtoo.ls\\"
class=\\"u-url mention\\"
rel=\\"nofollow noopener noreferrer\\"
target=\\"_blank\\"
>@<span>haoqun</span></a
></span
>
<span class=\\"h-card\\"
><a
href=\\"/@antfu@webtoo.ls\\"
class=\\"u-url mention\\"
rel=\\"nofollow noopener noreferrer\\"
target=\\"_blank\\"
>@<span>antfu</span></a
></span
></mention-group
>Great to see this happening
</p>
"
`;
exports[`html-parse > show mentions in context > text 1`] = `"@haoqun @antfuGreat to see this happening"`;

View File

@ -2,6 +2,7 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToString } from 'vue/server-renderer'
import { format } from 'prettier'
import type { mastodon } from 'masto'
import { contentToVNode } from '~/composables/content-render'
import type { ContentParseOptions } from '~~/composables/content-parse'
@ -139,6 +140,37 @@ describe('content-rich', () => {
`)
})
it('hides collapsed mentions', 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> content</p>', {
collapseMentionLink: true,
inReplyToStatus: { account: { acct: 'elk@webtoo.ls' }, mentions: [] as mastodon.v1.StatusMention[] } as mastodon.v1.Status,
})
expect(formatted).toMatchInlineSnapshot(`
"<p>content</p>
"
`)
})
it('shows some collapsed mentions', 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/@antfu" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@<span>antfu</span></a></span> content</p>', {
collapseMentionLink: true,
inReplyToStatus: { account: { acct: 'elk@webtoo.ls' }, mentions: [] as mastodon.v1.StatusMention[] } as mastodon.v1.Status,
})
expect(formatted).toMatchInlineSnapshot(`
"<p>
<mention-group>
<span class=\\"h-card\\"
><a
class=\\"u-url mention\\"
rel=\\"nofollow noopener noreferrer\\"
to=\\"/m.webtoo.ls/@antfu\\"
></a></span></mention-group
>content
</p>
"
`)
})
it ('block with injected html, without language', async () => {
const { formatted } = await render(`
<pre>

View File

@ -1,7 +1,7 @@
import type { mastodon } from 'masto'
import { describe, expect, it } from 'vitest'
import { format } from 'prettier'
import { render as renderTree } from 'ultrahtml'
import type { ContentParseOptions } from '~~/composables/content-parse'
describe('html-parse', () => {
it('empty', async () => {
@ -19,11 +19,13 @@ describe('html-parse', () => {
it('custom emoji', async () => {
const { formatted, serializedText } = await render('Daniel Roe :nuxt:', {
nuxt: {
shortcode: 'nuxt',
url: 'https://media.webtoo.ls/custom_emojis/images/000/000/366/original/73330dfc9dda4078.png',
staticUrl: 'https://media.webtoo.ls/custom_emojis/images/000/000/366/original/73330dfc9dda4078.png',
visibleInPicker: true,
emojis: {
nuxt: {
shortcode: 'nuxt',
url: 'https://media.webtoo.ls/custom_emojis/images/000/000/366/original/73330dfc9dda4078.png',
staticUrl: 'https://media.webtoo.ls/custom_emojis/images/000/000/366/original/73330dfc9dda4078.png',
visibleInPicker: true,
},
},
})
expect(formatted).toMatchSnapshot('html')
@ -62,8 +64,8 @@ describe('html-parse', () => {
})
})
async function render(input: string, emojis?: Record<string, mastodon.v1.CustomEmoji>) {
const tree = parseMastodonHTML(input, { emojis })
async function render(input: string, options?: ContentParseOptions) {
const tree = parseMastodonHTML(input, options)
const html = await renderTree(tree)
let formatted = ''
const serializedText = treeToText(tree).trim()