From 6298220010a9130272427c115497a1b21b639652 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Thu, 2 Feb 2023 16:46:53 +0000 Subject: [PATCH] implement account page --- backend/src/accounts/getAccount.ts | 59 ++++++++ backend/src/activitypub/objects/index.ts | 52 ++++--- frontend/src/components/Status/index.tsx | 4 +- .../[accountId]/[statusId]/index.tsx | 2 +- .../routes/(frontend)/[accountId]/index.tsx | 136 ++++++++++++++++-- frontend/src/utils/dateTime.ts | 4 +- .../innerHtmlContent.scss} | 42 +++--- frontend/test/account.spec.ts | 30 ++++ functions/api/v1/accounts/[id].ts | 43 +----- 9 files changed, 270 insertions(+), 102 deletions(-) create mode 100644 backend/src/accounts/getAccount.ts rename frontend/src/{components/Status/index.scss => utils/innerHtmlContent.scss} (53%) create mode 100644 frontend/test/account.spec.ts diff --git a/backend/src/accounts/getAccount.ts b/backend/src/accounts/getAccount.ts new file mode 100644 index 0000000..3b674cb --- /dev/null +++ b/backend/src/accounts/getAccount.ts @@ -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 { + 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 { + // 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 { + 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') +} diff --git a/backend/src/activitypub/objects/index.ts b/backend/src/activitypub/objects/index.ts index 59df2da..900edc7 100644 --- a/backend/src/activitypub/objects/index.ts +++ b/backend/src/activitypub/objects/index.ts @@ -231,7 +231,7 @@ export async function sanitizeObjectProperties(properties: unknown): Promise { - 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 { * This sanitization removes all HTML elements from the string leaving only the text content. */ export async function sanitizeName(unsafeName: string): Promise { - 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 +} diff --git a/frontend/src/components/Status/index.tsx b/frontend/src/components/Status/index.tsx index 5a7e266..8ea9775 100644 --- a/frontend/src/components/Status/index.tsx +++ b/frontend/src/components/Status/index.tsx @@ -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) => { -
+
diff --git a/frontend/src/routes/(frontend)/[accountId]/[statusId]/index.tsx b/frontend/src/routes/(frontend)/[accountId]/[statusId]/index.tsx index df66f7a..5b22193 100644 --- a/frontend/src/routes/(frontend)/[accountId]/[statusId]/index.tsx +++ b/frontend/src/routes/(frontend)/[accountId]/[statusId]/index.tsx @@ -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 diff --git a/frontend/src/routes/(frontend)/[accountId]/index.tsx b/frontend/src/routes/(frontend)/[accountId]/index.tsx index d8ec0c9..64058b7 100644 --- a/frontend/src/routes/(frontend)/[accountId]/index.tsx +++ b/frontend/src/routes/(frontend)/[accountId]/index.tsx @@ -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 { + return await getAccount(domain, accountId, db) +} - html(404, getNotFoundHtml()) +export const accountLoader = loader$<{ DATABASE: D1Database }, Promise>( + 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 &&
account details
} + 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 ( +
+ +
+ +
+
+
+ {`Header + {`Avatar +
+
+

{accountDetails.display_name}

+ + @{accountDetails.acct} + {accountDomain && `@${accountDomain}`} + +
+
+ {fields.map(({ name, value }) => ( +
+
{name}
+
+
+ ))} +
+
+ {stats.map(({ name, value }) => ( +
+ {value} + {name} +
+ ))} +
+
+
+ ) }) + +export function getAccountDomain(account: MastodonAccount): string | null { + try { + const url = new URL(account.url) + return url.hostname || null + } catch { + return null + } +} diff --git a/frontend/src/utils/dateTime.ts b/frontend/src/utils/dateTime.ts index 7cfc8cb..521e6e1 100644 --- a/frontend/src/utils/dateTime.ts +++ b/frontend/src/utils/dateTime.ts @@ -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(', ') } diff --git a/frontend/src/components/Status/index.scss b/frontend/src/utils/innerHtmlContent.scss similarity index 53% rename from frontend/src/components/Status/index.scss rename to frontend/src/utils/innerHtmlContent.scss index af10141..2a4ab94 100644 --- a/frontend/src/components/Status/index.scss +++ b/frontend/src/utils/innerHtmlContent.scss @@ -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; + } } diff --git a/frontend/test/account.spec.ts b/frontend/test/account.spec.ts new file mode 100644 index 0000000..634c47c --- /dev/null +++ b/frontend/test/account.spec.ts @@ -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(/]*alt="Avatar of Bethany Black"[^<>]*>/) + expect(body).toMatch(/

]*>Bethany Black<\/h2>/) + expect(body).toMatch( + /
]*>
]*>Joined<\/dt>[^<>]*
]*>[A-Z][a-z]{2} \d{1,2}, \d{4}<\/dd>[^<>]*<\/div>/ + ) + + expect(body).toMatch( + /
]*>
]*>Joined<\/dt>[^<>]*
]*>[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(`]*>${value}[^<>]*]*>${name}`) + expect(body).toMatch(regex) + }) + }) +}) diff --git a/functions/api/v1/accounts/[id].ts b/functions/api/v1/accounts/[id].ts index 4c179ac..d2eb7fb 100644 --- a/functions/api/v1/accounts/[id].ts +++ b/functions/api/v1/accounts/[id].ts @@ -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 = async ({ request, } export async function handleRequest(domain: string, id: string, db: D1Database): Promise { - 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 { - // 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 { - 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 }) }