feat: hide redudant mentions (#1293)
parent
da2f19fb23
commit
3132f4fdea
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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 <World />.</p>
|
||||
"
|
||||
|
@ -142,3 +161,50 @@ exports[`html-parse > link + mention > html 1`] = `
|
|||
`;
|
||||
|
||||
exports[`html-parse > link + mention > text 1`] = `"Happy 🤗 we’re 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"`;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue