Skip to content

Commit

Permalink
Web pages
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Jan 26, 2025
1 parent 6ae6474 commit dd8d9c9
Show file tree
Hide file tree
Showing 33 changed files with 605 additions and 3 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"multikey",
"nodeinfo",
"phensley",
"pico",
"Pixelfed",
"Tailscale",
"unfollow",
Expand Down
3 changes: 3 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@
"@fedify/fedify": "jsr:@fedify/fedify@^1.3.4",
"@fedify/markdown-it-mention": "jsr:@fedify/markdown-it-mention@^0.2.0",
"@hongminhee/x-forwarded-fetch": "jsr:@hongminhee/x-forwarded-fetch@^0.2.0",
"@hono/hono": "jsr:@hono/hono@^4.6.18",
"@logtape/logtape": "jsr:@logtape/logtape@^0.8.0",
"@phensley/language-tag": "npm:@phensley/language-tag@^1.9.2",
"@std/assert": "jsr:@std/assert@^1.0.10",
"@std/html": "jsr:@std/html@^1.0.3",
"@std/path": "jsr:@std/path@^1.0.8",
"@std/uuid": "jsr:@std/uuid@^1.0.4",
"markdown-it": "npm:markdown-it@^14.1.0",
"xss": "npm:xss@^1.0.15"
Expand All @@ -35,6 +37,7 @@
],
"fmt": {
"exclude": [
"src/css/*.css",
"*.md"
]
},
Expand Down
38 changes: 38 additions & 0 deletions docs/concepts/bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,44 @@ Turned off by default.
[ngrok]: https://ngrok.com/
[Tailscale Funnel]: https://tailscale.com/kb/1223/funnel

### `~CreateBotOptions.pages`

The options for the web pages of the bot.

`~PageOptions.color`
: The color of the theme. It will be used for the theme color of the web
pages. The default color is `"green"`.

Here's the list of available colors:

- `"amber"`
- `"azure"`
- `"blue"`
- `"cyan"`
- `"fuchsia"`
- `"green"` (default)
- `"grey"`
- `"indigo"`
- `"jade"`
- `"lime"`
- `"orange"`
- `"pink"`
- `"pumpkin"`
- `"purple"`
- `"red"`
- `"sand"`
- `"slate"`
- `"violet"`
- `"yellow"`
- `"zinc"`

See also the [*Colors* section] of the Pico CSS docs.

`~PageOptions.css`
: The custom CSS to be injected into the web pages. It should be a string
of CSS code.

[*Colors* section]: https://picocss.com/docs/colors

Running the bot
---------------
Expand Down
10 changes: 9 additions & 1 deletion examples/greet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createBot, Image, mention, text } from "@fedify/botkit";
import { createBot, Image, link, mention, text } from "@fedify/botkit";
import { DenoKvMessageQueue, DenoKvStore } from "@fedify/fedify/x/denokv";

const kv = await Deno.openKv();
Expand All @@ -12,9 +12,17 @@ const bot = createBot<void>({
icon: new URL(
"https://repository-images.githubusercontent.com/913141583/852a1091-14d5-46a0-b3bf-8d2f45ef6e7f",
),
properties: {
"Source code": link(
"examples/greet.ts",
"https://github.com/dahlia/botkit/blob/main/examples/greet.ts",
),
"Powered by": link("BotKit", "https://botkit.fedify.dev/"),
},
kv: new DenoKvStore(kv),
queue: new DenoKvMessageQueue(kv),
behindProxy: true,
pages: { color: "green" },
});

