handle spoiler text in the statuses ui

as part of this:
 - wire up spoilter_text in the backend so that frontend can use it
 - add spoiler_text to dummy data (+ related refactoring)
 - show spoilter text in the ui (+ related refactoring)
 - add/update e2e tests to check spoiler text
pull/305/head
Dario Piotrowicz 2023-02-16 16:58:03 +00:00
rodzic 9cc290ecc7
commit 97530bbc7a
13 zmienionych plików z 176 dodań i 109 usunięć

Wyświetl plik

@ -19,6 +19,7 @@ export interface Note extends objects.APObject {
attachment: Array<objects.APObject> attachment: Array<objects.APObject>
cc: Array<string> cc: Array<string>
tag: Array<Link> tag: Array<Link>
spoiler_text?: string
} }
export async function createPublicNote( export async function createPublicNote(

Wyświetl plik

@ -78,10 +78,10 @@ export async function toMastodonStatusFromObject(
emojis: [], emojis: [],
tags: [], tags: [],
mentions: [], mentions: [],
spoiler_text: obj.spoiler_text ?? '',
// TODO: stub values // TODO: stub values
visibility: 'public', visibility: 'public',
spoiler_text: '',
media_attachments: mediaAttachments, media_attachments: mediaAttachments,
content: obj.content || '', content: obj.content || '',
@ -143,10 +143,10 @@ export async function toMastodonStatusFromRow(
tags: [], tags: [],
mentions: [], mentions: [],
account, account,
spoiler_text: properties.spoiler_text ?? '',
// TODO: stub values // TODO: stub values
visibility: 'public', visibility: 'public',
spoiler_text: '',
content: properties.content, content: properties.content,
favourites_count: row.favourites_count, favourites_count: row.favourites_count,

Wyświetl plik

@ -19,7 +19,8 @@ export async function init(domain: string, db: D1Database) {
db, db,
actor, actor,
status.content, 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 }) loadedStatuses.push({ status, note })
} }

Wyświetl plik

@ -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 { Link, useNavigate } from '@builder.io/qwik-city'
import { formatTimeAgo } from '~/utils/dateTime' import { formatTimeAgo } from '~/utils/dateTime'
import type { Account, MastodonStatus } from '~/types' import type { Account, MastodonStatus } from '~/types'
@ -7,9 +7,13 @@ import { useAccountUrl } from '~/utils/useAccountUrl'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement' import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
import { StatusAccountCard } from '../StatusAccountCard/StatusAccountCard' import { StatusAccountCard } from '../StatusAccountCard/StatusAccountCard'
import { HtmlContent } from '../HtmlContent/HtmlContent' import { HtmlContent } from '../HtmlContent/HtmlContent'
import { StatusInfoTray } from '../StatusInfoTray/StatusInfoTray'
type Props = { type Props = {
status: MastodonStatus status: MastodonStatus
accountSubText: 'username' | 'acct'
showInfoTray: boolean
contentClickable: boolean
} }
export default component$((props: Props) => { export default component$((props: Props) => {
@ -21,13 +25,15 @@ export default component$((props: Props) => {
const accountUrl = useAccountUrl(status.account) const accountUrl = useAccountUrl(status.account)
const statusUrl = `${accountUrl}/${status.id}` const statusUrl = `${accountUrl}/${status.id}`
const handleContentClick = $(() => nav(statusUrl)) const showContent = useSignal(!status.spoiler_text)
const handleContentClick = $(() => props.contentClickable && nav(statusUrl))
return ( return (
<article class="p-4 border-t border-wildebeest-700 break-words sm:break-normal"> <article class="p-4 border-t border-wildebeest-700 break-words sm:break-normal">
<RebloggerLink account={reblogger}></RebloggerLink> <RebloggerLink account={reblogger}></RebloggerLink>
<div class="flex justify-between mb-3"> <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}> <Link class="no-underline" href={statusUrl}>
<div class="text-wildebeest-500 flex items-baseline"> <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" /> <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> </div>
</Link> </Link>
</div> </div>
<div onClick$={handleContentClick} class="cursor-pointer">
<HtmlContent html={status.content} />
</div>
<MediaGallery medias={status.media_attachments} /> {status.spoiler_text && (
<div class="my-4 flex items-center">
{status.card && status.media_attachments.length === 0 && ( <span class={props.contentClickable ? 'cursor-pointer' : ''} onClick$={handleContentClick}>
<a class="no-underline" href={status.card.url}> {status.spoiler_text}
<div class="rounded flex border border-wildebeest-600"> </span>
<img class="w-16 h-16" src={status.card.image} /> <button
<div class="p-3 overflow-hidden"> class="bg-wildebeest-500 text-wildebeest-900 uppercase font-semibold text-xs p-1 rounded opacity-50 ml-4"
<div class="overflow-ellipsis text-sm text-bold text-wildebeest-400">{status.card.title}</div> onClick$={() => (showContent.value = !showContent.value)}
<div class="overflow-ellipsis mt-2 text-sm text-wildebeest-500">{status.card.provider_name}</div> >
</div> show {showContent.value ? 'less' : 'more'}
</div> </button>
</a> </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> </article>
) )
}) })

Wyświetl plik

@ -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>
)}
</>
)
})

Wyświetl plik

@ -48,7 +48,7 @@ export const StatusesPanel = component$(({ initialStatuses, fetchMoreStatuses: f
const divProps = isLastStatus ? { ref: lastStatusRef } : {} const divProps = isLastStatus ? { ref: lastStatusRef } : {}
return ( return (
<div key={status.id} {...divProps}> <div key={status.id} {...divProps}>
<Status status={status} /> <Status status={status} accountSubText="username" showInfoTray={false} contentClickable={true} />
</div> </div>
) )
}) })

