feat: hover card for content @

zio/stable
Anthony Fu 2022-11-30 15:08:10 +08:00
parent 66393cd838
commit 2becb254b4
8 changed files with 61 additions and 27 deletions

View File

@ -1,17 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Account } from 'masto' import type { Account } from 'masto'
defineProps<{ const props = defineProps<{
account: Account account?: Account
handle?: string
disabled?: boolean disabled?: boolean
}>() }>()
const account = props.account || (props.handle ? useAccountByHandle(props.handle!) : undefined)
</script> </script>
<template> <template>
<VMenu v-if="!disabled" placement="bottom-start" :delay="{ show: 500, hide: 100 }"> <VMenu v-if="!disabled && account" placement="bottom-start" :delay="{ show: 500, hide: 100 }">
<slot /> <slot />
<template #popper> <template #popper>
<AccountHoverCard :account="account" /> <AccountHoverCard v-if="account" :account="account" />
</template> </template>
</VMenu> </VMenu>
<slot v-else /> <slot v-else />

View File

@ -5,7 +5,7 @@ const { status } = defineProps<{
status: Status status: Status
}>() }>()
const account = asyncComputed(() => fetchAccount(status.inReplyToAccountId!)) const account = useAccountById(status.inReplyToAccountId!)
</script> </script>
<template> <template>

View File

@ -28,7 +28,7 @@ export function fetchStatus(id: string): Promise<Status> {
return promise return promise
} }
export function fetchAccount(id: string): Promise<Account> { export function fetchAccountById(id: string): Promise<Account> {
const key = `account:${id}` const key = `account:${id}`
const cached = cache.get(key) const cached = cache.get(key)
if (cached) if (cached)
@ -42,7 +42,7 @@ export function fetchAccount(id: string): Promise<Account> {
return promise return promise
} }
export async function fetchAccountByName(acct: string): Promise<Account> { export async function fetchAccountByHandle(acct: string): Promise<Account> {
const key = `account:${acct}` const key = `account:${acct}`
const cached = cache.get(key) const cached = cache.get(key)
if (cached) if (cached)
@ -56,6 +56,14 @@ export async function fetchAccountByName(acct: string): Promise<Account> {
return account return account
} }
export function useAccountByHandle(acct: string) {
return useAsyncState(() => fetchAccountByHandle(acct), null).state
}
export function useAccountById(id: string) {
return useAsyncState(() => fetchAccountById(id), null).state
}
export function cacheStatus(status: Status, override?: boolean) { export function cacheStatus(status: Status, override?: boolean) {
setCached(`status:${status.id}`, status, override) setCached(`status:${status.id}`, status, override)
} }

View File

@ -5,6 +5,7 @@ import type { VNode } from 'vue'
import { Fragment, h, isVNode } from 'vue' import { Fragment, h, isVNode } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import ContentCode from '~/components/content/ContentCode.vue' import ContentCode from '~/components/content/ContentCode.vue'
import AccountHoverWrapper from '~/components/account/AccountHoverWrapper.vue'
type Node = DefaultTreeAdapterMap['childNode'] type Node = DefaultTreeAdapterMap['childNode']
type Element = DefaultTreeAdapterMap['element'] type Element = DefaultTreeAdapterMap['element']
@ -18,7 +19,9 @@ function handleMention(el: Element) {
if (matchUser) { if (matchUser) {
const [, server, username] = matchUser const [, server, username] = matchUser
// Handles need to ignore server subdomains // Handles need to ignore server subdomains
href.value = `/@${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}` const handle = `@${username}@${server.replace(/(.+\.)(.+\..+)/, '$2')}`
href.value = `/${handle}`
return h(AccountHoverWrapper, { handle, class: 'inline-block' }, () => nodeToVNode(el))
} }
const matchTag = href.value.match(TagLinkRE) const matchTag = href.value.match(TagLinkRE)
if (matchTag) { if (matchTag) {
@ -108,22 +111,13 @@ export function contentToVNode(
return h(Fragment, tree.childNodes.map(n => treeToVNode(n))) return h(Fragment, tree.childNodes.map(n => treeToVNode(n)))
} }
function treeToVNode( function nodeToVNode(node: Node): VNode | string | null {
input: Node, if (node.nodeName === '#text') {
): VNode | string | null {
if (input.nodeName === '#text') {
// @ts-expect-error casing // @ts-expect-error casing
const text = input.value as string return input.value as string
return text
} }
if ('childNodes' in input) { if ('childNodes' in node) {
const node = handleNode(input)
if (node == null)
return null
if (isVNode(node))
return node
const attrs = Object.fromEntries(node.attrs.map(i => [i.name, i.value])) const attrs = Object.fromEntries(node.attrs.map(i => [i.name, i.value]))
if (node.nodeName === 'a' && (attrs.href?.startsWith('/') || attrs.href?.startsWith('.'))) { if (node.nodeName === 'a' && (attrs.href?.startsWith('/') || attrs.href?.startsWith('.'))) {
attrs.to = attrs.href attrs.to = attrs.href
@ -144,6 +138,25 @@ function treeToVNode(
return null return null
} }
function treeToVNode(
input: Node,
): VNode | string | null {
if (input.nodeName === '#text') {
// @ts-expect-error casing
return input.value as string
}
if ('childNodes' in input) {
const node = handleNode(input)
if (node == null)
return null
if (isVNode(node))
return node
return nodeToVNode(node)
}
return null
}
export function htmlToText(html: string) { export function htmlToText(html: string) {
const tree = parseFragment(html) const tree = parseFragment(html)
return tree.childNodes.map(n => treeToText(n)).join('').trim() return tree.childNodes.map(n => treeToText(n)).join('').trim()

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
const params = useRoute().params const params = useRoute().params
const accountName = $(computedEager(() => params.account as string)) const handle = $(computedEager(() => params.account as string))
const account = await fetchAccountByName(accountName) const account = await fetchAccountByHandle(handle)
const paginator = account ? useMasto().accounts.getFollowersIterable(account.id, {}) : null const paginator = account ? useMasto().accounts.getFollowersIterable(account.id, {}) : null
</script> </script>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
const params = useRoute().params const params = useRoute().params
const accountName = $(computedEager(() => params.account as string)) const handle = $(computedEager(() => params.account as string))
const account = await fetchAccountByName(accountName) const account = await fetchAccountByHandle(handle)
const paginator = account ? useMasto().accounts.getFollowingIterable(account.id, {}) : null const paginator = account ? useMasto().accounts.getFollowingIterable(account.id, {}) : null
</script> </script>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
const params = useRoute().params const params = useRoute().params
const accountName = $(computedEager(() => params.account as string)) const handle = $(computedEager(() => params.account as string))
const account = await fetchAccountByName(accountName) const account = await fetchAccountByHandle(handle)
const { t } = useI18n() const { t } = useI18n()
const paginatorPosts = useMasto().accounts.getStatusesIterable(account.id, { excludeReplies: true }) const paginatorPosts = useMasto().accounts.getStatusesIterable(account.id, { excludeReplies: true })

View File

@ -91,3 +91,13 @@ vi.mock('../components/content/ContentCode.vue', () => {
}), }),
} }
}) })
vi.mock('../components/account/AccountHoverWrapper.vue', () => {
return {
default: defineComponent({
setup(_, { slots }) {
return () => slots?.default?.()
},
}),
}
})