kopia lustrzana https://github.com/cloudflare/wildebeest
commit
9256d39647
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"cSpell.words": [
|
||||
"activitypub",
|
||||
"cdate",
|
||||
"favourited",
|
||||
"favourites",
|
||||
"reblog",
|
||||
"reblogged",
|
||||
"reblogger",
|
||||
"reblogs"
|
||||
]
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
boosted
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!')
|
||||
})
|
||||
})
|
||||
|
|
Ładowanie…
Reference in New Issue