From 37bddfbe19722d40c5f82cd01b6208d7fe40688e Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Mon, 13 Feb 2023 12:33:24 +0000 Subject: [PATCH] show account posts in account page --- backend/src/accounts/getAccount.ts | 14 +-- backend/src/mastodon/account.ts | 2 +- backend/src/utils/adjustLocalHostDomain.ts | 12 ++ .../StatusesPanel/StatusesPanel.tsx | 61 ++++++++++ frontend/src/dummyData.tsx | 110 ++++++++++++++++++ .../routes/(frontend)/[accountId]/index.tsx | 95 +++++++++------ .../src/routes/(frontend)/explore/index.tsx | 80 +++---------- functions/api/v1/accounts/[id]/statuses.ts | 5 +- ui-e2e-tests/account-page.spec.ts | 39 ++++++- 9 files changed, 300 insertions(+), 118 deletions(-) create mode 100644 backend/src/utils/adjustLocalHostDomain.ts create mode 100644 frontend/src/components/StatusesPanel/StatusesPanel.tsx diff --git a/backend/src/accounts/getAccount.ts b/backend/src/accounts/getAccount.ts index 3b674cb..5f3f630 100644 --- a/backend/src/accounts/getAccount.ts +++ b/backend/src/accounts/getAccount.ts @@ -6,6 +6,7 @@ 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' +import { adjustLocalHostDomain } from '../utils/adjustLocalHostDomain' export async function getAccount(domain: string, accountId: string, db: D1Database): Promise { const handle = parseHandle(accountId) @@ -44,16 +45,3 @@ async function getLocalAccount(domain: string, db: D1Database, handle: Handle): 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/mastodon/account.ts b/backend/src/mastodon/account.ts index 2f6751c..a1961b3 100644 --- a/backend/src/mastodon/account.ts +++ b/backend/src/mastodon/account.ts @@ -1,6 +1,6 @@ import { MastodonAccount } from 'wildebeest/backend/src/types/account' import { unwrapPrivateKey } from 'wildebeest/backend/src/utils/key-ops' -import type { Actor } from '../activitypub/actors' +import { Actor } from '../activitypub/actors' import { defaultImages } from 'wildebeest/config/accounts' import * as apOutbox from 'wildebeest/backend/src/activitypub/actors/outbox' import * as apFollow from 'wildebeest/backend/src/activitypub/actors/follow' diff --git a/backend/src/utils/adjustLocalHostDomain.ts b/backend/src/utils/adjustLocalHostDomain.ts new file mode 100644 index 0000000..4c48db2 --- /dev/null +++ b/backend/src/utils/adjustLocalHostDomain.ts @@ -0,0 +1,12 @@ +/** + * 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 + */ +export function adjustLocalHostDomain(domain: string) { + return domain.replace(/^localhost$|^127(\.(?:\d){1,3}){3}$/, '0.0.0.0') +} diff --git a/frontend/src/components/StatusesPanel/StatusesPanel.tsx b/frontend/src/components/StatusesPanel/StatusesPanel.tsx new file mode 100644 index 0000000..0db427c --- /dev/null +++ b/frontend/src/components/StatusesPanel/StatusesPanel.tsx @@ -0,0 +1,61 @@ +import { $, component$, useClientEffect$, useSignal, type QRL } from '@builder.io/qwik' +import { type MastodonStatus } from '~/types' +import Status from '../Status' + +type Props = { + initialStatuses: MastodonStatus[] + fetchMoreStatuses: QRL<(numOfCurrentStatuses: number) => Promise> +} + +export const StatusesPanel = component$(({ initialStatuses, fetchMoreStatuses: fetchMoreStatusesFn }: Props) => { + const fetchingMoreStatuses = useSignal(false) + const noMoreStatusesAvailable = useSignal(false) + const lastStatusRef = useSignal() + const statuses = useSignal(initialStatuses) + + const fetchMoreStatuses = $(async () => { + if (fetchingMoreStatuses.value || noMoreStatusesAvailable.value) { + return + } + fetchingMoreStatuses.value = true + const newStatuses = await fetchMoreStatusesFn(statuses.value.length) + fetchingMoreStatuses.value = false + noMoreStatusesAvailable.value = newStatuses.length === 0 + }) + + useClientEffect$(({ track }) => { + track(() => lastStatusRef.value) + if (lastStatusRef.value) { + const observer = new IntersectionObserver( + async ([lastStatus]) => { + if (lastStatus.isIntersecting) { + await fetchMoreStatuses() + observer.disconnect() + } + }, + { rootMargin: '250px' } + ) + observer.observe(lastStatusRef.value) + } + }) + + return ( + <> + {statuses.value.length > 0 ? ( + statuses.value.map((status, i) => { + const isLastStatus = i === statuses.value.length - 1 + const divProps = isLastStatus ? { ref: lastStatusRef } : {} + return ( +
+ +
+ ) + }) + ) : ( +
+

Nothing to see right now. Check back later!

+
+ )} + + ) +}) diff --git a/frontend/src/dummyData.tsx b/frontend/src/dummyData.tsx index 8b86ad3..a9fa4f4 100644 --- a/frontend/src/dummyData.tsx +++ b/frontend/src/dummyData.tsx @@ -1126,6 +1126,116 @@ const mastodonRawStatuses: MastodonStatus[] = [ }, poll: null, }, + { + id: '109261798394272672', + created_at: '2022-10-31T07:52:14.290Z', + in_reply_to_id: null, + in_reply_to_account_id: null, + sensitive: false, + spoiler_text: '', + visibility: 'public', + language: 'en', + uri: 'https://mastodon.design/users/rafa/statuses/109261798394272672', + url: 'https://mastodon.design/@rafa/109261798394272672', + replies_count: 5, + reblogs_count: 10, + favourites_count: 42, + edited_at: null, + content: + '

Time for an updated :

I'm Rafa, a designer and app developer currently living in Amsterdam ā˜€ļø

I make Hand Mirror for macOS, Booby Track for iOS/watchOS, and co-host a design podcast with my friend @kevin called Layout.

Been loving the sense of community and the good vibes you're all giving in this platform, say hi šŸ‘‹

Here's some topics I feel like I like to participate in, I hear hashtags can help with discovery so here it goes:

', + reblog: null, + application: { + name: 'Web', + website: null, + }, + account: { + id: '11932', + username: 'rafa', + acct: 'rafa', + display_name: 'Rafa', + locked: false, + bot: false, + discoverable: true, + group: false, + created_at: '2018-08-21T00:00:00.000Z', + note: '

Iā€™m a designer and app developer, currently working on Sketch, and Hand Mirror for Mac

', + url: 'https://mastodon.design/@rafa', + avatar: 'https://cdn.masto.host/mastodondesign/accounts/avatars/000/011/932/original/8f601be03c98b2e8.png', + avatar_static: 'https://cdn.masto.host/mastodondesign/accounts/avatars/000/011/932/original/8f601be03c98b2e8.png', + header: 'https://cdn.masto.host/mastodondesign/accounts/headers/000/011/932/original/668f6d32abb54252.jpeg', + header_static: + 'https://cdn.masto.host/mastodondesign/accounts/headers/000/011/932/original/668f6d32abb54252.jpeg', + followers_count: 3135, + following_count: 453, + statuses_count: 1289, + last_status_at: '2023-02-12', + noindex: false, + emojis: [], + fields: [ + { + name: 'Website', + value: + 'rafa.design', + verified_at: '2022-11-06T16:49:24.339+00:00', + }, + { + name: 'App', + value: + 'handmirror.app', + verified_at: null, + }, + { + name: 'Podcast', + value: + 'layout.fm', + verified_at: null, + }, + { + name: 'Pronouns', + value: 'He/Them', + verified_at: null, + }, + ], + }, + media_attachments: [], + mentions: [ + { + id: '12179', + username: 'kevin', + url: 'https://mastodon.design/@kevin', + acct: 'kevin', + }, + ], + tags: [ + { + name: 'therapy', + url: 'https://mastodon.design/tags/therapy', + }, + { + name: 'parenthood', + url: 'https://mastodon.design/tags/parenthood', + }, + { + name: 'appdevelopment', + url: 'https://mastodon.design/tags/appdevelopment', + }, + { + name: 'tech', + url: 'https://mastodon.design/tags/tech', + }, + { + name: 'design', + url: 'https://mastodon.design/tags/design', + }, + { + name: 'introduction', + url: 'https://mastodon.design/tags/introduction', + }, + ], + emojis: [], + card: null, + poll: null, + }, ] export const statuses: MastodonStatus[] = mastodonRawStatuses.map((rawStatus) => ({ diff --git a/frontend/src/routes/(frontend)/[accountId]/index.tsx b/frontend/src/routes/(frontend)/[accountId]/index.tsx index 0264525..e941519 100644 --- a/frontend/src/routes/(frontend)/[accountId]/index.tsx +++ b/frontend/src/routes/(frontend)/[accountId]/index.tsx @@ -1,4 +1,4 @@ -import { component$, useStyles$ } from '@builder.io/qwik' +import { $, component$, useStyles$ } from '@builder.io/qwik' import { DocumentHead, loader$ } from '@builder.io/qwik-city' import { MastodonAccount } from 'wildebeest/backend/src/types' import StickyHeader from '~/components/StickyHeader/StickyHeader' @@ -9,18 +9,27 @@ import { getAccount } from 'wildebeest/backend/src/accounts/getAccount' import { getNotFoundHtml } from '~/utils/getNotFoundHtml/getNotFoundHtml' import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml' import { getDocumentHead } from '~/utils/getDocumentHead' +import type { MastodonStatus } from '~/types' +import { StatusesPanel } from '~/components/StatusesPanel/StatusesPanel' +import { parseHandle } from 'wildebeest/backend/src/utils/parse' +import { getLocalStatuses } from 'wildebeest/functions/api/v1/accounts/[id]/statuses' export const accountLoader = loader$< { DATABASE: D1Database }, - Promise<{ account: MastodonAccount; accountHandle: string }> + Promise<{ account: MastodonAccount; accountHandle: string; statuses: MastodonStatus[] }> >(async ({ platform, request, html }) => { let account: MastodonAccount | null = null + let statuses: MastodonStatus[] = [] try { const url = new URL(request.url) const domain = url.hostname const accountId = url.pathname.split('/')[1] account = await getAccount(domain, accountId, platform.DATABASE) + + const handle = parseHandle(accountId) + const response = await getLocalStatuses(request as Request, platform.DATABASE, handle) + statuses = await response.json>() } catch { throw html( 500, @@ -36,7 +45,7 @@ export const accountLoader = loader$< const accountHandle = `@${account.acct}${accountDomain ? `@${accountDomain}` : ''}` - return { account, accountHandle } + return { account, accountHandle, statuses: JSON.parse(JSON.stringify(statuses)) } }) export default component$(() => { @@ -70,38 +79,56 @@ export default component$(() => { return (
-
- {`Header - {`Avatar -
-
-

{accountDetails.account.display_name}

- {accountDetails.accountHandle} -
-
- {fields.map(({ name, value }) => ( -
-
{name}
-
-
- ))} -
-
- {stats.map(({ name, value }) => ( -
- {value} - {name} -
- ))} +
+
+ {`Header + {`Avatar
+
+

{accountDetails.account.display_name}

+ {accountDetails.accountHandle} +
+
+ {fields.map(({ name, value }) => ( +
+
{name}
+
+
+ ))} +
+
+ {stats.map(({ name, value }) => ( +
+ {value} + {name} +
+ ))} +
+
+
+ + Posts + +
+
+
+ {accountDetails.statuses.length > 0 && ( + { + // TODO-DARIO: implement this function + return [] + })} + /> + )}
) diff --git a/frontend/src/routes/(frontend)/explore/index.tsx b/frontend/src/routes/(frontend)/explore/index.tsx index 05f10cf..82bfdc3 100644 --- a/frontend/src/routes/(frontend)/explore/index.tsx +++ b/frontend/src/routes/(frontend)/explore/index.tsx @@ -1,8 +1,8 @@ -import { $, component$, useClientEffect$, useSignal } from '@builder.io/qwik' +import { $, component$ } from '@builder.io/qwik' import { DocumentHead, loader$ } from '@builder.io/qwik-city' import { RequestContext } from '@builder.io/qwik-city/middleware/request-handler' import * as timelines from 'wildebeest/functions/api/v1/timelines/public' -import Status from '~/components/Status' +import { StatusesPanel } from '~/components/StatusesPanel/StatusesPanel' import type { MastodonStatus } from '~/types' import { getDocumentHead } from '~/utils/getDocumentHead' import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml' @@ -25,67 +25,23 @@ export const statusesLoader = loader$<{ DATABASE: D1Database; domain: string }, export default component$(() => { const statuses = statusesLoader.use() - return -}) - -type StatusesPanelProps = { initialStatuses: MastodonStatus[] } - -export const StatusesPanel = component$(({ initialStatuses }: StatusesPanelProps) => { - const fetchingMoreStatuses = useSignal(false) - const noMoreStatusesAvailable = useSignal(false) - const lastStatusRef = useSignal() - const statuses = useSignal(initialStatuses) - - const fetchMoreStatuses = $(async () => { - if (fetchingMoreStatuses.value || noMoreStatusesAvailable.value) { - return - } - fetchingMoreStatuses.value = true - const response = await fetch(`/api/v1/timelines/public?offset=${statuses.value.length}`) - fetchingMoreStatuses.value = false - if (response.ok) { - const results = await response.text() - const newStatuses: MastodonStatus[] = JSON.parse(results) - noMoreStatusesAvailable.value = newStatuses.length === 0 - statuses.value = statuses.value.concat(newStatuses) - } - fetchingMoreStatuses.value = false - }) - - useClientEffect$(({ track }) => { - track(() => lastStatusRef.value) - if (lastStatusRef.value) { - const observer = new IntersectionObserver( - async ([lastStatus]) => { - if (lastStatus.isIntersecting) { - await fetchMoreStatuses() - observer.disconnect() - } - }, - { rootMargin: '250px' } - ) - observer.observe(lastStatusRef.value) - } - }) - return ( - <> - {statuses.value.length > 0 ? ( - statuses.value.map((status, i) => { - const isLastStatus = i === statuses.value.length - 1 - const divProps = isLastStatus ? { ref: lastStatusRef } : {} - return ( -
- -
- ) - }) - ) : ( -
-

Nothing to see right now. Check back later!

-
- )} - + { + let statuses: MastodonStatus[] = [] + try { + const response = await fetch(`/api/v1/timelines/public?offset=${numOfCurrentStatuses}`) + if (response.ok) { + const results = await response.text() + statuses = JSON.parse(results) + } + } catch { + /* empty */ + } + return statuses + })} + /> ) }) diff --git a/functions/api/v1/accounts/[id]/statuses.ts b/functions/api/v1/accounts/[id]/statuses.ts index 638c277..537d22c 100644 --- a/functions/api/v1/accounts/[id]/statuses.ts +++ b/functions/api/v1/accounts/[id]/statuses.ts @@ -17,6 +17,7 @@ import * as webfinger from 'wildebeest/backend/src/webfinger' import * as outbox from 'wildebeest/backend/src/activitypub/actors/outbox' import * as actors from 'wildebeest/backend/src/activitypub/actors' import { toMastodonStatusFromRow } from 'wildebeest/backend/src/mastodon/status' +import { adjustLocalHostDomain } from 'wildebeest/backend/src/utils/adjustLocalHostDomain' const headers = { ...cors(), @@ -112,9 +113,9 @@ async function getRemoteStatuses(request: Request, handle: Handle, db: D1Databas return new Response(JSON.stringify(statuses), { headers }) } -async function getLocalStatuses(request: Request, db: D1Database, handle: Handle): Promise { +export async function getLocalStatuses(request: Request, db: D1Database, handle: Handle): Promise { const domain = new URL(request.url).hostname - const actorId = actorURL(domain, handle.localPart) + const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart) const QUERY = ` SELECT objects.*, diff --git a/ui-e2e-tests/account-page.spec.ts b/ui-e2e-tests/account-page.spec.ts index 7357df1..7d30545 100644 --- a/ui-e2e-tests/account-page.spec.ts +++ b/ui-e2e-tests/account-page.spec.ts @@ -1,14 +1,41 @@ import { test, expect } from '@playwright/test' -test('Navigation to and view of an account', async ({ page }) => { +test('Navigation 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 Rosengart', exact: true }).click() await page.getByRole('link', { name: 'Ben Rosengart', exact: true }).click() await page.waitForLoadState('networkidle') await page.getByRole('link', { name: 'Ben Rosengart', exact: true }).click() - await expect(page.getByRole('img', { name: 'Header of Ben Rosengart' })).toBeVisible() - await expect(page.getByRole('img', { name: 'Avatar of Ben Rosengart' })).toBeVisible() - await expect(page.getByRole('heading', { name: 'Ben Rosengart' })).toBeVisible() - await expect(page.getByText('Joined')).toBeVisible() - await expect(page.getByTestId('stats')).toHaveText('1Posts0Following0Followers') + + await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Ben Rosengart' })).toBeVisible() + await expect(page.getByTestId('account-info').getByRole('img', { name: 'Avatar of Ben Rosengart' })).toBeVisible() + await expect(page.getByTestId('account-info').getByRole('heading', { name: 'Ben Rosengart' })).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 Rosengart' }) + ).toBeVisible() + await expect(page.getByTestId('account-statuses').getByRole('article')).toContainText('What fresh hell is this?') +}) + +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 Rafa, a designer and app" }).locator('i.fa-globe + span').click() + await page.waitForLoadState('networkidle') + await page.getByRole('link', { name: 'Rafa', exact: true }).click() + + await expect(page.getByTestId('account-info').getByRole('img', { name: 'Header of Rafa' })).toBeVisible() + await expect(page.getByTestId('account-info').getByRole('img', { name: 'Avatar of Rafa' })).toBeVisible() + await expect(page.getByTestId('account-info').getByRole('heading', { name: 'Rafa' })).toBeVisible() + await expect(page.getByTestId('account-info').getByText('Joined')).toBeVisible() + await expect(page.getByTestId('account-info').getByTestId('stats')).toHaveText('2Posts0Following0Followers') + + expect(await page.getByTestId('account-statuses').getByRole('article').count()).toBe(2) + const [post1Locator, post2Locator] = await page.getByTestId('account-statuses').getByRole('article').all() + await expect(post1Locator.getByRole('img', { name: 'Avatar of Rafa' })).toBeVisible() + await expect(post1Locator).toContainText("I'm Rafa, a designer and app developer currently living in Amsterdam") + await expect(post2Locator.getByRole('img', { name: 'Avatar of Rafa' })).toBeVisible() + await expect(post2Locator).toContainText('Hi, meet HiDock!') })