Merge pull request #300 from cloudflare/account-link

Improve navigation to accounts and toots
pull/294/head
Sven Sauleau 2023-02-16 13:57:54 +00:00 zatwierdzone przez GitHub
commit 20be442595
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
13 zmienionych plików z 127 dodań i 131 usunięć

Wyświetl plik

@ -1,5 +1,5 @@
import { createPerson, getPersonByEmail, type Person } from 'wildebeest/backend/src/activitypub/actors'
import { replies, statuses } from 'wildebeest/frontend/src/dummyData'
import { reblogs, replies, statuses } from 'wildebeest/frontend/src/dummyData'
import type { Account, MastodonStatus } from 'wildebeest/frontend/src/types'
import { Note } from 'wildebeest/backend/src/activitypub/objects/note'
import { createReblog } from 'wildebeest/backend/src/mastodon/reblog'
@ -24,8 +24,17 @@ export async function init(domain: string, db: D1Database) {
loadedStatuses.push({ status, note })
}
const { reblogger, noteToReblog } = await pickReblogDetails(loadedStatuses, domain, db)
await createReblog(db, reblogger, noteToReblog)
for (const reblog of reblogs) {
const rebloggerAccount = reblog.account
const reblogger = await getOrCreatePerson(domain, db, rebloggerAccount)
const reblogStatus = reblog.reblog
if (reblogStatus?.id) {
const noteToReblog = loadedStatuses.find(({ status: { id } }) => id === reblogStatus.id)?.note
if (noteToReblog) {
await createReblog(db, reblogger, noteToReblog)
}
}
}
for (const reply of replies) {
await createReply(domain, db, reply, loadedStatuses)
@ -74,21 +83,3 @@ async function getOrCreatePerson(
}
return newPerson
}
/**
* Picks the details to use to reblog an arbitrary note/status.
*
* Both the note/status and the reblogger are picked arbitrarily
* form a list of available notes/states (respectively from the first
* and second entries).
*/
async function pickReblogDetails(
loadedStatuses: { status: MastodonStatus; note: Note }[],
domain: string,
db: D1Database
) {
const rebloggerAccount = loadedStatuses[1].status.account
const reblogger = await getOrCreatePerson(domain, db, rebloggerAccount)
const noteToReblog = loadedStatuses[2].note
return { reblogger, noteToReblog }
}

Wyświetl plik

