kopia lustrzana https://github.com/cloudflare/wildebeest
Merge pull request #305 from cloudflare/spoiler-contents
handle spoiler text in the statuses uipull/319/head
commit
08a5bc4fa4
|
@ -19,6 +19,7 @@ export interface Note extends objects.APObject {
|
|||
attachment: Array<objects.APObject>
|
||||
cc: Array<string>
|
||||
tag: Array<Link>
|
||||
spoiler_text?: string
|
||||
}
|
||||
|
||||
export async function createPublicNote(
|
||||
|
|
|
@ -78,10 +78,10 @@ export async function toMastodonStatusFromObject(
|
|||
emojis: [],
|
||||
tags: [],
|
||||
mentions: [],
|
||||
spoiler_text: obj.spoiler_text ?? '',
|
||||
|
||||
// TODO: stub values
|
||||
visibility: 'public',
|
||||
spoiler_text: '',
|
||||
|
||||
media_attachments: mediaAttachments,
|
||||
content: obj.content || '',
|
||||
|
@ -143,10 +143,10 @@ export async function toMastodonStatusFromRow(
|
|||
tags: [],
|
||||
mentions: [],
|
||||
account,
|
||||
spoiler_text: properties.spoiler_text ?? '',
|
||||
|
||||
// TODO: stub values
|
||||
visibility: 'public',
|
||||
spoiler_text: '',
|
||||
|
||||
content: properties.content,
|
||||
favourites_count: row.favourites_count,
|
||||
|
|
|
@ -19,7 +19,8 @@ export async function init(domain: string, db: D1Database) {
|
|||
db,
|
||||
actor,
|
||||
status.content,
|
||||
status.media_attachments as unknown as APObject[]
|
||||
status.media_attachments as unknown as APObject[],
|
||||
{ spoiler_text: status.spoiler_text }
|
||||
)
|
||||
loadedStatuses.push({ status, note })
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { component$, $ } from '@builder.io/qwik'
|
||||
import { component$, $, useSignal } from '@builder.io/qwik'
|
||||
import { Link, useNavigate } from '@builder.io/qwik-city'
|
||||
import { formatTimeAgo } from '~/utils/dateTime'
|
||||
import type { Account, MastodonStatus } from '~/types'
|
||||
|
@ -7,9 +7,13 @@ import { useAccountUrl } from '~/utils/useAccountUrl'
|
|||
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
|
||||
import { StatusAccountCard } from '../StatusAccountCard/StatusAccountCard'
|
||||
import { HtmlContent } from '../HtmlContent/HtmlContent'
|
||||
import { StatusInfoTray } from '../StatusInfoTray/StatusInfoTray'
|
||||
|
||||
type Props = {
|
||||
status: MastodonStatus
|
||||
accountSubText: 'username' | 'acct'
|
||||
showInfoTray: boolean
|
||||
contentClickable: boolean
|
||||
}
|
||||
|
||||
export default component$((props: Props) => {
|
||||
|
@ -21,13 +25,15 @@ export default component$((props: Props) => {
|
|||
const accountUrl = useAccountUrl(status.account)
|
||||
const statusUrl = `${accountUrl}/${status.id}`
|
||||
|
||||
const handleContentClick = $(() => nav(statusUrl))
|
||||
const showContent = useSignal(!status.spoiler_text)
|
||||
|
||||
const handleContentClick = $(() => props.contentClickable && nav(statusUrl))
|
||||
|
||||
return (
|
||||
<article class="p-4 border-t border-wildebeest-700 break-words">
|
||||
<RebloggerLink account={reblogger}></RebloggerLink>
|
||||
<div class="flex justify-between mb-3">
|
||||
<StatusAccountCard status={status} subText="username" secondaryAvatar={reblogger} />
|
||||
<StatusAccountCard status={status} subText={props.accountSubText} secondaryAvatar={reblogger} />
|
||||
<Link class="no-underline" href={statusUrl}>
|
||||
<div class="text-wildebeest-500 flex items-baseline">
|
||||
<i style={{ height: '0.75rem', width: '0.75rem' }} class="fa fa-xs fa-globe w-3 h-3" />
|
||||
|
@ -35,23 +41,44 @@ export default component$((props: Props) => {
|
|||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div onClick$={handleContentClick} class="cursor-pointer">
|
||||
<HtmlContent html={status.content} />
|
||||
</div>
|
||||
|
||||
<MediaGallery medias={status.media_attachments} />
|
||||
|
||||
{status.card && status.media_attachments.length === 0 && (
|
||||
<a class="no-underline" href={status.card.url}>
|
||||
<div class="rounded flex border border-wildebeest-600">
|
||||
<img class="w-16 h-16" src={status.card.image} />
|
||||
<div class="p-3 overflow-hidden">
|
||||
<div class="overflow-ellipsis text-sm text-bold text-wildebeest-400">{status.card.title}</div>
|
||||
<div class="overflow-ellipsis mt-2 text-sm text-wildebeest-500">{status.card.provider_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{status.spoiler_text && (
|
||||
<div class="my-4 flex items-center">
|
||||
<span class={props.contentClickable ? 'cursor-pointer' : ''} onClick$={handleContentClick}>
|
||||
{status.spoiler_text}
|
||||
</span>
|
||||
<button
|
||||
class="bg-wildebeest-500 text-wildebeest-900 uppercase font-semibold text-xs p-1 rounded opacity-50 ml-4"
|
||||
onClick$={() => (showContent.value = !showContent.value)}
|
||||
>
|
||||
show {showContent.value ? 'less' : 'more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showContent.value && (
|
||||
<>
|
||||
<div class={props.contentClickable ? 'cursor-pointer' : ''} onClick$={handleContentClick}>
|
||||
<HtmlContent html={status.content} />
|
||||
</div>
|
||||
|
||||
<MediaGallery medias={status.media_attachments} />
|
||||
|
||||
{status.card && status.media_attachments.length === 0 && (
|
||||
<a class="no-underline" href={status.card.url}>
|
||||
<div class="rounded flex border border-wildebeest-600">
|
||||
<img class="w-16 h-16" src={status.card.image} />
|
||||
<div class="p-3 overflow-hidden">
|
||||
<div class="overflow-ellipsis text-sm text-bold text-wildebeest-400">{status.card.title}</div>
|
||||
<div class="overflow-ellipsis mt-2 text-sm text-wildebeest-500">{status.card.provider_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.showInfoTray && <StatusInfoTray status={status} />}
|
||||
</article>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { component$, Slot } from '@builder.io/qwik'
|
||||
import type { MastodonStatus } from '~/types'
|
||||
import { formatDateTime } from '~/utils/dateTime'
|
||||
import { formatRoundedNumber } from '~/utils/numbers'
|
||||
|
||||
export const StatusInfoTray = component$<{ status: MastodonStatus }>(({ status }) => {
|
||||
return (
|
||||
<div class="text-wildebeest-500 mt-4 text-sm">
|
||||
<Info href={status.url}>
|
||||
<span>{formatDateTime(status.created_at)}</span>
|
||||
</Info>
|
||||
<span class="ml-3"> · </span>
|
||||
<span>
|
||||
<i class="fa fa-globe mx-3 w-4 inline-block" />
|
||||
<span>Web</span>
|
||||
</span>
|
||||
<span class="ml-3"> · </span>
|
||||
<Info href={status.url ? `${status.url}/reblogs` : null}>
|
||||
<i class="fa fa-retweet mx-3 w-4 inline-block" />
|
||||
<span>{formatRoundedNumber(status.reblogs_count)}</span>
|
||||
</Info>
|
||||
<span class="ml-3"> · </span>
|
||||
<Info href={status.url ? `${status.url}/favourites` : null}>
|
||||
<i class="fa fa-star mx-3 w-4 inline-block" />
|
||||
<span>{formatRoundedNumber(status.favourites_count)}</span>
|
||||
</Info>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Info = component$<{ href: string | null }>(({ href }) => {
|
||||
return (
|
||||
<>
|
||||
{!href ? (
|
||||
<span>
|
||||
<Slot />
|
||||
</span>
|
||||
) : (
|
||||
<a href={href} class="no-underline">
|
||||
<Slot />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -48,7 +48,7 @@ export const StatusesPanel = component$(({ initialStatuses, fetchMoreStatuses: f
|
|||
const divProps = isLastStatus ? { ref: lastStatusRef } : {}
|
||||
return (
|
||||
<div key={status.id} {...divProps}>
|
||||
<Status status={status} />
|
||||
<Status status={status} accountSubText="username" showInfoTray={false} contentClickable={true} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -1,20 +1,31 @@
|
|||
import { Account, MastodonStatus, MediaAttachment } from '~/types'
|
||||
import { george } from './accounts'
|
||||
import { getRandomDateInThePastYear } from './getRandomDateInThePastYear'
|
||||
|
||||
export function generateDummyStatus(
|
||||
content: string,
|
||||
account: Account,
|
||||
mediaAttachments: MediaAttachment[] = [],
|
||||
inReplyTo: string | null = null,
|
||||
reblog: MastodonStatus | null = null
|
||||
): MastodonStatus {
|
||||
type dummyStatusConfig = {
|
||||
content?: string
|
||||
account?: Account
|
||||
mediaAttachments?: MediaAttachment[]
|
||||
inReplyTo?: string | null
|
||||
reblog?: MastodonStatus | null
|
||||
spoiler_text?: string
|
||||
}
|
||||
|
||||
export function generateDummyStatus({
|
||||
content = '',
|
||||
account = george,
|
||||
mediaAttachments = [],
|
||||
inReplyTo = null,
|
||||
reblog = null,
|
||||
spoiler_text = '',
|
||||
}: dummyStatusConfig): MastodonStatus {
|
||||
return {
|
||||
id: `${Math.random() * 9999999}`.padStart(3, '7'),
|
||||
created_at: getRandomDateInThePastYear().toISOString(),
|
||||
in_reply_to_id: inReplyTo,
|
||||
in_reply_to_account_id: null,
|
||||
sensitive: false,
|
||||
spoiler_text: '',
|
||||
spoiler_text,
|
||||
visibility: 'public',
|
||||
language: 'en',
|
||||
uri: '',
|
||||
|
|
|
@ -4,8 +4,8 @@ import { ben, george, penny, rafael, zak } from './accounts'
|
|||
|
||||
// Raw statuses which follow the precise structure found mastodon does
|
||||
const mastodonRawStatuses: MastodonStatus[] = [
|
||||
generateDummyStatus(
|
||||
`
|
||||
generateDummyStatus({
|
||||
content: `
|
||||
<p>Fine. I'll use Wildebeest!</p>
|
||||
<p>It does look interesting:
|
||||
<a href="https://blog.cloudflare.com/welcome-to-wildebeest-the-fediverse-on-cloudflare/"
|
||||
|
@ -16,20 +16,27 @@ const mastodonRawStatuses: MastodonStatus[] = [
|
|||
<span class="invisible">-wildebeest-the-fediverse-on-cloudflare/</span>
|
||||
</a>
|
||||
</p>`,
|
||||
george
|
||||
),
|
||||
generateDummyStatus('We did it!', george, [
|
||||
generateDummyMediaImage(`https:/loremflickr.com/640/480/victory?lock=${Math.round(Math.random() * 999999)}`),
|
||||
]),
|
||||
generateDummyStatus('<span>A very simple update: all good!</span>', ben),
|
||||
generateDummyStatus('<p>Hi! My name is Rafael! 👋</p>', rafael),
|
||||
generateDummyStatus(
|
||||
"<div><p>I'm Rafael and I am a web designer!</p><p>💪💪</p></div>",
|
||||
rafael,
|
||||
new Array(4)
|
||||
account: george,
|
||||
}),
|
||||
generateDummyStatus({
|
||||
content: 'We did it!',
|
||||
account: george,
|
||||
mediaAttachments: [
|
||||
generateDummyMediaImage(`https:/loremflickr.com/640/480/victory?lock=${Math.round(Math.random() * 999999)}`),
|
||||
],
|
||||
}),
|
||||
generateDummyStatus({
|
||||
content: '<span>A very simple update: all good!</span>',
|
||||
account: ben,
|
||||
}),
|
||||
generateDummyStatus({ content: '<p>Hi! My name is Rafael! 👋</p>', account: rafael, spoiler_text: 'who am I?' }),
|
||||
generateDummyStatus({
|
||||
content: "<div><p>I'm Rafael and I am a web designer!</p><p>💪💪</p></div>",
|
||||
account: rafael,
|
||||
mediaAttachments: new Array(4)
|
||||
.fill(null)
|
||||
.map((_, idx) => generateDummyMediaImage(`https:/loremflickr.com/640/480/abstract?lock=${100 + idx}`))
|
||||
),
|
||||
.map((_, idx) => generateDummyMediaImage(`https:/loremflickr.com/640/480/abstract?lock=${100 + idx}`)),
|
||||
}),
|
||||
]
|
||||
|
||||
export const statuses: MastodonStatus[] = mastodonRawStatuses.map((rawStatus) => ({
|
||||
|
@ -41,11 +48,11 @@ export const statuses: MastodonStatus[] = mastodonRawStatuses.map((rawStatus) =>
|
|||
}))
|
||||
|
||||
export const replies: MastodonStatus[] = [
|
||||
generateDummyStatus('<p>Yes we did! 🎉</p>', zak, [], statuses[1].id),
|
||||
generateDummyStatus('<p> Yes you guys did it! </p>', penny, [], statuses[1].id),
|
||||
generateDummyStatus({ content: '<p>Yes we did! 🎉</p>', account: zak, inReplyTo: statuses[1].id }),
|
||||
generateDummyStatus({ content: '<p> Yes you guys did it! </p>', account: penny, inReplyTo: statuses[1].id }),
|
||||
]
|
||||
|
||||
export const reblogs: MastodonStatus[] = [generateDummyStatus('', george, [], null, statuses[2])]
|
||||
export const reblogs: MastodonStatus[] = [generateDummyStatus({ account: george, reblog: statuses[2] })]
|
||||
|
||||
function getStandardMediaType(mediaAttachmentMastodonType: string): string {
|
||||
switch (mediaAttachmentMastodonType) {
|
||||
|
|
|
@ -1,18 +1,13 @@
|
|||
import { component$, Slot } from '@builder.io/qwik'
|
||||
import { component$ } from '@builder.io/qwik'
|
||||
import { MastodonStatus, StatusContext } from '~/types'
|
||||
import Status from '~/components/Status'
|
||||
import { formatDateTime } from '~/utils/dateTime'
|
||||
import { formatRoundedNumber } from '~/utils/numbers'
|
||||
import * as statusAPI from 'wildebeest/functions/api/v1/statuses/[id]'
|
||||
import * as contextAPI from 'wildebeest/functions/api/v1/statuses/[id]/context'
|
||||
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
|
||||
import { MediaGallery } from '~/components/MediaGallery.tsx'
|
||||
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
|
||||
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
|
||||
import { getTextContent } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { getDocumentHead } from '~/utils/getDocumentHead'
|
||||
import { StatusAccountCard } from '~/components/StatusAccountCard/StatusAccountCard'
|
||||
import { HtmlContent } from '~/components/HtmlContent/HtmlContent'
|
||||
|
||||
export const statusLoader = loader$<
|
||||
Promise<{ status: MastodonStatus; statusTextContent: string; context: StatusContext }>,
|
||||
|
@ -50,67 +45,16 @@ export default component$(() => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div class="p-4">
|
||||
<div class="mb-3">
|
||||
<StatusAccountCard subText="acct" status={loaderData.status} />
|
||||
</div>
|
||||
|
||||
<HtmlContent html={loaderData.status.content} />
|
||||
|
||||
<MediaGallery medias={loaderData.status.media_attachments} />
|
||||
|
||||
<InfoTray status={loaderData.status} />
|
||||
</div>
|
||||
<Status status={loaderData.status} accountSubText="acct" showInfoTray={true} contentClickable={false} />
|
||||
<div>
|
||||
{loaderData.context.descendants.map((status) => {
|
||||
return <Status status={status} />
|
||||
return <Status status={status} accountSubText="username" showInfoTray={false} contentClickable={true} />
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export const InfoTray = component$<{ status: MastodonStatus }>(({ status }) => {
|
||||
return (
|
||||
<div class="text-wildebeest-500 mt-4 text-sm">
|
||||
<Info href={status.url}>
|
||||
<span>{formatDateTime(status.created_at)}</span>
|
||||
</Info>
|
||||
<span class="ml-3"> · </span>
|
||||
<span>
|
||||
<i class="fa fa-globe mx-3 w-4 inline-block" />
|
||||
<span>Web</span>
|
||||
</span>
|
||||
<span class="ml-3"> · </span>
|
||||
<Info href={status.url ? `${status.url}/reblogs` : null}>
|
||||
<i class="fa fa-retweet mx-3 w-4 inline-block" />
|
||||
<span>{formatRoundedNumber(status.reblogs_count)}</span>
|
||||
</Info>
|
||||
<span class="ml-3"> · </span>
|
||||
<Info href={status.url ? `${status.url}/favourites` : null}>
|
||||
<i class="fa fa-star mx-3 w-4 inline-block" />
|
||||
<span>{formatRoundedNumber(status.favourites_count)}</span>
|
||||
</Info>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const Info = component$<{ href: string | null }>(({ href }) => {
|
||||
return (
|
||||
<>
|
||||
{!href ? (
|
||||
<span>
|
||||
<Slot />
|
||||
</span>
|
||||
) : (
|
||||
<a href={href} class="no-underline">
|
||||
<Slot />
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export const head: DocumentHead = ({ resolveValue }) => {
|
||||
const { status, statusTextContent } = resolveValue(statusLoader)
|
||||
|
||||
|
|
|
@ -68,6 +68,7 @@ export async function handleRequest(
|
|||
media_attachments: [],
|
||||
tags: [],
|
||||
mentions: [],
|
||||
spoiler_text: properties.spoiler_text ?? '',
|
||||
|
||||
// TODO: a shortcut has been taked. We assume that the actor
|
||||
// generating the notification also created the object. In practice
|
||||
|
@ -76,7 +77,6 @@ export async function handleRequest(
|
|||
|
||||
// TODO: stub values
|
||||
visibility: 'public',
|
||||
spoiler_text: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,6 +31,11 @@ test('Navigation to and view of an account (with 2 posts)', async ({ page }) =>
|
|||
await expect(post1Locator.getByRole('img', { name: 'Avatar of Raffa123$' })).toBeVisible()
|
||||
await expect(post1Locator).toContainText("I'm Rafael and I am a web designer")
|
||||
await expect(post2Locator.getByRole('img', { name: 'Avatar of Raffa123$' })).toBeVisible()
|
||||
await expect(post2Locator.getByText('who am I?')).toBeVisible()
|
||||
await expect(post2Locator.getByRole('paragraph').getByText('Hi! My name is Rafael! 👋')).not.toBeVisible()
|
||||
await post2Locator.getByRole('button', { name: 'show more' }).click()
|
||||
await expect(post2Locator.getByRole('paragraph').getByText('Hi! My name is Rafael! 👋')).toBeVisible()
|
||||
|
||||
await expect(post2Locator).toContainText('Hi! My name is Rafael!')
|
||||
})
|
||||
|
||||
|
|
|
@ -4,8 +4,8 @@ test('Display the list of toots in the explore page', async ({ page }) => {
|
|||
await page.goto('http://127.0.0.1:8788/explore')
|
||||
|
||||
const tootsTextsToCheck = [
|
||||
'Hi! My name is Rafael!',
|
||||
'We did it!',
|
||||
"I'm Rafael and I am a web designer!",
|
||||
"Fine. I'll use Wildebeest",
|
||||
'A very simple update: all good!',
|
||||
]
|
||||
|
@ -21,3 +21,14 @@ test('Correctly displays toots with truncated urls', async ({ page }) => {
|
|||
const articleLocator = page.locator('article').filter({ hasText: "Fine. I'll use Wildebeest" })
|
||||
await expect(articleLocator.getByRole('link', { name: 'blog.cloudflare.com/welcome-to…' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('Correctly displays toots with spoiler text', async ({ page }) => {
|
||||
await page.goto('http://127.0.0.1:8788/explore')
|
||||
|
||||
const articleLocator = page.getByRole('article').filter({ hasText: 'who am I?' })
|
||||
await expect(articleLocator.getByRole('paragraph').getByText('Hi! My name is Rafael! 👋')).not.toBeVisible()
|
||||
await articleLocator.getByRole('button', { name: 'show more' }).click()
|
||||
await expect(articleLocator.getByRole('paragraph').getByText('Hi! My name is Rafael! 👋')).toBeVisible()
|
||||
await articleLocator.getByRole('button', { name: 'show less' }).click()
|
||||
await expect(articleLocator.getByRole('paragraph').getByText('Hi! My name is Rafael! 👋')).not.toBeVisible()
|
||||
})
|
||||
|
|
|
@ -83,3 +83,18 @@ test("Navigation to and view of a toot's replies", async ({ page }) => {
|
|||
await expect(page.getByTestId('account-display-name').filter({ hasText: 'Penny' })).toBeVisible()
|
||||
await expect(page.getByText('Yes you guys did it!')).toBeVisible()
|
||||
})
|
||||
|
||||
test("Correctly hides and displays the toot's content based on the spoiler text", async ({ page }) => {
|
||||
await page.goto('http://127.0.0.1:8788/explore')
|
||||
|
||||
await page.getByText('who am I?').click()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible()
|
||||
|
||||
const articleLocator = page.getByRole('article').filter({ hasText: 'who am I?' })
|
||||
await expect(articleLocator.getByRole('paragraph').getByText('Hi! My name is Rafael! 👋')).not.toBeVisible()
|
||||
await articleLocator.getByRole('button', { name: 'show more' }).click()
|
||||
await expect(articleLocator.getByRole('paragraph').getByText('Hi! My name is Rafael! 👋')).toBeVisible()
|
||||
await articleLocator.getByRole('button', { name: 'show less' }).click()
|
||||
await expect(articleLocator.getByRole('paragraph').getByText('Hi! My name is Rafael! 👋')).not.toBeVisible()
|
||||
})
|
||||
|
|
Ładowanie…
Reference in New Issue