Merge pull request #83 from cloudflare/reblogs

Add reblogs (boosts) to the frontend UI
pull/89/head
Sven Sauleau 2023-01-12 11:39:01 +01:00 zatwierdzone przez GitHub
commit 9256d39647
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
11 zmienionych plików z 130 dodań i 3794 usunięć

12
.vscode/settings.json vendored 100644
Wyświetl plik

@ -0,0 +1,12 @@
{
"cSpell.words": [
"activitypub",
"cdate",
"favourited",
"favourites",
"reblog",
"reblogged",
"reblogger",
"reblogs"
]
}

Wyświetl plik

@ -38,7 +38,11 @@ export interface Actor extends Object {
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person
export interface Person extends Actor {
publicKey: string
publicKey: {
id: string
owner: URL
publicKeyPem: string
}
}
export async function get(url: string | URL): Promise<Actor> {
@ -111,14 +115,20 @@ export async function getPersonByEmail(db: D1Database, email: string): Promise<P
return personFromRow(row)
}
type Properties = { [key: string]: Properties | string }
type PersonProperties = {
name?: string
summary?: string
icon?: { url: string }
image?: { url: string }
preferredUsername?: string
}
export async function createPerson(
domain: string,
db: D1Database,
userKEK: string,
email: string,
properties: Properties = {}
properties: PersonProperties = {}
): Promise<Person> {
const userKeyPair = await generateUserKey(userKEK)
@ -181,19 +191,23 @@ export async function getPersonById(db: D1Database, id: URL): Promise<Person | n
}
export function personFromRow(row: any): Person {
const icon: Object = {
const properties = JSON.parse(row.properties) as PersonProperties
const icon = properties.icon ?? {
type: 'Image',
mediaType: 'image/jpeg',
url: new URL(defaultImages.avatar),
id: new URL(row.id + '#icon'),
}
const image: Object = {
const image = properties.image ?? {
type: 'Image',
mediaType: 'image/jpeg',
url: new URL(defaultImages.header),
id: new URL(row.id + '#image'),
}
const preferredUsername = properties.preferredUsername
const name = properties.name ?? preferredUsername
let publicKey = null
if (row.pubkey !== null) {
publicKey = {
@ -214,9 +228,11 @@ export function personFromRow(row: any): Person {
// Hidden values
[emailSymbol]: row.email,
name: row.preferredUsername,
...properties,
name,
icon,
image,
preferredUsername,
discoverable: true,
publicKey,
type: PERSON,
@ -227,10 +243,6 @@ export function personFromRow(row: any): Person {
following: followingURL(row.id),
followers: followersURL(row.id),
url: new URL('@' + row.preferredUsername, 'https://' + domain),
// It's very possible that properties override the values set above.
// Almost guaranteed for remote user.
...JSON.parse(row.properties),
url: new URL('@' + preferredUsername, 'https://' + domain),
} as Person
}

Wyświetl plik

@ -6,15 +6,8 @@ import * as apOutbox from 'wildebeest/backend/src/activitypub/actors/outbox'
import * as apFollow from 'wildebeest/backend/src/activitypub/actors/follow'
function toMastodonAccount(acct: string, res: Actor): MastodonAccount {
let avatar = defaultImages.avatar
let header = defaultImages.header
if (res.icon !== undefined && typeof res.icon.url === 'string') {
avatar = res.icon.url
}
if (res.image !== undefined && typeof res.image.url === 'string') {
header = res.image.url
}
const avatar = res.icon?.url.toString() ?? defaultImages.avatar
const header = res.image?.url.toString() ?? defaultImages.header
return {
acct,

Wyświetl plik

@ -88,7 +88,7 @@ export async function toMastodonStatusFromObject(db: D1Database, obj: Note): Pro
}
// toMastodonStatusFromRow makes assumption about what field are available on
// the `row` object. This funciton is only used for timelines, which is optimized
// the `row` object. This function is only used for timelines, which is optimized
// SQL. Otherwise don't use this function.
export async function toMastodonStatusFromRow(
domain: string,

Wyświetl plik

@ -1,18 +1,26 @@
import { createPerson, getPersonByEmail, type Person } from 'wildebeest/backend/src/activitypub/actors'
import * as statusesAPI from 'wildebeest/functions/api/v1/statuses'
import * as reblogAPI from 'wildebeest/functions/api/v1/statuses/[id]/reblog'
import { statuses } from 'wildebeest/frontend/src/dummyData'
import type { MastodonStatus } from 'wildebeest/frontend/src/types'
import type { MastodonAccount } from 'wildebeest/backend/src/types'
import type { Account, MastodonStatus } from 'wildebeest/frontend/src/types'
const kek = 'test-kek'
/**
* Run helper commands to initialize the database with actors, statuses, etc.
*/
export async function init(domain: string, db: D1Database) {
for (const status of statuses as MastodonStatus[]) {
const actor = await getOrCreatePerson(domain, db, status.account.username)
await createStatus(db, actor, status.content)
const loadedStatuses: MastodonStatus[] = []
for (const status of statuses) {
const actor = await getOrCreatePerson(domain, db, status.account)
loadedStatuses.push(await createStatus(db, actor, status.content))
}
// Grab the account from an arbitrary status to use as the reblogger
const rebloggerAccount = loadedStatuses[1].account
const reblogger = await getOrCreatePerson(domain, db, rebloggerAccount)
// Reblog an arbitrary status with this reblogger
const statusToReblog = loadedStatuses[2]
await reblogStatus(db, reblogger, statusToReblog)
}
/**
@ -31,15 +39,27 @@ async function createStatus(db: D1Database, actor: Person, status: string, visib
headers,
body: JSON.stringify(body),
})
await statusesAPI.handleRequest(req, db, actor, kek)
const resp = await statusesAPI.handleRequest(req, db, actor, kek)
return (await resp.json()) as MastodonStatus
}
async function getOrCreatePerson(domain: string, db: D1Database, username: string): Promise<Person> {
async function getOrCreatePerson(
domain: string,
db: D1Database,
{ username, avatar, display_name }: Account
): Promise<Person> {
const person = await getPersonByEmail(db, username)
if (person) return person
const newPerson = await createPerson(domain, db, kek, username)
const newPerson = await createPerson(domain, db, kek, username, {
icon: { url: avatar },
name: display_name,
})
if (!newPerson) {
throw new Error('Could not create Actor ' + username)
}
return newPerson
}
async function reblogStatus(db: D1Database, actor: Person, status: MastodonStatus) {
await reblogAPI.handleRequest(db, status.id, actor, kek)
}

Wyświetl plik

@ -1,10 +1,10 @@
import { component$, $, useStyles$ } from '@builder.io/qwik'
import { Link, useNavigate } from '@builder.io/qwik-city'
import { formatTimeAgo } from '~/utils/dateTime'
import { MastodonStatus } from '~/types'
import styles from './index.scss?inline'
import { Avatar } from '../avatar'
import Image from './ImageGallery'
import type { Account, MastodonStatus } from '~/types'
import styles from './index.scss?inline'
type Props = {
status: MastodonStatus
@ -14,7 +14,8 @@ export default component$((props: Props) => {
useStyles$(styles)
const nav = useNavigate()
const status = props.status
const status = props.status.reblog ?? props.status
const reblogger = props.status.reblog && props.status.account
const accountUrl = `/@${status.account.username}`
const statusUrl = `${accountUrl}/${status.id}`
@ -23,14 +24,16 @@ export default component$((props: Props) => {
return (
<div class="p-4 border-t border-wildebeest-700 pointer">
<RebloggerLink account={reblogger}></RebloggerLink>
<div onClick$={handleContentClick}>
<div class="flex justify-between mb-3">
<div class="flex">
<Avatar accountDisplayName={status.account.display_name} src={status.account.avatar} />
<Avatar primary={status.account} secondary={reblogger} />
<div class="flex-col ml-3">
<div>
{/* TODO: this should either have an href or not being an `a` element (also consider using QwikCity's `Link` instead) */}
<a class="no-underline">{status.account.display_name}</a>
<a class="no-underline" href={status.account.url}>
{status.account.display_name}
</a>
</div>
<div class="text-wildebeest-500">@{status.account.username}</div>
</div>
@ -61,3 +64,19 @@ export default component$((props: Props) => {
</div>
)
})
export const RebloggerLink = ({ account }: { account: Account | null }) => {
return (
account && (
<div class="flex text-wildebeest-500 py-3">
<p>
<i class="fa fa-retweet mr-3" />
<a class="no-underline" href={account.url}>
{account.display_name}
</a>
&nbsp;boosted
</p>
</div>
)
)
}

Wyświetl plik

@ -1,10 +1,26 @@
import { component$ } from '@builder.io/qwik'
import type { Account } from '~/types'
type Props = {
src: string
accountDisplayName: string
primary: Account
secondary: Account | null
}
export const Avatar = component$<Props>(({ src, accountDisplayName }) => {
return <img class="rounded h-12 w-12" src={src} alt={`Avatar of ${accountDisplayName}`} />
export const Avatar = component$<Props>(({ primary, secondary }) => {
return (
<div class={`relative ${secondary && 'pr-2 pb-2'}`}>
<a href={primary.url}>
<img class="rounded h-12 w-12" src={primary.avatar} alt={`Avatar of ${primary.display_name}`} />
</a>
{secondary && (
<a href={secondary.url}>
<img
class="absolute right-0 bottom-0 rounded h-6 w-6"
src={secondary.avatar}
alt={`Avatar of ${secondary.display_name}`}
/>
</a>
)}
</div>
)
})

Plik diff jest za duży Load Diff

Wyświetl plik

@ -36,11 +36,12 @@ export default component$(() => {
<div class="bg-wildebeest-700 p-4">
{/* Account Card */}
<div class="flex">
<Avatar accountDisplayName={status.account.display_name} src={status.account.avatar} />
<Avatar primary={status.account} secondary={null} />
<div class="flex flex-col">
<div class="p-1">
{/* TODO: this should either have an href or not being an `a` element (also consider using QwikCity's `Link` instead) */}
<a class="no-underline">{status.account.display_name}</a>
<a class="no-underline" href={status.account.url}>
{status.account.display_name}
</a>
</div>
<div class="p-1 text-wildebeest-400">@{status.account.acct}</div>
</div>

Wyświetl plik

@ -12,15 +12,21 @@ export type MastodonStatus = {
sensitive: boolean
spoiler_text: string
visibility: 'public' | 'private' | 'unlisted'
language: string
language: string | null
uri: string
url: string
replies_count: number
reblogs_count: number
favourites_count: number
edited_at: string | null
reblog: Reblog | null
application?: Application
favourited?: boolean
reblogged?: boolean
muted?: boolean
bookmarked?: boolean
pinned?: boolean
filtered?: FilterResult[]
reblog: MastodonStatus | null
application?: Application | null
media_attachments: MediaAttachment[]
mentions: Mention[]
tags: Tag[]
@ -37,6 +43,7 @@ export type MastodonStatus = {
type Emoji = any
type Card = any
type Poll = any
type FilterResult = any
/* eslint-enable @typescript-eslint/no-explicit-any */
export type Account = {
@ -87,16 +94,6 @@ export type Application = {
website: string | null
}
export type Reblog = {
id: string
created_at: string
favourited: false
reblogged: true
muted: false
bookmarked: false
pinned: false
}
export type MediaAttachment = {
id: string
type: string

Wyświetl plik

@ -5,14 +5,14 @@ describe('Posts timeline page', () => {
const response = await fetch('http://0.0.0.0:6868/')
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain("I'll be House Speaker")
expect(body).toContain('We did it!')
})
it('should display a list of statuses for the explore page', async () => {
const response = await fetch('http://0.0.0.0:6868/explore/')
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain("I'll be House Speaker")
expect(body).toContain('We did it!')
})
})
@ -21,7 +21,7 @@ describe('Toot details', () => {
// Find a specific toot in the list
const exploreResponse = await fetch('http://0.0.0.0:6868/explore/')
const exploreBody = await exploreResponse.text()
const match = exploreBody.match(/href="\/(@georgetakei\/[0-9a-z-]*)"/)
const match = exploreBody.match(/href="\/(@BethanyBlack\/[0-9a-z-]*)"/)
// Fetch the page for it and validate the result
const tootPath = match?.[1]
@ -29,6 +29,6 @@ describe('Toot details', () => {
const response = await fetch(`http://0.0.0.0:6868/${tootPath}`)
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain("I'll be House Speaker")
expect(body).toContain('We did it!')
})
})