show account posts in account page

pull/273/head
Dario Piotrowicz 2023-02-13 12:33:24 +00:00
rodzic e88a55a7e0
commit 37bddfbe19
9 zmienionych plików z 300 dodań i 118 usunięć

Wyświetl plik

@ -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<MastodonAccount | null> {
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')
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<MastodonStatus[]>>
}
export const StatusesPanel = component$(({ initialStatuses, fetchMoreStatuses: fetchMoreStatusesFn }: Props) => {
const fetchingMoreStatuses = useSignal(false)
const noMoreStatusesAvailable = useSignal(false)
const lastStatusRef = useSignal<HTMLDivElement>()
const statuses = useSignal<MastodonStatus[]>(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 (
<div key={status.id} {...divProps}>
<Status status={status} />
</div>
)
})
) : (
<div class="flex-1 grid place-items-center bg-wildebeest-600 text-center">
<p>Nothing to see right now. Check back later!</p>
</div>
)}
</>
)
})

Wyświetl plik

@ -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:
'<p>Time for an updated <a href="https://mastodon.design/tags/Introduction" class="mention hashtag" rel="tag">#<span>Introduction</span></a>:</p><p>I&#39;m Rafa, a designer and app developer currently living in Amsterdam ☀️</p><p>I make Hand Mirror for macOS, Booby Track for iOS/watchOS, and co-host a design podcast with my friend <span class="h-card"><a href="https://mastodon.design/@kevin" class="u-url mention">@<span>kevin</span></a></span> called Layout.</p><p>Been loving the sense of community and the good vibes you&#39;re all giving in this platform, say hi 👋</p><p>Here&#39;s some topics I feel like I like to participate in, I hear hashtags can help with discovery so here it goes: <a href="https://mastodon.design/tags/Design" class="mention hashtag" rel="tag">#<span>Design</span></a> <a href="https://mastodon.design/tags/Tech" class="mention hashtag" rel="tag">#<span>Tech</span></a> <a href="https://mastodon.design/tags/AppDevelopment" class="mention hashtag" rel="tag">#<span>AppDevelopment</span></a> <a href="https://mastodon.design/tags/Parenthood" class="mention hashtag" rel="tag">#<span>Parenthood</span></a> <a href="https://mastodon.design/tags/Therapy" class="mention hashtag" rel="tag">#<span>Therapy</span></a></p>',
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: '<p>Im a designer and app developer, currently working on Sketch, and Hand Mirror for Mac</p>',
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:
'<a href="https://rafa.design" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://</span><span class="">rafa.design</span><span class="invisible"></span></a>',
verified_at: '2022-11-06T16:49:24.339+00:00',
},
{
name: 'App',
value:
'<a href="http://handmirror.app" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">http://</span><span class="">handmirror.app</span><span class="invisible"></span></a>',
verified_at: null,
},
{
name: 'Podcast',
value:
'<a href="http://layout.fm" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">http://</span><span class="">layout.fm</span><span class="invisible"></span></a>',
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) => ({

Wyświetl plik

@ -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<Array<MastodonStatus>>()
} 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 (
<div>
<StickyHeader withBackButton />
<div class="relative mb-16">
<img
src={accountDetails.account.header}
alt={`Header of ${accountDetails.account.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.account.avatar}
alt={`Avatar of ${accountDetails.account.display_name}`}
/>
</div>
<div class="px-5">
<h2 class="font-bold">{accountDetails.account.display_name}</h2>
<span class="block my-1 text-wildebeest-400">{accountDetails.accountHandle}</span>
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.account.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 data-testid="stats" 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 data-testid="account-info">
<div class="relative mb-16">
<img
src={accountDetails.account.header}
alt={`Header of ${accountDetails.account.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.account.avatar}
alt={`Avatar of ${accountDetails.account.display_name}`}
/>
</div>
<div class="px-5">
<h2 class="font-bold">{accountDetails.account.display_name}</h2>
<span class="block my-1 text-wildebeest-400">{accountDetails.accountHandle}</span>
<div class="inner-html-content my-5" dangerouslySetInnerHTML={accountDetails.account.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 data-testid="stats" 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 class="bg-wildebeest-800 flex justify-around mt-6">
<span class="my-3 text-wildebeest-200">
<span>Posts</span>
</span>
</div>
</div>
<div data-testid="account-statuses">
{accountDetails.statuses.length > 0 && (
<StatusesPanel
initialStatuses={accountDetails.statuses}
fetchMoreStatuses={$(async () => {
// TODO-DARIO: implement this function
return []
})}
/>
)}
</div>
</div>
)

Wyświetl plik

@ -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 <StatusesPanel initialStatuses={statuses.value} />
})
type StatusesPanelProps = { initialStatuses: MastodonStatus[] }
export const StatusesPanel = component$(({ initialStatuses }: StatusesPanelProps) => {
const fetchingMoreStatuses = useSignal(false)
const noMoreStatusesAvailable = useSignal(false)
const lastStatusRef = useSignal<HTMLDivElement>()
const statuses = useSignal<MastodonStatus[]>(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 (
<div key={status.id} {...divProps}>
<Status status={status} />
</div>
)
})
) : (
<div class="flex-1 grid place-items-center bg-wildebeest-600 text-center">
<p>Nothing to see right now. Check back later!</p>
</div>
)}
</>
<StatusesPanel
initialStatuses={statuses.value}
fetchMoreStatuses={$(async (numOfCurrentStatuses: number) => {
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
})}
/>
)
})

Wyświetl plik

@ -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<Response> {
export async function getLocalStatuses(request: Request, db: D1Database, handle: Handle): Promise<Response> {
const domain = new URL(request.url).hostname
const actorId = actorURL(domain, handle.localPart)
const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart)
const QUERY = `
SELECT objects.*,

Wyświetl plik

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