@ -1,12 +1,12 @@
import { component$, $, useStyles$ } from '@builder.io/qwik'
import { Link, useNavigate } from '@builder.io/qwik-city'
import { formatTimeAgo } from '~/utils/dateTime'
import { Avatar } from '../avatar'
import type { Account, MastodonStatus } from '~/types'
import styles from '../../utils/innerHtmlContent.scss?inline'
import { MediaGallery } from '../MediaGallery.tsx'
import { useAccountUrl } from '~/utils/useAccountUrl'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
import { StatusAccountCard } from '../StatusAccountCard/StatusAccountCard'
type Props = {
status: MastodonStatus
@ -27,28 +27,20 @@ export default component$((props: Props) => {
return (
<article class="p-4 border-t border-wildebeest-700 break-words sm:break-normal">
<RebloggerLink account={reblogger}></RebloggerLink>
<div onClick$={handleContentClick}>
<div class="flex justify-between mb-3">
<div class="flex">
<Avatar primary={status.account} secondary={reblogger} />
<div class="flex-col ml-3">
<div>
<Link class="no-underline" href={accountUrl}>
{getDisplayNameElement(status.account)}
</Link>
</div>
<div class="text-wildebeest-500">@{status.account.username}</div>
</div>
<div class="flex justify-between mb-3">
<StatusAccountCard status={status} subText="username" 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" />
<span class="ml-2 text-sm hover:underline">{formatTimeAgo(new Date(status.created_at))}</span>
</div>
<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" />
<span class="ml-2 text-sm hover:underline">{formatTimeAgo(new Date(status.created_at))}</span>
</div>
</Link>
</div>
<div class="leading-relaxed inner-html-content" dangerouslySetInnerHTML={status.content} />
</Link>
</div>
<div
onClick$={handleContentClick}
class="leading-relaxed inner-html-content cursor-pointer"
dangerouslySetInnerHTML={status.content}
/>
<MediaGallery medias={status.media_attachments} />

Wyświetl plik

@ -0,0 +1,31 @@
import { component$ } from '@builder.io/qwik'
import { Link } from '@builder.io/qwik-city'
import { type MastodonStatus } from '~/types'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
import { useAccountUrl } from '~/utils/useAccountUrl'
import { Avatar, type AvatarDetails } from '../avatar'
export const StatusAccountCard = component$<{
status: MastodonStatus
subText: 'username' | 'acct'
secondaryAvatar?: AvatarDetails | null
}>(({ status, subText, secondaryAvatar }) => {
const accountUrl = useAccountUrl(status.account)
return (
<Link
href={accountUrl}
class="inline-grid grid-cols-[repeat(2,_max-content)] grid-rows-[1fr,1fr] items-center no-underline"
>
<div class="row-span-2">
<Avatar primary={status.account} secondary={secondaryAvatar ?? null} />
</div>
<div data-testid="account-display-name" class="ml-2 col-start-2 row-start-1">
{getDisplayNameElement(status.account)}
</div>
<div class="ml-2 text-wildebeest-400 col-start-2 row-start-2">
@{subText === 'username' ? status.account.username : status.account.acct}
</div>
</Link>
)
})

Wyświetl plik

@ -1,32 +1,35 @@
import { component$ } from '@builder.io/qwik'
import { Link } from '@builder.io/qwik-city'
import type { Account } from '~/types'
import { useAccountUrl } from '~/utils/useAccountUrl'
type AvatarDetails = Partial<Pick<Account, 'id'>> & Pick<Account, 'display_name' | 'avatar' | 'url'>
export type AvatarDetails = Partial<Pick<Account, 'id'>> & Pick<Account, 'display_name' | 'avatar' | 'url'>
type Props = {
primary: AvatarDetails
secondary: AvatarDetails | null
withLinks?: boolean
}
export const Avatar = component$<Props>(({ primary, secondary }) => {
export const Avatar = component$<Props>(({ primary, secondary, withLinks }) => {
const primaryUrl = useAccountUrl(primary)
const secondaryUrl = useAccountUrl(secondary)
// eslint-disable-next-line qwik/single-jsx-root
const primaryImg = <img class="rounded h-12 w-12" src={primary.avatar} alt={`Avatar of ${primary.display_name}`} />
const secondaryImg = (
<img
class="absolute right-0 bottom-0 rounded h-6 w-6"
src={secondary?.avatar}
alt={`Avatar of ${secondary?.display_name}`}
/>
)
return (
<div class={`relative ${secondary && 'pr-2 pb-2'}`}>
<a href={primaryUrl}>
<img class="rounded h-12 w-12" src={primary.avatar} alt={`Avatar of ${primary.display_name}`} />
</a>
{secondary && (
<a href={secondaryUrl}>
<img
class="absolute right-0 bottom-0 rounded h-6 w-6"
src={secondary.avatar}
alt={`Avatar of ${secondary.display_name}`}
/>
</a>
)}
{withLinks ? <Link href={primaryUrl}>{primaryImg}</Link> : primaryImg}
{secondary && (withLinks ? <Link href={secondaryUrl}>{secondaryImg}</Link> : secondaryImg)}
</div>
)
})

Wyświetl plik

@ -5,8 +5,8 @@ export const george = generateDummyAccount({
username: 'george',
acct: 'george_george@dummy.users.wildebeest.com',
display_name: 'George :verified: 👍',
avatar: getAvatarUrl(805),
avatar_static: getAvatarUrl(805),
avatar: getAvatarUrl(837),
avatar_static: getAvatarUrl(837),
})
export const zak = generateDummyAccount({

Wyświetl plik

@ -5,7 +5,8 @@ export function generateDummyStatus(
content: string,
account: Account,
mediaAttachments: MediaAttachment[] = [],
inReplyTo: string | null = null
inReplyTo: string | null = null,
reblog: MastodonStatus | null = null
): MastodonStatus {
return {
id: `${Math.random() * 9999999}`.padStart(3, '7'),
@ -23,7 +24,7 @@ export function generateDummyStatus(
favourites_count: Math.random() * 900,
edited_at: null,
content,
reblog: null,
reblog,
application: { name: 'Wildebeest', website: null },
account,
media_attachments: mediaAttachments,

Wyświetl plik

@ -2,7 +2,7 @@ import type { MediaAttachment, MastodonStatus } from '~/types'
import { generateDummyStatus } from './generateDummyStatus'
import { ben, george, penny, rafael, zak } from './accounts'
// Raw statuses taken directly from mastodon
// Raw statuses which follow the precise structure found mastodon does
const mastodonRawStatuses: MastodonStatus[] = [
generateDummyStatus(
`
@ -45,6 +45,8 @@ export const replies: MastodonStatus[] = [
generateDummyStatus('<p> Yes you guys did it! </p>', penny, [], statuses[1].id),
]
export const reblogs: MastodonStatus[] = [generateDummyStatus('', george, [], null, statuses[2])]
function getStandardMediaType(mediaAttachmentMastodonType: string): string {
switch (mediaAttachmentMastodonType) {
case 'image':

Wyświetl plik

@ -87,6 +87,7 @@ export default component$(() => {
url: url.toString(),
}}
secondary={null}
withLinks={true}
/>
</div>
<p class="col-start-2">Signed in as:</p>

Wyświetl plik

@ -5,17 +5,15 @@ 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, Link, loader$ } from '@builder.io/qwik-city'
import { DocumentHead, loader$ } from '@builder.io/qwik-city'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
import { Avatar } from '~/components/avatar'
import { MediaGallery } from '~/components/MediaGallery.tsx'
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml'
import styles from '../../../../utils/innerHtmlContent.scss?inline'
import { getTextContent } from 'wildebeest/backend/src/activitypub/objects'
import { getDocumentHead } from '~/utils/getDocumentHead'
import { useAccountUrl } from '~/utils/useAccountUrl'
import { getDisplayNameElement } from '~/utils/getDisplayNameElement'
import { StatusAccountCard } from '~/components/StatusAccountCard/StatusAccountCard'
export const statusLoader = loader$<
{ DATABASE: D1Database },
@ -57,7 +55,7 @@ export default component$(() => {
<>
<StickyHeader withBackButton />
<div class="bg-wildebeest-700 p-4">
<AccountCard status={loaderData.status} />
<StatusAccountCard subText="acct" status={loaderData.status} />
<div class="leading-normal inner-html-content text-lg" dangerouslySetInnerHTML={loaderData.status.content} />
<MediaGallery medias={loaderData.status.media_attachments} />
@ -73,24 +71,6 @@ export default component$(() => {
)
})
export const AccountCard = component$<{ status: MastodonStatus }>(({ status }) => {
const accountUrl = useAccountUrl(status.account)
return (
<div class="flex">
<Avatar primary={status.account} secondary={null} />
<div class="flex flex-col">
<div class="p-1">
<Link href={accountUrl} class="no-underline">
{getDisplayNameElement(status.account)}
</Link>
</div>
<div class="p-1 text-wildebeest-400">@{status.account.acct}</div>
</div>
</div>
)
})
export const InfoTray = component$<{ status: MastodonStatus }>(({ status }) => {
return (
<div class="text-wildebeest-500 mt-4 text-sm">

Wyświetl plik

@ -1,41 +1,26 @@
import { test, expect } from '@playwright/test'
const navigationsVia = ['name link', 'avatar'] as const
test(`Navigation via to and view of an account (with 1 post)`, async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await page.getByRole('article').getByRole('link').filter({ hasText: 'Ben, just Ben' }).first().click()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Avatar of Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('heading', { name: 'Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByText('Joined')).toBeVisible()
await expect(page.getByTestId('account-info').getByTestId('stats')).toHaveText('1Posts0Following0Followers')
navigationsVia.forEach((via) =>
test(`Navigation via ${via} to and view of an account (with 1 post)`, async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await page.getByRole('article').getByRole('link', { name: 'Ben, just Ben', exact: true }).click()
await page.getByRole('link', { name: 'Ben, just Ben', exact: true }).click()
await page.waitForLoadState('networkidle')
const linkOption =
via === 'name link' ? { name: 'Ben, just Ben', exact: true } : { name: 'Avatar of Ben, just Ben' }
await page.getByRole('link', linkOption).click()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Avatar of Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('heading', { name: 'Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-info').getByText('Joined')).toBeVisible()
await expect(page.getByTestId('account-info').getByTestId('stats')).toHaveText('1Posts0Following0Followers')
expect(await page.getByTestId('account-statuses').getByRole('article').count()).toBe(1)
await expect(
page.getByTestId('account-statuses').getByRole('article').getByRole('img', { name: 'Avatar of Ben, just Ben' })
).toBeVisible()
await expect(page.getByTestId('account-statuses').getByRole('article')).toContainText(
'A very simple update: all good!'
)
})
)
expect(await page.getByTestId('account-statuses').getByRole('article').count()).toBe(1)
await expect(
page.getByTestId('account-statuses').getByRole('article').getByRole('img', { name: 'Avatar of Ben, just Ben' })
).toBeVisible()
await expect(page.getByTestId('account-statuses').getByRole('article')).toContainText(
'A very simple update: all good!'
)
})
test('Navigation to and view of an account (with 2 posts)', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await page
.locator('article')
.filter({ hasText: "I'm Rafael and I am a web designer" })
.locator('i.fa-globe + span')
.click()
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: 'Raffa123$', exact: true }).click()
await page.getByRole('article').getByRole('link').filter({ hasText: 'Raffa123$' }).first().click()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Raffa123$' })).toBeVisible()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Avatar of Raffa123$' })).toBeVisible()

Wyświetl plik

@ -40,7 +40,11 @@ test('View of custom emojis in an toots author display name', async ({ page, bro
.locator('i.fa-globe + span')
.click()
const customEmojiLocator = page.getByRole('link', { name: 'George :verified: 👍', exact: true }).getByRole('img')
const customEmojiLocator = page
.getByRole('link')
.filter({ hasText: 'George' })
.getByTestId('account-display-name')
.getByRole('img')
await expect(customEmojiLocator).toBeVisible()
await expect(customEmojiLocator).toHaveAttribute(
'src',

Wyświetl plik

@ -26,9 +26,7 @@ test.describe('Infinite (statuses) scrolling', () => {
description: 'in account page',
goToPageFn: async (page: Page) => {
await page.goto('http://127.0.0.1:8788/explore')
await page.locator('article').filter({ hasText: "I'm Rafael" }).locator('i.fa-globe + span').click()
await page.waitForLoadState('networkidle')
await page.getByRole('link', { name: 'Raffa123$', exact: true }).click()
await page.getByRole('article').getByRole('link').filter({ hasText: 'Raffa123$' }).first().click()
await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Raffa123$' })).toBeVisible()
},
fetchUrl: 'http://127.0.0.1:8788/api/v1/accounts/Rafael/statuses?*',

Wyświetl plik

@ -1,13 +1,21 @@
import { test, expect } from '@playwright/test'
test('Navigation to and view of an individual toot', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
await page.locator('article').filter({ hasText: 'Ben, just Ben' }).locator('i.fa-globe + span').click()
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Avatar of Ben, just Ben' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Ben, just Ben', exact: true })).toBeVisible()
await expect(page.locator('span', { hasText: 'A very simple update: all good!' })).toBeVisible()
})
const navigationVias = ['time link', 'toot content'] as const
navigationVias.forEach((via) =>
test(`Navigation to and view of an individual toot via ${via}`, async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
if (via === 'time link') {
await page.locator('article').filter({ hasText: 'Ben, just Ben' }).locator('i.fa-globe + span').click()
} else {
await page.getByText('A very simple update: all good!').click()
}
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Avatar of Ben, just Ben' })).toBeVisible()
await expect(page.getByTestId('account-display-name').filter({ hasText: 'Ben, just Ben' })).toBeVisible()
await expect(page.locator('span', { hasText: 'A very simple update: all good!' })).toBeVisible()
})
)
test('Navigation to and view of an individual toot with images', async ({ page }) => {
await page.goto('http://127.0.0.1:8788/explore')
@ -18,7 +26,7 @@ test('Navigation to and view of an individual toot with images', async ({ page }
.click()
await expect(page.getByRole('button', { name: 'Back' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Avatar of Raffa123$' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Raffa123$', exact: true })).toBeVisible()
await expect(page.getByTestId('account-display-name').filter({ hasText: 'Raffa123$' })).toBeVisible()
await expect(page.locator('p', { hasText: "I'm Rafael and I am a web designer!" })).toBeVisible()
expect(await page.getByTestId('media-gallery').getByRole('img').count()).toBe(4)
await expect(page.getByTestId('images-modal')).not.toBeVisible()
@ -57,7 +65,7 @@ test("Navigation to and view of a toot's replies", async ({ page }) => {
.click()
await expect(page.getByRole('link', { name: 'Avatar of Zak Smith' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Zak Smith', exact: true })).toBeVisible()
await expect(page.getByTestId('account-display-name').filter({ hasText: 'Zak Smith' })).toBeVisible()
await expect(page.getByText('Yes we did!')).toBeVisible()
await page.getByRole('button', { name: 'Back' }).click()
@ -72,6 +80,6 @@ test("Navigation to and view of a toot's replies", async ({ page }) => {
.click()
await expect(page.getByRole('link', { name: 'Avatar of Penny' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Penny', exact: true })).toBeVisible()
await expect(page.getByTestId('account-display-name').filter({ hasText: 'Penny' })).toBeVisible()
await expect(page.getByText('Yes you guys did it!')).toBeVisible()
})