Merge pull request #182 from cloudflare/account-page

implement account page
pull/183/head
Sven Sauleau 2023-02-03 13:48:06 +00:00 zatwierdzone przez GitHub
commit 0fd30524f5
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 270 dodań i 102 usunięć

Wyświetl plik

@ -0,0 +1,59 @@
// https://docs.joinmastodon.org/methods/accounts/#get
import { actorURL, getActorById } from 'wildebeest/backend/src/activitypub/actors'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import type { Handle } from 'wildebeest/backend/src/utils/parse'
import { queryAcct } from 'wildebeest/backend/src/webfinger/index'
import { loadExternalMastodonAccount, loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { MastodonAccount } from '../types'
export async function getAccount(domain: string, accountId: string, db: D1Database): Promise<MastodonAccount | null> {
const handle = parseHandle(accountId)
if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) {
// Retrieve the statuses from a local user
return getLocalAccount(domain, db, handle)
} else if (handle.domain !== null) {
// Retrieve the statuses of a remote actor
const acct = `${handle.localPart}@${handle.domain}`
return getRemoteAccount(handle, acct)
} else {
return null
}
}
async function getRemoteAccount(handle: Handle, acct: string): Promise<MastodonAccount | null> {
// TODO: using webfinger isn't the optimal implementation. We could cache
// the object in D1 and directly query the remote API, indicated by the actor's
// url field. For now, let's keep it simple.
const actor = await queryAcct(handle.domain!, acct)
if (actor === null) {
return null
}
return await loadExternalMastodonAccount(acct, actor, true)
}
async function getLocalAccount(domain: string, db: D1Database, handle: Handle): Promise<MastodonAccount | null> {
const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart)
const actor = await getActorById(db, actorId)
if (actor === null) {
return null
}
return await loadLocalMastodonAccount(db, actor)
}
/**
* checks if a domain is a localhost one ('localhost' or '127.x.x.x') and
* in that case replaces it with '0.0.0.0' (which is what we use for our local data)
*
* Note: only needed for local development
*
* @param domain the potentially localhost domain
* @returns the adjusted domain if it was a localhost one, the original domain otherwise
*/
function adjustLocalHostDomain(domain: string) {
return domain.replace(/^localhost$|^127(\.(?:\d){1,3}){3}$/, '0.0.0.0')
}

Wyświetl plik

@ -231,7 +231,7 @@ export async function sanitizeObjectProperties(properties: unknown): Promise<APO
* See https://docs.joinmastodon.org/spec/activitypub/#sanitization
*/
export async function sanitizeContent(unsafeContent: string): Promise<string> {
return await contentRewriter.transform(new Response(unsafeContent)).text()
return await getContentRewriter().transform(new Response(unsafeContent)).text()
}
/**
@ -240,29 +240,35 @@ export async function sanitizeContent(unsafeContent: string): Promise<string> {
* This sanitization removes all HTML elements from the string leaving only the text content.
*/
export async function sanitizeName(unsafeName: string): Promise<string> {
return await nameRewriter.transform(new Response(unsafeName)).text()
return await getNameRewriter().transform(new Response(unsafeName)).text()
}
const contentRewriter = new HTMLRewriter()
contentRewriter.on('*', {
element(el) {
if (!['p', 'span', 'br', 'a'].includes(el.tagName)) {
el.tagName = 'p'
}
function getContentRewriter() {
const contentRewriter = new HTMLRewriter()
contentRewriter.on('*', {
element(el) {
if (!['p', 'span', 'br', 'a'].includes(el.tagName)) {
el.tagName = 'p'
}
if (el.hasAttribute('class')) {
const classes = el.getAttribute('class')!.split(/\s+/)
const sanitizedClasses = classes.filter((c) =>
/^(h|p|u|dt|e)-|^mention$|^hashtag$|^ellipsis$|^invisible$/.test(c)
)
el.setAttribute('class', sanitizedClasses.join(' '))
}
},
})
if (el.hasAttribute('class')) {
const classes = el.getAttribute('class')!.split(/\s+/)
const sanitizedClasses = classes.filter((c) =>
/^(h|p|u|dt|e)-|^mention$|^hashtag$|^ellipsis$|^invisible$/.test(c)
)
el.setAttribute('class', sanitizedClasses.join(' '))
}
},
})
return contentRewriter
}
const nameRewriter = new HTMLRewriter()
nameRewriter.on('*', {
element(el) {
el.removeAndKeepContent()
},
})
function getNameRewriter() {
const nameRewriter = new HTMLRewriter()
nameRewriter.on('*', {
element(el) {
el.removeAndKeepContent()
},
})
return nameRewriter
}

Wyświetl plik

