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 { const {
status, status,
newer,
withAction = true, withAction = true,
} = defineProps<{ } = defineProps<{
status: mastodon.v1.Status | mastodon.v1.StatusEdit status: mastodon.v1.Status | mastodon.v1.StatusEdit
newer?: mastodon.v1.Status
withAction?: boolean withAction?: boolean
}>() }>()
@ -20,13 +22,15 @@ const vnode = $computed(() => {
mentions: 'mentions' in status ? status.mentions : undefined, mentions: 'mentions' in status ? status.mentions : undefined,
markdown: true, markdown: true,
collapseMentionLink: !!('inReplyToId' in status && status.inReplyToId), collapseMentionLink: !!('inReplyToId' in status && status.inReplyToId),
status: 'id' in status ? status : undefined,
inReplyToStatus: newer,
}) })
return vnode return vnode
}) })
</script> </script>
<template> <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 <span
v-if="status.content" v-if="status.content"
class="content-rich line-compact" dir="auto" class="content-rich line-compact" dir="auto"

View file

@ -22,6 +22,7 @@ const props = withDefaults(
}>(), }>(),
{ actions: true }, { actions: true },
) )
const userSettings = useUserSettings() const userSettings = useUserSettings()
const status = $computed(() => { const status = $computed(() => {
@ -183,7 +184,7 @@ const showReplyTo = $computed(() => !replyToMain && !directReply)
</div> </div>
<!-- Content --> <!-- 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" /> <StatusActions v-if="actions !== false" v-show="!userSettings.zenMode" :status="status" />
</div> </div>
</div> </div>

View file

@ -3,6 +3,7 @@ import type { mastodon } from 'masto'
const { status, context } = defineProps<{ const { status, context } = defineProps<{
status: mastodon.v1.Status status: mastodon.v1.Status
newer?: mastodon.v1.Status
context?: mastodon.v2.FilterContext | 'details' 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', '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"> <StatusSpoiler :enabled="status.sensitive || isFiltered" :filter="isFiltered">
<template v-if="filterPhrase" #spoiler> <template v-if="filterPhrase" #spoiler>
<p>{{ `${$t('status.filter_hidden_phrase')}: ${filterPhrase}` }}</p> <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> <template v-else-if="status.spoilerText" #spoiler>
<p>{{ status.spoilerText }}</p> <p>{{ status.spoilerText }}</p>
</template> </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" /> <StatusTranslation :status="status" />
<StatusPoll v-if="status.poll" :status="status" /> <StatusPoll v-if="status.poll" :status="status" />
<StatusMedia <StatusMedia

View file

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

View file

@ -14,6 +14,8 @@ export interface ContentParseOptions {
astTransforms?: Transform[] astTransforms?: Transform[]
convertMentionLink?: boolean convertMentionLink?: boolean
collapseMentionLink?: 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) 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, convertMentionLink = false,
collapseMentionLink = false, collapseMentionLink = false,
mentions, mentions,
status,
inReplyToStatus,
} = options } = options
if (markdown) { if (markdown) {
@ -121,7 +125,7 @@ export function parseMastodonHTML(
transforms.push(transformParagraphs) transforms.push(transformParagraphs)
if (collapseMentionLink) if (collapseMentionLink)
transforms.push(transformCollapseMentions()) transforms.push(transformCollapseMentions(status, inReplyToStatus))
return transformSync(parse(html), transforms) return transformSync(parse(html), transforms)
} }
@ -443,25 +447,35 @@ function transformParagraphs(node: Node): Node | Node[] {
return node return node
} }
function transformCollapseMentions() {
let processed = false
function isMention(node: Node) { function isMention(node: Node) {
const child = node.children?.length === 1 ? node.children[0] : null const child = node.children?.length === 1 ? node.children[0] : null
return Boolean(child?.name === 'a' && child.attributes.class?.includes('mention')) 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
return (node: Node, root: Node): Node | Node[] => { return (node: Node, root: Node): Node | Node[] => {
if (processed || node.parent !== root || !node.children) if (processed || node.parent !== root || !node.children)
return node return node
const mentions: (Node | undefined)[] = [] const mentions: (Node | undefined)[] = []
const children = node.children as Node[] const children = node.children as Node[]
for (const child of children) { for (const child of children) {
// metion // mention
if (isMention(child)) { if (isMention(child)) {
mentions.push(child) mentions.push(child)
} }
// spaces in between // spaces in between
else if (child.type === TEXT_NODE && !child.value.trim()) { else if (isSpacing(child)) {
mentions.push(child) mentions.push(child)
} }
// other content, stop collapsing // other content, stop collapsing
@ -478,21 +492,55 @@ function transformCollapseMentions() {
if (mentions.length === 0) if (mentions.length === 0)
return node 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 { return {
...node, ...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 { 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
if (href) { if (href) {
const matchUser = href.match(UserLinkRE) const handle = hrefToHandle(href)
if (matchUser) { if (handle) {
const [, server, username] = matchUser
const handle = `${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}`
// convert to Tiptap mention node // convert to Tiptap mention node
return h('span', { 'data-type': 'mention', 'data-id': handle }, handle) return h('span', { 'data-type': 'mention', 'data-id': handle }, handle)
} }

View file

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

View file

@ -92,6 +92,25 @@ exports[`html-parse > empty > html 1`] = `""`;
exports[`html-parse > empty > text 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`] = ` exports[`html-parse > html entities > html 1`] = `
"<p>Hello &lt;World /&gt;.</p> "<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 > 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 { 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 type { mastodon } from 'masto'
import { contentToVNode } from '~/composables/content-render' import { contentToVNode } from '~/composables/content-render'
import type { ContentParseOptions } from '~~/composables/content-parse' 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 () => { it ('block with injected html, without language', async () => {
const { formatted } = await render(` const { formatted } = await render(`
<pre> <pre>

View file

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