bot.onFollow = async (session, followRequest) => {
Expand Down
20 changes: 18 additions & 2 deletions src/bot-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
} from "@fedify/fedify/vocab";
import { getXForwardedRequest } from "@hongminhee/x-forwarded-fetch";
import metadata from "../deno.json" with { type: "json" };
import type { Bot, CreateBotOptions } from "./bot.ts";
import type { Bot, CreateBotOptions, PagesOptions } from "./bot.ts";
import type {
AcceptEventHandler,
FollowEventHandler,
Expand All @@ -65,6 +65,7 @@ import type {
import { FollowRequestImpl } from "./follow-impl.ts";
import { createMessage, messageClasses } from "./message-impl.ts";
import type { Message, MessageClass } from "./message.ts";
import { app } from "./pages.tsx";
import { KvRepository, type Repository, type Uuid } from "./repository.ts";
import { SessionImpl } from "./session-impl.ts";
import type { Session } from "./session.ts";
Expand All @@ -90,6 +91,7 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
readonly repository: Repository;
readonly software?: Software;
readonly behindProxy: boolean;
readonly pages: Required<PagesOptions>;
readonly collectionWindow: number;
readonly federation: Federation<TContextData>;

Expand All @@ -115,6 +117,11 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
this.followerPolicy = options.followerPolicy ?? "accept";
this.repository = options.repository ?? new KvRepository(options.kv);
this.software = options.software;
this.pages = {
color: "green",
css: "",
...(options.pages ?? {}),
};
this.federation = createFederation<TContextData>({
kv: options.kv,
queue: options.queue,
Expand Down Expand Up @@ -280,6 +287,7 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
outbox: ctx.getOutboxUri(identifier),
publicKey: keyPairs[0].cryptographicKey,
assertionMethods: keyPairs.map((pair) => pair.multikey),
url: new URL("/", ctx.origin),
});
}

Expand Down Expand Up @@ -659,6 +667,14 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
if (this.behindProxy) {
request = await getXForwardedRequest(request);
}
return await this.federation.fetch(request, { contextData });
const url = new URL(request.url);
if (
url.pathname.startsWith("/.well-known/") ||
url.pathname.startsWith("/ap/") ||
url.pathname.startsWith("/nodeinfo/")
) {
return await this.federation.fetch(request, { contextData });
}
return await app.fetch(request, { bot: this, contextData });
}
}
4 changes: 4 additions & 0 deletions src/bot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ Deno.test("createBot()", async () => {
rel: "self",
type: "application/activity+json",
},
{
href: "https://example.com/",
rel: "http://webfinger.net/rel/profile-page",
},
],
subject: "acct:[email protected]",
});
Expand Down
44 changes: 44 additions & 0 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,50 @@ export interface CreateBotOptions<TContextData> {
* @default `false`
*/
readonly behindProxy?: boolean;

/**
* The options for the web pages of the bot. If omitted, the default options
* will be used.
*/
readonly pages?: PagesOptions;
}

/**
* Options for the web pages of the bot.
*/
export interface PagesOptions {
/**
* The color of the theme. It will be used for the theme color of the web
* pages. The default color is `"green"`.
* @default `"green"`
*/
readonly color?:
| "amber"
| "azure"
| "blue"
| "cyan"
| "fuchsia"
| "green"
| "grey"
| "indigo"
| "jade"
| "lime"
| "orange"
| "pink"
| "pumpkin"
| "purple"
| "red"
| "sand"
| "slate"
| "violet"
| "yellow"
| "zinc";

/**
* The CSS code for the bot. It will be used for the custom CSS of the web
* pages.
*/
readonly css?: string;
}

/**
Expand Down
57 changes: 57 additions & 0 deletions src/components/Layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// BotKit by Fedify: A framework for creating ActivityPub bots
// Copyright (C) 2025 Hong Minhee <https://hongminhee.org/>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
/** @jsx react-jsx */
/** @jsxImportSource @hono/hono/jsx */
import type { JSX } from "@hono/hono/jsx/jsx-runtime";
import type { BotImpl } from "../bot-impl.ts";

export interface LayoutProps extends JSX.ElementChildrenAttribute {
readonly bot: BotImpl<unknown>;
readonly host: string;
readonly title?: string;
readonly activityLink?: string | URL;
}