@ -3,7 +3,7 @@ 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 './index.scss?inline'
import styles from '../../utils/innerHtmlContent.scss?inline'
import { MediaGallery } from '../MediaGallery.tsx'
type Props = {
@ -45,7 +45,7 @@ export default component$((props: Props) => {
</div>
</Link>
</div>
<div class="leading-relaxed status-content" dangerouslySetInnerHTML={status.content} />
<div class="leading-relaxed inner-html-content" dangerouslySetInnerHTML={status.content} />
</div>
<MediaGallery medias={status.media_attachments} />

Wyświetl plik

@ -12,7 +12,7 @@ import { MediaGallery } from '~/components/MediaGallery.tsx'
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
export const statusLoader = loader$<
{ DATABASE: D1Database; domain: string },
{ DATABASE: D1Database },
Promise<{ status: MastodonStatus; context: StatusContext }>
>(async ({ request, html, platform, params }) => {
const domain = new URL(request.url).hostname

Wyświetl plik

@ -1,23 +1,131 @@
import { component$ } from '@builder.io/qwik'
import { loader$ } from '@builder.io/qwik-city'
import { $, component$, useStyles$ } from '@builder.io/qwik'
import { loader$, useNavigate } from '@builder.io/qwik-city'
import { MastodonAccount } from 'wildebeest/backend/src/types'
import StickyHeader from '~/components/StickyHeader/StickyHeader'
import { formatDateTime } from '~/utils/dateTime'
import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml'
import { formatRoundedNumber } from '~/utils/numbers'
import styles from '../../../utils/innerHtmlContent.scss?inline'
import { getAccount } from 'wildebeest/backend/src/accounts/getAccount'
export const accountLoader = loader$(({ request, html }) => {
const params = new URL(request.url).searchParams
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const accountId = params.get('accountId')
export async function getAccountDetails(
domain: string,
db: D1Database,
accountId: string
): Promise<MastodonAccount | null> {
return await getAccount(domain, accountId, db)
}
html(404, getNotFoundHtml())
export const accountLoader = loader$<{ DATABASE: D1Database }, Promise<MastodonAccount>>(
async ({ platform, request, html }) => {
const url = new URL(request.url)
const domain = url.hostname
const accountId = url.pathname.split('/')[1]
// TODO: retrieve the account details from the backend
const accountDetails = null
const account = await getAccountDetails(domain, platform.DATABASE, accountId)
return accountDetails
})
if (!account) {
throw html(404, getNotFoundHtml())
}
return account
}
)
export default component$(() => {
const accountDetails = accountLoader.use()
useStyles$(styles)
const nav = useNavigate()
// TODO: Implement the account view
return <>{accountDetails.value && <div>account details</div>}</>
const accountDetails = accountLoader.use().value
const goBack = $(() => {
if (window.history.length > 1) {
window.history.back()
} else {
nav('/explore')
}
})
const fields = [
{
name: 'Joined',
value: formatDateTime(accountDetails.created_at, false),
},
...accountDetails.fields,
]
const stats = [
{
name: 'Posts',
value: formatRoundedNumber(accountDetails.statuses_count),
},
{
name: 'Following',
value: formatRoundedNumber(accountDetails.following_count),
},
{
name: 'Followers',
value: formatRoundedNumber(accountDetails.followers_count),
},
]
const accountDomain = getAccountDomain(accountDetails)
return (
<div>
<StickyHeader>
<div class="flex justify-between items-center xl:rounded-t header bg-wildebeest-700">
<button class="text-semi no-underline text-wildebeest-vibrant-400 bg-transparent p-4" onClick$={goBack}>
<i class="fa fa-chevron-left mr-2 w-3 inline-block" />
<span class="hover:underline">Back</span>
</button>
</div>
</StickyHeader>
<div class="relative mb-16">
<img
src={accountDetails.header}
alt={`Header of ${accountDetails.display_name}`}
class="w-full h-40 object-cover bg-wildebeest-500"
/>
<img
class="rounded h-24 w-24 absolute bottom-[-3rem] left-5 border-2 border-wildebeest-600"
src={accountDetails.avatar}
alt={`Avatar of ${accountDetails.display_name}`}
/>
</div>
<div class="px-5">
<h2 class="font-bold">{accountDetails.display_name}</h2>
<span class="block my-1 text-wildebeest-400">
@{accountDetails.acct}
{accountDomain && `@${accountDomain}`}
</span>
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.note} />
<dl class="mb-6 flex flex-col bg-wildebeest-800 border border-wildebeest-600 rounded-md">
{fields.map(({ name, value }) => (
<div class="border-b border-wildebeest-600 p-3 text-sm" key={name}>
<dt class="uppercase font-semibold text-wildebeest-500 opacity-80 mb-1">{name}</dt>
<dd class="inner-html-content opacity-80 text-wildebeest-200" dangerouslySetInnerHTML={value}></dd>
</div>
))}
</dl>
<div class="pb-4 flex flex-wrap gap-5">
{stats.map(({ name, value }) => (
<div class="flex gap-1" key={name}>
<span class="font-semibold">{value}</span>
<span class="text-wildebeest-500">{name}</span>
</div>
))}
</div>
</div>
</div>
)
})
export function getAccountDomain(account: MastodonAccount): string | null {
try {
const url = new URL(account.url)
return url.hostname || null
} catch {
return null
}
}

Wyświetl plik

@ -40,9 +40,9 @@ export const formatTimeAgo = (date: Date) => {
return `${roundTo(YEAR)}y`
}
export const formatDateTime = (isoString: string) => {
export const formatDateTime = (isoString: string, includeTime = true) => {
const date = new Date(isoString)
const dateFormatter = Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' })
const timeFormatter = Intl.DateTimeFormat('en', { timeStyle: 'short' })
return `${dateFormatter.format(date)}, ${timeFormatter.format(date)}`
return [dateFormatter.format(date), ...(includeTime ? [timeFormatter.format(date)] : [])].join(', ')
}

Wyświetl plik

@ -2,7 +2,7 @@
// defined by the client (set using dangerouslySetInnerHTML)
// (thus wee cannot rely on Tailwind for such content)
.status-content {
.inner-html-content {
a {
text-decoration: none;
color: var(--wildebeest-vibrant-color-400);
@ -17,28 +17,28 @@
p {
margin-bottom: theme('spacing.4');
}
}
.invisible {
font-size: 0;
line-height: 0;
display: inline-block;
width: 0;
height: 0;
}
.invisible {
font-size: 0;
line-height: 0;
display: inline-block;
width: 0;
height: 0;
}
.ellipsis {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
text-decoration: none;
}
.ellipsis {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
text-decoration: none;
}
.ellipsis::after {
content: "...";
}
.ellipsis::after {
content: "...";
}
.status-link {
color: var(--wildebeest-vibrant-color-200);
text-decoration: none;
.status-link {
color: var(--wildebeest-vibrant-color-200);
text-decoration: none;
}
}

Wyświetl plik

@ -0,0 +1,30 @@
import { fetch } from 'undici'
describe('Account page', () => {
it('should display the basic information of an account', async () => {
const response = await fetch(`http://0.0.0.0:6868/@BethanyBlack`)
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toMatch(/<img [^<>]*alt="Avatar of Bethany Black"[^<>]*>/)
expect(body).toMatch(/<h2 [^<>]*>Bethany Black<\/h2>/)
expect(body).toMatch(
/<div [^<>]*><dt [^<>]*>Joined<\/dt>[^<>]*<dd [^<>]*>[A-Z][a-z]{2} \d{1,2}, \d{4}<\/dd>[^<>]*<\/div>/
)
expect(body).toMatch(
/<div [^<>]*><dt [^<>]*>Joined<\/dt>[^<>]*<dd [^<>]*>[A-Z][a-z]{2} \d{1,2}, \d{4}<\/dd>[^<>]*<\/div>/
)
const stats = [
{ name: 'Posts', value: 1 },
{ name: 'Posts', value: 1 },
{ name: 'Following', value: 0 },
{ name: 'Followers', value: 0 },
]
stats.forEach(({ name, value }) => {
const regex = new RegExp(`<span [^<>]*>${value}</span>[^<>]*<span [^<>]*>${name}</span>`)
expect(body).toMatch(regex)
})
})
})

Wyświetl plik

@ -1,14 +1,9 @@
// https://docs.joinmastodon.org/methods/accounts/#get
import { cors } from 'wildebeest/backend/src/utils/cors'
import { actorURL } from 'wildebeest/backend/src/activitypub/actors'
import { getActorById } from 'wildebeest/backend/src/activitypub/actors'
import type { ContextData } from 'wildebeest/backend/src/types/context'
import type { Env } from 'wildebeest/backend/src/types/env'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import type { Handle } from 'wildebeest/backend/src/utils/parse'
import { queryAcct } from 'wildebeest/backend/src/webfinger/index'
import { loadExternalMastodonAccount, loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { getAccount } from 'wildebeest/backend/src/accounts/getAccount'
const headers = {
...cors(),
@ -21,41 +16,11 @@ export const onRequest: PagesFunction<Env, any, ContextData> = async ({ request,
}
export async function handleRequest(domain: string, id: string, db: D1Database): Promise<Response> {
const handle = parseHandle(id)
const account = await getAccount(domain, id, db)
if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) {
// Retrieve the statuses from a local user
return getLocalAccount(domain, db, handle)
} else if (handle.domain !== null) {
// Retrieve the statuses of a remote actor
const acct = `${handle.localPart}@${handle.domain}`
return getRemoteAccount(handle, acct)
if (account) {
return new Response(JSON.stringify(account), { headers })
} else {
return new Response('', { status: 403 })
}
}
async function getRemoteAccount(handle: Handle, acct: string): Promise<Response> {
// TODO: using webfinger isn't the optimal implementation. We could cache
// the object in D1 and directly query the remote API, indicated by the actor's
// url field. For now, let's keep it simple.
const actor = await queryAcct(handle.domain!, acct)
if (actor === null) {
return new Response('', { status: 404 })
}
const res = await loadExternalMastodonAccount(acct, actor, true)
return new Response(JSON.stringify(res), { headers })
}
async function getLocalAccount(domain: string, db: D1Database, handle: Handle): Promise<Response> {
const actorId = actorURL(domain, handle.localPart)
const actor = await getActorById(db, actorId)
if (actor === null) {
return new Response('', { status: 404 })
}
const res = await loadLocalMastodonAccount(db, actor)
return new Response(JSON.stringify(res), { headers })
}