Skip to content

Commit

Permalink
feat: hide redudant mentions (#1293)
Browse files Browse the repository at this point in the history
  • Loading branch information
patak-dev committed Jan 18, 2023
1 parent da2f19f commit 3132f4f
Show file tree
Hide file tree
Showing 9 changed files with 186 additions and 30 deletions.
6 changes: 5 additions & 1 deletion components/status/StatusBody.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
}>()
Expand All @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion components/status/StatusCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const props = withDefaults(
}>(),
{ actions: true },
)
const userSettings = useUserSettings()
const status = $computed(() => {
Expand Down Expand Up @@ -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>
Expand Down
5 changes: 3 additions & 2 deletions components/status/StatusContent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}>()
Expand All @@ -25,15 +26,15 @@ 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>
</template>
<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
Expand Down
3 changes: 2 additions & 1 deletion components/status/StatusDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
}>(), {
Expand Down Expand Up @@ -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>
Expand Down
74 changes: 61 additions & 13 deletions composables/content-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -80,6 +82,8 @@ export function parseMastodonHTML(
convertMentionLink = false,
collapseMentionLink = false,
mentions,
status,
inReplyToStatus,
} = options

if (markdown) {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -443,25 +447,35 @@ 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)
return node
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
Expand All @@ -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)
}
Expand Down
9 changes: 5 additions & 4 deletions pages/[[server]]/@[account]/[status].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand All @@ -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"
/>
Expand Down
66 changes: 66 additions & 0 deletions tests/__snapshots__/html-parse.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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>
"
Expand Down Expand Up @@ -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"`;
32 changes: 32 additions & 0 deletions tests/content-rich.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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>
Expand Down
Loading

0 comments on commit 3132f4f

Please sign in to comment.