diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5e3a08d..e134233 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -23,10 +23,11 @@ export interface LayoutProps extends JSX.ElementChildrenAttribute { readonly host: string; readonly title?: string; readonly activityLink?: string | URL; + readonly feedLink?: string | URL; } export function Layout( - { bot, host, title, activityLink, children }: LayoutProps, + { bot, host, title, activityLink, feedLink, children }: LayoutProps, ) { const handle = `@${bot.username}@${host}`; const cssFilename = bot.pages.color === "azure" @@ -47,8 +48,17 @@ export function Layout( rel="alternate" type="application/activity+json" href={activityLink.toString()} + title="ActivityPub" /> )} + {feedLink && ( + + )} . /** @jsx react-jsx */ /** @jsxImportSource @hono/hono/jsx */ +import type { Context } from "@fedify/fedify/federation"; import { type Announce, type Create, + getActorHandle, Image, Link, type Object, PUBLIC_COLLECTION, } from "@fedify/fedify/vocab"; import { Hono } from "@hono/hono"; +import { unescape } from "@std/html/entities"; import type { BotImpl } from "./bot-impl.ts"; import { Layout } from "./components/Layout.tsx"; import { Message } from "./components/Message.tsx"; -import { getMessageClass, isMessageObject } from "./message-impl.ts"; +import { getMessageClass, isMessageObject, textXss } from "./message-impl.ts"; import type { MessageClass } from "./message.ts"; import type { Uuid } from "./repository.ts"; @@ -42,8 +45,6 @@ export interface Env { export const app = new Hono(); -const WINDOW = 15; - app.get("/", async (c) => { const { bot } = c.env; const ctx = bot.federation.createContext(c.req.raw, c.env.contextData); @@ -73,38 +74,24 @@ app.get("/", async (c) => { properties[name] = valueHtml; } const offset = c.req.query("offset"); - let posts = await Array.fromAsync( - bot.repository.getMessages({ - order: "newest", - until: offset ? Temporal.Instant.from(offset) : undefined, - limit: WINDOW * 2, - }), + const { posts: messages, nextPost } = await getPosts( + bot, + ctx, + offset ? Temporal.Instant.from(offset) : undefined, ); - let lastPost: Announce | Create | undefined = posts[posts.length - 1]; - posts = posts.filter(isPublic); - while (lastPost != null && posts.length < WINDOW + 1) { - const limit = (WINDOW - posts.length) * 2; - const nextPosts = bot.repository.getMessages({ - order: "newest", - until: lastPost.published ?? (await lastPost.getObject(ctx))?.published ?? - undefined, - limit, - }); - lastPost = undefined; - for await (const post of nextPosts) { - if (isPublic(post) && posts.length < WINDOW + 1) posts.push(post); - lastPost = post; - } + const activityLink = ctx.getActorUri(bot.identifier); + const feedLink = new URL("/feed.xml", url); + let nextLink: URL | undefined; + if (nextPost?.published != null) { + nextLink = new URL("/", url); + nextLink.searchParams.set("offset", nextPost.published.toString()); } - const nextPost: Object | null = await posts[WINDOW]?.getObject(ctx); - posts = posts.slice(0, WINDOW); - const messages = (await Promise.all(posts.map((p) => p.getObject(ctx)))) - .filter(isMessageObject); return c.html( {image && ( @@ -132,6 +119,27 @@ app.get("/", async (c) => { {handle} ·{" "} + + + + + + {" "} + ·{" "} {followersCount === 1 ? `1 follower` @@ -143,6 +151,7 @@ app.get("/", async (c) => { ? `1 post` : `${postsCount.toLocaleString("en")} posts`} + {" "} {summary && @@ -175,13 +184,9 @@ app.get("/", async (c) => { , { headers: { - Link: `<${ - ctx.getActorUri(bot.identifier).href - }>; rel="alternate"; type="application/activity+json"`, + Link: + `<${activityLink.href}>; rel="alternate"; type="application/activity+json", ` + + `<${feedLink.href}>; rel="alternate"; type="application/atom+xml"` + + (nextLink + ? `, <${nextLink.href}>; rel="next"; type="text/html"` + : ""), }, }, ); @@ -211,8 +219,14 @@ app.get("/message/:id", async (c) => { getMessageClass(message), { id }, ); + const feedLink = new URL("/feed.xml", url); return c.html( - + @@ -220,12 +234,154 @@ app.get("/message/:id", async (c) => { { headers: { Link: - `<${activityLink.href}>; rel="alternate"; type="application/activity+json"`, + `<${activityLink.href}>; rel="alternate"; type="application/activity+json", ` + + `<${feedLink.href}>; rel="alternate"; type="application/atom+xml"`, }, }, ); }); +app.get("/feed.xml", async (c) => { + const { bot } = c.env; + const url = new URL(c.req.url); + const ctx = bot.federation.createContext(c.req.raw, c.env.contextData); + const session = bot.getSession(ctx); + const { posts } = await getPosts(bot, ctx, undefined, 30); + const botName = bot.name ?? bot.username; + const canonicalUrl = new URL("/feed.xml", url); + const profileUrl = new URL("/", url); + const actorUrl = ctx.getActorUri(bot.identifier); + c.header( + "Link", + `<${actorUrl.href}>; rel="alternate"; type="application/activity+json", ` + + `<${profileUrl.href}>; rel="alternate"; type="text/html"`, + ); + const response = await c.render( + + {canonicalUrl.href} + + + + {botName} (@{bot.username}@{url.host}) + + {botName} + {profileUrl.href} + + {posts.length > 0 && ( + + {(posts[0].updated ?? posts[0].published)?.toString()} + + )} + {posts.map(async (post) => { + const activityUrl = post.id; + if (activityUrl == null) return undefined; + const permalink = + (post.url instanceof Link ? post.url.href : post.url) ?? activityUrl; + const author = post.attributionId?.href === session.actorId?.href + ? await session.getActor() + : await post.getAttribution({ + documentLoader: ctx.documentLoader, + contextLoader: ctx.contextLoader, + suppressError: true, + }); + const authorName = author?.name ?? author?.preferredUsername ?? + (author == null ? undefined : await getActorHandle(author)); + const authorUrl = + (author?.url instanceof Link ? author.url.href : author?.url) ?? + author?.id; + const updated = post.updated ?? post.published; + let title = post.name; + if (title == null) { + title = post.summary ?? post.content; + if (title != null) { + title = unescape(textXss.process(title.toString())); + } + } + return ( + + {permalink.href} + + + {authorName && + ( + + {authorName} + {authorUrl && + {authorUrl.href}} + + )} + {post.published && ( + {post.published.toString()} + )} + {updated && {updated.toString()}} + {title && {title}} + {post.summary && ( + {post.summary.toString()} + )} + {post.content && ( + {post.content.toString()} + )} + + ); + })} + , + ); + response.headers.set("Content-Type", "application/atom+xml; charset=utf-8"); + return response; +}); + +async function getPosts( + bot: BotImpl, + ctx: Context, + offset?: Temporal.Instant, + window = 15, +): Promise<{ posts: MessageClass[]; nextPost?: Object }> { + let posts = await Array.fromAsync( + bot.repository.getMessages({ + order: "newest", + until: offset, + limit: window * 2, + }), + ); + let lastPost: Announce | Create | undefined = posts[posts.length - 1]; + posts = posts.slice(0, posts.length - 1); + posts = posts.filter(isPublic); + while (lastPost != null && posts.length < window) { + const limit = (window - posts.length) * 2; + const until = lastPost.published ?? + (await lastPost.getObject(ctx))?.published ?? + undefined; + if (until == null) break; + const nextPosts = bot.repository.getMessages({ + order: "newest", + until, + limit, + }); + let i = 0; + lastPost = undefined; + for await (const post of nextPosts) { + if (isPublic(post) && posts.length < window + 1) posts.push(post); + lastPost = post; + i++; + } + if (i < limit) break; + } + const nextPost: Object | undefined = await posts[window]?.getObject(ctx) ?? + undefined; + posts = posts.slice(0, window); + const messages = (await Promise.all(posts.map((p) => p.getObject(ctx)))) + .filter(isMessageObject); + return { posts: messages, nextPost }; +} + function isPublic(post: Create | Announce): boolean { return post.toIds.some((url) => url.href === PUBLIC_COLLECTION.href) || post.ccIds.some((url) => url.href === PUBLIC_COLLECTION.href);
{handle} ·{" "} + + + + + + {" "} + ·{" "} {followersCount === 1 ? `1 follower` @@ -143,6 +151,7 @@ app.get("/", async (c) => { ? `1 post` : `${postsCount.toLocaleString("en")} posts`} + {" "}