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>
cc: Array<string>
tag: Array<Link>
spoiler_text?: string
}
export async function createPublicNote(

Wyświetl plik

@ -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,

Wyświetl plik

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

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 { 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 sm:break-normal">
<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>
)
})

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 } : {}
return (
<div key={status.id} {...divProps}>
<Status status={status} />
<Status status={status} accountSubText="username" showInfoTray={false} contentClickable={true} />
</div>
)
})

Wyświetl plik

@ -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: '',

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
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) {

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 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)

Wyświetl plik

@ -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: '',
}
}

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).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!')
})

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')
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()
})

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.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()
})