Wyświetl plik

@ -1,20 +1,31 @@
import { Account, MastodonStatus, MediaAttachment } from '~/types' import { Account, MastodonStatus, MediaAttachment } from '~/types'
import { george } from './accounts'
import { getRandomDateInThePastYear } from './getRandomDateInThePastYear' import { getRandomDateInThePastYear } from './getRandomDateInThePastYear'
export function generateDummyStatus( type dummyStatusConfig = {
content: string, content?: string
account: Account, account?: Account
mediaAttachments: MediaAttachment[] = [], mediaAttachments?: MediaAttachment[]
inReplyTo: string | null = null, inReplyTo?: string | null
reblog: MastodonStatus | null = null reblog?: MastodonStatus | null
): MastodonStatus { spoiler_text?: string
}
export function generateDummyStatus({
content = '',
account = george,
mediaAttachments = [],
inReplyTo = null,
reblog = null,
spoiler_text = '',
}: dummyStatusConfig): MastodonStatus {
return { return {
id: `${Math.random() * 9999999}`.padStart(3, '7'), id: `${Math.random() * 9999999}`.padStart(3, '7'),
created_at: getRandomDateInThePastYear().toISOString(), created_at: getRandomDateInThePastYear().toISOString(),
in_reply_to_id: inReplyTo, in_reply_to_id: inReplyTo,
in_reply_to_account_id: null, in_reply_to_account_id: null,
sensitive: false, sensitive: false,
spoiler_text: '', spoiler_text,
visibility: 'public', visibility: 'public',
language: 'en', language: 'en',
uri: '', uri: '',

Wyświetl plik

@ -4,8 +4,8 @@ import { ben, george, penny, rafael, zak } from './accounts'
// Raw statuses which follow the precise structure found mastodon does // Raw statuses which follow the precise structure found mastodon does
const mastodonRawStatuses: MastodonStatus[] = [ const mastodonRawStatuses: MastodonStatus[] = [
generateDummyStatus( generateDummyStatus({
` content: `
<p>Fine. I'll use Wildebeest!</p> <p>Fine. I'll use Wildebeest!</p>
<p>It does look interesting: <p>It does look interesting:
<a href="https://blog.cloudflare.com/welcome-to-wildebeest-the-fediverse-on-cloudflare/" <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> <span class="invisible">-wildebeest-the-fediverse-on-cloudflare/</span>
</a> </a>
</p>`, </p>`,
george account: george,
), }),
generateDummyStatus('We did it!', george, [ generateDummyStatus({
generateDummyMediaImage(`https:/loremflickr.com/640/480/victory?lock=${Math.round(Math.random() * 999999)}`), content: 'We did it!',
]), account: george,
generateDummyStatus('<span>A very simple update: all good!</span>', ben), mediaAttachments: [
generateDummyStatus('<p>Hi! My name is Rafael! 👋</p>', rafael), generateDummyMediaImage(`https:/loremflickr.com/640/480/victory?lock=${Math.round(Math.random() * 999999)}`),
generateDummyStatus( ],
"<div><p>I'm Rafael and I am a web designer!</p><p>💪💪</p></div>", }),
rafael, generateDummyStatus({
new Array(4) 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) .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) => ({ export const statuses: MastodonStatus[] = mastodonRawStatuses.map((rawStatus) => ({
@ -41,11 +48,11 @@ export const statuses: MastodonStatus[] = mastodonRawStatuses.map((rawStatus) =>
})) }))
export const replies: MastodonStatus[] = [ export const replies: MastodonStatus[] = [
generateDummyStatus('<p>Yes we did! 🎉</p>', zak, [], statuses[1].id), generateDummyStatus({ content: '<p>Yes we did! 🎉</p>', account: zak, inReplyTo: statuses[1].id }),
generateDummyStatus('<p> Yes you guys did it! </p>', penny, [], 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 { function getStandardMediaType(mediaAttachmentMastodonType: string): string {
switch (mediaAttachmentMastodonType) { switch (mediaAttachmentMastodonType) {

Wyświetl plik

@ -1,18 +1,13 @@
import { component$, Slot } from '@builder.io/qwik' import { component$ } from '@builder.io/qwik'
import { MastodonStatus, StatusContext } from '~/types' import { MastodonStatus, StatusContext } from '~/types'
import Status from '~/components/Status' 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 statusAPI from 'wildebeest/functions/api/v1/statuses/[id]'
import * as contextAPI from 'wildebeest/functions/api/v1/statuses/[id]/context' import * as contextAPI from 'wildebeest/functions/api/v1/statuses/[id]/context'
import { DocumentHead, loader$ } from '@builder.io/qwik-city' import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import { MediaGallery } from '~/components/MediaGallery.tsx'
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml' import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml' import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
import { getTextContent } from 'wildebeest/backend/src/activitypub/objects' import { getTextContent } from 'wildebeest/backend/src/activitypub/objects'
import { getDocumentHead } from '~/utils/getDocumentHead' import { getDocumentHead } from '~/utils/getDocumentHead'
import { StatusAccountCard } from '~/components/StatusAccountCard/StatusAccountCard'
import { HtmlContent } from '~/components/HtmlContent/HtmlContent'
export const statusLoader = loader$< export const statusLoader = loader$<
Promise<{ status: MastodonStatus; statusTextContent: string; context: StatusContext }>, Promise<{ status: MastodonStatus; statusTextContent: string; context: StatusContext }>,
@ -50,67 +45,16 @@ export default component$(() => {
return ( return (
<> <>
<div class="p-4"> <Status status={loaderData.status} accountSubText="acct" showInfoTray={true} contentClickable={false} />
<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>
<div> <div>
{loaderData.context.descendants.map((status) => { {loaderData.context.descendants.map((status) => {
return <Status status={status} /> return <Status status={status} accountSubText="username" showInfoTray={false} contentClickable={true} />
})} })}
</div> </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 }) => { export const head: DocumentHead = ({ resolveValue }) => {
const { status, statusTextContent } = resolveValue(statusLoader) const { status, statusTextContent } = resolveValue(statusLoader)

Wyświetl plik

@ -68,6 +68,7 @@ export async function handleRequest(
media_attachments: [], media_attachments: [],
tags: [], tags: [],
mentions: [], mentions: [],
spoiler_text: properties.spoiler_text ?? '',
// TODO: a shortcut has been taked. We assume that the actor // TODO: a shortcut has been taked. We assume that the actor
// generating the notification also created the object. In practice // generating the notification also created the object. In practice
@ -76,7 +77,6 @@ export async function handleRequest(
// TODO: stub values // TODO: stub values
visibility: 'public', visibility: 'public',
spoiler_text: '',
} }
} }

Wyświetl plik

@ -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.getByRole('img', { name: 'Avatar of Raffa123$' })).toBeVisible()
await expect(post1Locator).toContainText("I'm Rafael and I am a web designer") 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.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!') await expect(post2Locator).toContainText('Hi! My name is Rafael!')
}) })

Wyświetl plik

@ -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') await page.goto('http://127.0.0.1:8788/explore')
const tootsTextsToCheck = [ const tootsTextsToCheck = [
'Hi! My name is Rafael!',
'We did it!', 'We did it!',
"I'm Rafael and I am a web designer!",
"Fine. I'll use Wildebeest", "Fine. I'll use Wildebeest",
'A very simple update: all good!', '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" }) const articleLocator = page.locator('article').filter({ hasText: "Fine. I'll use Wildebeest" })
await expect(articleLocator.getByRole('link', { name: 'blog.cloudflare.com/welcome-to…' })).toBeVisible() 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()
})

Wyświetl plik

@ -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.getByTestId('account-display-name').filter({ hasText: 'Penny' })).toBeVisible()
await expect(page.getByText('Yes you guys did it!')).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()
})