export function Layout(
{ bot, host, title, activityLink, children }: LayoutProps,
) {
const handle = `@${bot.username}@${host}`;
return (
<html>
<head>
<meta charset="utf-8" />
<title>
{title == null
? bot.name == null ? handle : `${bot.name} (${handle})`
: title}
</title>
{activityLink &&
(
<link
rel="alternate"
type="application/activity+json"
href={activityLink.toString()}
/>
)}
<link rel="stylesheet" href={`/css/pico.${bot.pages.color}.min.css`} />
<style>{bot.pages.css}</style>
</head>
<body>
{children}
</body>
</html>
);
}
92 changes: 92 additions & 0 deletions src/components/Message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// BotKit by Fedify: A framework for creating ActivityPub bots
// Copyright (C) 2025 Hong Minhee <https://hongminhee.org/>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
/** @jsx react-jsx */
/** @jsxImportSource @hono/hono/jsx */
import type { Context } from "@fedify/fedify/federation";
import { LanguageString } from "@fedify/fedify/runtime";
import { getActorHandle, Link } from "@fedify/fedify/vocab";
import type { MessageClass } from "../message.ts";

export interface MessageProps {
readonly message: MessageClass;
readonly context: Context<unknown>;
}

export async function Message({ context, message }: MessageProps) {
const author = await message.getAttribution({
documentLoader: context.documentLoader,
contextLoader: context.contextLoader,
suppressError: true,
});
const authorIcon = await author?.getIcon({
documentLoader: context.documentLoader,
contextLoader: context.contextLoader,
suppressError: true,
});
const authorHandle = author == null ? null : await getActorHandle(author);
return (
<article>
<header>
{author?.id
? (
<hgroup>
{authorIcon?.url && (
<img
src={authorIcon.url instanceof Link
? authorIcon.url.href?.href
: authorIcon.url.href}
width={authorIcon.width ?? undefined}
height={authorIcon.height ?? undefined}
alt={authorIcon.name?.toString() ?? undefined}
style="float: left; margin-right: 1em; height: 64px;"
/>
)}
<h3>
<a href={author.url?.href?.toString() ?? author.id.href}>
{author.name}
</a>
</h3>{" "}
<p>
<span style="user-select: all;">{authorHandle}</span>
</p>
</hgroup>
)
: <em>(Deleted account)</em>}
</header>
<div
dangerouslySetInnerHTML={{ __html: `${message.content}` }}
lang={message.content instanceof LanguageString
? message.content.language.compact()
: undefined}
/>
<footer>
{message.published &&
(
<a href={message.url?.href?.toString() ?? message.id?.href}>
<small>
<time dateTime={message.published.toString()}>
{message.published.toLocaleString("en", {
dateStyle: "full",
timeStyle: "short",
})}
</time>
</small>
</a>
)}
</footer>
</article>
);
}
4 changes: 4 additions & 0 deletions src/css/pico.amber.min.css

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/css/pico.azure.min.css
4 changes: 4 additions & 0 deletions src/css/pico.blue.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.cyan.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.fuchsia.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.green.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.grey.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.indigo.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.jade.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.lime.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.orange.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.pink.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.pumpkin.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.purple.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.red.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.sand.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.slate.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.violet.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.yellow.min.css

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/css/pico.zinc.min.css

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions src/message-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ export function isMessageObject(value: unknown): value is MessageClass {
return messageClasses.some((cls) => value instanceof cls);
}

export function getMessageClass(
value: MessageClass,
): (typeof Article | typeof ChatMessage | typeof Note | typeof Question) & {
typeId: URL;
} {
return value instanceof Article
? Article
: value instanceof ChatMessage
? ChatMessage
: value instanceof Note
? Note
: Question;
}

export class MessageImpl<T extends MessageClass, TContextData>
implements Message<T, TContextData> {
readonly session: SessionImpl<TContextData>;
Expand Down
Loading

0 comments on commit dd8d9c9

Please sign in to comment.