Skip to content
63 changes: 63 additions & 0 deletions packages/botkit/src/components/FollowButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/** @jsxImportSource hono/jsx */
import type { BotImpl } from "../bot-impl.ts";

export interface FollowButtonProps {
readonly bot: BotImpl<unknown>;
}

export function FollowButton({ bot }: FollowButtonProps) {
return (
<>
<button
id="follow-btn"
type="button"
style="padding: 0.5rem 1rem; background: var(--pico-primary); color: var(--pico-primary-inverse); border: none; border-radius: 0.25rem; cursor: pointer;"
onclick="showFollowModal()"
>
Follow
</button>
<dialog id="follow-modal">
<article style="width: 400px;">
<header style="display: flex; align-items: center; justify-content:space-between">
<h3>Follow {bot.name ?? bot.username}</h3>
<button
aria-label="Close"
rel="prev"
type="button"
onclick="closeFollowModal()"
/>
</header>
<main>
<p>Enter your fediverse handle to follow this account:</p>
<form action="/follow" method="post">
<input
type="text"
id="fediverse-handle"
name="handle"
placeholder="@[email protected]"
required
style="width: 100%; margin-bottom: 1rem;"
/>
<button type="submit" style="width: 100%;">
Follow
</button>
</form>
</main>
</article>
</dialog>
<script
dangerouslySetInnerHTML={{
__html: `
function showFollowModal() {
document.getElementById('follow-modal').showModal();
}
function closeFollowModal() {
document.getElementById('follow-modal').close();
}
`,
}}
/>
</>
);
}
70 changes: 67 additions & 3 deletions packages/botkit/src/pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ import {
import { Hono } from "hono";
import { decode } from "html-entities";
import type { BotImpl } from "./bot-impl.ts";
import { FollowButton } from "./components/FollowButton.tsx";
import { Follower } from "./components/Follower.tsx";
import { Layout } from "./components/Layout.tsx";
import { Message } from "./components/Message.tsx";
import { Follower } from "./components/Follower.tsx";
import { getMessageClass, isMessageObject, textXss } from "./message-impl.ts";
import type { MessageClass } from "./message.ts";
import type { Uuid } from "./repository.ts";
Expand Down Expand Up @@ -154,8 +155,8 @@ app.get("/", async (c) => {
{postsCount === 1
? `1 post`
: `${postsCount.toLocaleString("en")} posts`}
</span>
{" "}
</span>{" "}
&middot; <FollowButton bot={bot} />
</p>
</hgroup>
{summary &&
Expand Down Expand Up @@ -431,6 +432,69 @@ app.get("/feed.xml", async (c) => {
return response;
});

app.post("/follow", async (c) => {
const { bot } = c.env;
const ctx = bot.federation.createContext(c.req.raw, c.env.contextData);
const url = new URL(c.req.url);
const botHandle = `@${bot.username}@${url.host}`;

const formData = await c.req.formData();
let followerHandle = formData.get("handle")?.toString();

try {
if (!followerHandle) {
throw new Error("No followerHandle!");
}

if (followerHandle.startsWith("@")) {
followerHandle = followerHandle.slice(1);
}

const webfingerData = await ctx
.lookupWebFinger(`acct:${followerHandle}`);

if (!webfingerData?.links) {
return c.json({ error: "No links found in webfinger data" }, 400);
}

console.log(webfingerData);

const subscribeLink = webfingerData.links.find(
(link) => link.rel === "http://ostatus.org/schema/1.0/subscribe",
) as { template?: string } | undefined;

if (subscribeLink?.template) {
const botActorUri = ctx.getActorUri(bot.identifier);
const followUrl = subscribeLink.template.replace(
"{uri}",
encodeURIComponent(botActorUri.href),
);
return c.redirect(followUrl);
}

const profileLink = webfingerData.links.find(
(link) => link.rel === "http://webfinger.net/rel/profile-page",
) as { href?: string } | undefined;

if (profileLink?.href) {
const followerUsername = followerHandle.split("@")[0];
const followUrl = profileLink.href.replace(
`@${followerUsername}`,
botHandle,
);
return c.redirect(followUrl);
}

return c.json({
error: "No follow link found in webfinger data",
data: webfingerData,
}, 400);
} catch (error) {
console.error("Follow request error:", error);
return c.json({ error: error }, 400);
}
});

interface GetPostsOptions {
readonly hashtag?: string;
readonly offset?: Temporal.Instant;
Expand Down
Loading