refactor(components): move from genHTML to hono jsx
refactor(post/image): parse and validate embed data from Bluesky to sort which image to show feat(post/description): add quote replies feat(post/details): add post details (likes, reskeets, comments) feat(profiles): add profile embed fix: added domain key to env vars so that oembed works locally and in self-hosted instances
This commit is contained in:
30
src/components/Layout.tsx
Normal file
30
src/components/Layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { html } from "hono/html";
|
||||
|
||||
export interface LayoutProps {
|
||||
url: string;
|
||||
children: any;
|
||||
}
|
||||
|
||||
export const Layout = ({ url, children }: LayoutProps) => {
|
||||
const removeLeadingSlash = url.substring(1);
|
||||
const redirectUrl = removeLeadingSlash.startsWith("https://")
|
||||
? removeLeadingSlash
|
||||
: `https://bsky.app/${removeLeadingSlash}`;
|
||||
return html`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="canonical" href="${url.substring(1)}" />
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta content="#0085ff" name="theme-color" />
|
||||
<meta property="og:site_name" content="FixBluesky" />
|
||||
|
||||
${children}
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
{
|
||||
/* <meta http-equiv="refresh" content="0;url=${redirectUrl}" /> */
|
||||
}
|
||||
38
src/components/Post.tsx
Normal file
38
src/components/Post.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { AppBskyFeedDefs } from "@atproto/api";
|
||||
|
||||
import { Layout } from "./Layout";
|
||||
import { OEmbedTypes } from "../routes/getOEmbed";
|
||||
import { parseEmbedImage } from "../lib/parseEmbedImage";
|
||||
import { parseEmbedDescription } from "../lib/parseEmbedDescription";
|
||||
|
||||
interface PostProps {
|
||||
post: AppBskyFeedDefs.PostView;
|
||||
url: string;
|
||||
appDomain: string;
|
||||
}
|
||||
|
||||
export const Post = ({ post, url, appDomain }: PostProps) => (
|
||||
<Layout url={url}>
|
||||
<meta name="twitter:creator" content={`@${post.author.handle}`} />
|
||||
<meta property="og:description" content={parseEmbedDescription(post)} />
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${post.author.displayName} (@${post.author.handle})`}
|
||||
/>
|
||||
|
||||
{!(parseEmbedImage(post) === post.author.avatar) && (
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
)}
|
||||
|
||||
<meta property="og:image" content={parseEmbedImage(post)} />
|
||||
|
||||
<link
|
||||
type="application/json+oembed"
|
||||
href={`https:/${appDomain}/oembed?type=${OEmbedTypes.Post}&replies=${
|
||||
post.replyCount
|
||||
}&reposts=${post.repostCount}&likes=${
|
||||
post.likeCount
|
||||
}&avatar=${encodeURIComponent(post.author.avatar ?? "")}`}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
31
src/components/Profile.tsx
Normal file
31
src/components/Profile.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { AppBskyActorDefs } from "@atproto/api";
|
||||
|
||||
import { Layout } from "./Layout";
|
||||
import { OEmbedTypes } from "../routes/getOEmbed";
|
||||
|
||||
interface ProfileProps {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed;
|
||||
url: string;
|
||||
appDomain: string;
|
||||
}
|
||||
|
||||
export const Profile = ({ profile, url, appDomain }: ProfileProps) => (
|
||||
<Layout url={url}>
|
||||
<meta name="twitter:creator" content={`@${profile.handle}`} />
|
||||
<meta property="og:description" content={profile.description ?? ""} />
|
||||
<meta
|
||||
property="og:title"
|
||||
content={`${profile.displayName} (@${profile.handle})`}
|
||||
/>
|
||||
<meta property="og:image" content={profile.avatar} />
|
||||
|
||||
<link
|
||||
type="application/json+oembed"
|
||||
href={`https://${appDomain}/oembed?type=${OEmbedTypes.Profile}&follows=${
|
||||
profile.followsCount
|
||||
}&posts=${profile.postsCount}&avatar=${encodeURIComponent(
|
||||
profile.avatar ?? ""
|
||||
)}`}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
1
src/globals.d.ts
vendored
1
src/globals.d.ts
vendored
@@ -6,6 +6,7 @@ declare global {
|
||||
BSKY_SERVICE_URL: string;
|
||||
BSKY_AUTH_USERNAME: string;
|
||||
BSKY_AUTH_PASSWORD: string;
|
||||
FIXBLUESKY_APP_DOMAIN: string;
|
||||
};
|
||||
Variables: {
|
||||
Agent: BskyAgent;
|
||||
|
||||
16
src/index.ts
16
src/index.ts
@@ -1,8 +1,10 @@
|
||||
import { Hono } from "hono";
|
||||
import { BskyAgent } from "@atproto/api";
|
||||
import { getPost } from "./lib/getPost";
|
||||
import { getPostData } from "./lib/getPostData";
|
||||
import { getPostOEmbed } from "./lib/getPostOEmbed";
|
||||
import { getPost } from "./routes/getPost";
|
||||
import { getPostData } from "./routes/getPostData";
|
||||
import { getOEmbed } from "./routes/getOEmbed";
|
||||
import { getProfileData } from "./routes/getProfileData";
|
||||
import { getProfile } from "./routes/getProfile";
|
||||
|
||||
const app = new Hono<Env>();
|
||||
|
||||
@@ -26,6 +28,12 @@ app.get("/https://bsky.app/profile/:user/post/:post", getPost);
|
||||
app.get("/profile/:user/post/:post/json", getPostData);
|
||||
app.get("/https://bsky.app/profile/:user/post/:post/json", getPostData);
|
||||
|
||||
app.get("/oembed", getPostOEmbed);
|
||||
app.get("/profile/:user", getProfile);
|
||||
app.get("/https://bsky.app/profile/:user", getProfile);
|
||||
|
||||
app.get("/profile/:user/json", getProfileData);
|
||||
app.get("/https://bsky.app/profile/:user/json", getProfileData);
|
||||
|
||||
app.get("/oembed", getOEmbed);
|
||||
|
||||
export default app;
|
||||
|
||||
18
src/lib/fetchPostData.ts
Normal file
18
src/lib/fetchPostData.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BskyAgent } from "@atproto/api";
|
||||
|
||||
export interface fetchPostOptions {
|
||||
user: string;
|
||||
post: string;
|
||||
}
|
||||
|
||||
export async function fetchPost(
|
||||
agent: BskyAgent,
|
||||
{ user, post }: fetchPostOptions
|
||||
) {
|
||||
const { data: userData } = await agent.getProfile({
|
||||
actor: user,
|
||||
});
|
||||
return agent.getPosts({
|
||||
uris: [`at://${userData.did}/app.bsky.feed.post/${post}`],
|
||||
});
|
||||
}
|
||||
14
src/lib/fetchProfile.ts
Normal file
14
src/lib/fetchProfile.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { BskyAgent } from "@atproto/api";
|
||||
|
||||
export interface fetchProfileOptions {
|
||||
user: string;
|
||||
}
|
||||
|
||||
export async function fetchProfile(
|
||||
agent: BskyAgent,
|
||||
{ user }: fetchProfileOptions
|
||||
) {
|
||||
return agent.getProfile({
|
||||
actor: user,
|
||||
});
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Handler } from "hono";
|
||||
import { genHTML } from "../util/genHTML";
|
||||
|
||||
export const getPost: Handler<
|
||||
Env,
|
||||
"/profile/:user/post/:post" | "/https://bsky.app/profile/:user/post/:post"
|
||||
> = async (c) => {
|
||||
const { user, post } = c.req.param();
|
||||
const agent = c.get("Agent");
|
||||
const { data: userData } = await agent.getProfile({
|
||||
actor: user,
|
||||
});
|
||||
const { data: postData } = await agent.getPosts({
|
||||
uris: [`at://${userData.did}/app.bsky.feed.post/${post}`],
|
||||
});
|
||||
return c.html(genHTML(postData.posts[0], c.req.path));
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Handler } from "hono";
|
||||
|
||||
export const getPostOEmbed: Handler<Env, "/oembed"> = async (c) => {
|
||||
const { handle, display_name, avatar } = c.req.query();
|
||||
return c.json({
|
||||
author_name: `${display_name} (@${handle})`,
|
||||
author_url: `https://bsky.app/profile/${handle}`,
|
||||
provider_name: "FixBluesky",
|
||||
provider_url: "https://bsyy.app/",
|
||||
thumbnail_url: avatar,
|
||||
thumbnail_width: 1000,
|
||||
thumbnail_height: 1000,
|
||||
});
|
||||
};
|
||||
39
src/lib/parseEmbedDescription.ts
Normal file
39
src/lib/parseEmbedDescription.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
} from "@atproto/api";
|
||||
|
||||
export function parseEmbedDescription(post: AppBskyFeedDefs.PostView) {
|
||||
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||
if (AppBskyEmbedRecord.isView(post.embed)) {
|
||||
const { success: isView } = AppBskyEmbedRecord.validateView(post.embed);
|
||||
if (isView && AppBskyEmbedRecord.isViewRecord(post.embed.record)) {
|
||||
const { success: isViewRecord } = AppBskyEmbedRecord.validateViewRecord(
|
||||
post.embed.record
|
||||
);
|
||||
if (isViewRecord) {
|
||||
// @ts-expect-error For some reason the original post value is typed as {}
|
||||
return `${post.record.text}\n\nQuoting @${post.embed.record.author.handle}\n➥ ${post.embed.record.value.text}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
|
||||
const { success: isView } = AppBskyEmbedRecordWithMedia.validateView(
|
||||
post.embed
|
||||
);
|
||||
if (isView && AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) {
|
||||
const { success: isViewRecord } = AppBskyEmbedRecord.validateViewRecord(
|
||||
post.embed.record.record
|
||||
);
|
||||
if (isViewRecord) {
|
||||
// @ts-expect-error For some reason the original post value is typed as {}
|
||||
return `${post.record.text}\n\nQuoting @${post.embed.record.record.author.handle}\n➥ ${post.embed.record.record.value.text}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return post.record.text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
51
src/lib/parseEmbedImage.ts
Normal file
51
src/lib/parseEmbedImage.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
} from "@atproto/api";
|
||||
|
||||
export function parseEmbedImage(post: AppBskyFeedDefs.PostView) {
|
||||
if (AppBskyEmbedRecord.isView(post.embed)) {
|
||||
const { success: isView } = AppBskyEmbedRecord.validateView(post.embed);
|
||||
if (isView && AppBskyEmbedRecord.isViewRecord(post.embed.record)) {
|
||||
const { success: isViewRecord } = AppBskyEmbedRecord.validateViewRecord(
|
||||
post.embed.record
|
||||
);
|
||||
if (
|
||||
isViewRecord &&
|
||||
post.embed.record.embeds &&
|
||||
AppBskyEmbedImages.isView(post.embed.record.embeds[0])
|
||||
) {
|
||||
const { success: isImageView } = AppBskyEmbedImages.validateView(
|
||||
post.embed.record.embeds[0]
|
||||
);
|
||||
if (isImageView) {
|
||||
return post.embed.record.embeds[0].images[0].fullsize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
|
||||
const { success: isView } = AppBskyEmbedRecordWithMedia.validateView(
|
||||
post.embed
|
||||
);
|
||||
if (isView && AppBskyEmbedImages.isView(post.embed.media)) {
|
||||
const { success: isImageView } = AppBskyEmbedImages.validateView(
|
||||
post.embed.media
|
||||
);
|
||||
if (isImageView) {
|
||||
return post.embed.media.images[0].fullsize;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (AppBskyEmbedImages.isView(post.embed)) {
|
||||
const { success: isImageView } = AppBskyEmbedImages.validateView(
|
||||
post.embed
|
||||
);
|
||||
if (isImageView) {
|
||||
return post.embed.images[0].fullsize;
|
||||
}
|
||||
}
|
||||
return post.author.avatar ?? "";
|
||||
}
|
||||
35
src/routes/getOEmbed.ts
Normal file
35
src/routes/getOEmbed.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Handler } from "hono";
|
||||
|
||||
export enum OEmbedTypes {
|
||||
Post = 1,
|
||||
Profile,
|
||||
}
|
||||
|
||||
export const getOEmbed: Handler<Env, "/oembed"> = async (c) => {
|
||||
const type = +(c.req.query("type") ?? 0);
|
||||
const avatar = c.req.query("avatar");
|
||||
|
||||
const defaults = {
|
||||
provider_name: "FixBluesky",
|
||||
provider_url: "https://bsyy.app/",
|
||||
thumbnail_url: avatar,
|
||||
thumbnail_width: 1000,
|
||||
thumbnail_height: 1000,
|
||||
};
|
||||
|
||||
if (type === OEmbedTypes.Post) {
|
||||
const { replies, reposts, likes } = c.req.query();
|
||||
return c.json({
|
||||
author_name: `🗨️ ${replies} ♻️ ${reposts} 💙 ${likes}`,
|
||||
...defaults,
|
||||
});
|
||||
}
|
||||
if (type === OEmbedTypes.Profile) {
|
||||
const { follows, posts } = c.req.query();
|
||||
return c.json({
|
||||
author_name: `👤 ${follows} followers\n🗨️ ${posts} skeets`,
|
||||
...defaults,
|
||||
});
|
||||
}
|
||||
return c.json(defaults, 400);
|
||||
};
|
||||
26
src/routes/getPost.tsx
Normal file
26
src/routes/getPost.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Handler } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { fetchPost } from "../lib/fetchPostData";
|
||||
import { Post } from "../components/Post";
|
||||
|
||||
export const getPost: Handler<
|
||||
Env,
|
||||
"/profile/:user/post/:post" | "/https://bsky.app/profile/:user/post/:post"
|
||||
> = async (c) => {
|
||||
const { user, post } = c.req.param();
|
||||
const agent = c.get("Agent");
|
||||
const { data, success } = await fetchPost(agent, { user, post });
|
||||
if (!success) {
|
||||
throw new HTTPException(500, {
|
||||
message: "Failed to fetch the post!",
|
||||
});
|
||||
}
|
||||
// return c.html(genHTML(data.posts[0], c.req.path));
|
||||
return c.html(
|
||||
<Post
|
||||
post={data.posts[0]}
|
||||
url={c.req.path}
|
||||
appDomain={c.env.FIXBLUESKY_APP_DOMAIN}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Handler } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { fetchPost } from "../lib/fetchPostData";
|
||||
|
||||
export const getPostData: Handler<
|
||||
Env,
|
||||
@@ -7,11 +9,11 @@ export const getPostData: Handler<
|
||||
> = async (c) => {
|
||||
const { user, post } = c.req.param();
|
||||
const agent = c.get("Agent");
|
||||
const { data: userData } = await agent.getProfile({
|
||||
actor: user,
|
||||
});
|
||||
const { data } = await agent.getPosts({
|
||||
uris: [`at://${userData.did}/app.bsky.feed.post/${post}`],
|
||||
});
|
||||
const { data, success } = await fetchPost(agent, { user, post });
|
||||
if (!success) {
|
||||
throw new HTTPException(500, {
|
||||
message: "Failed to fetch the post!",
|
||||
});
|
||||
}
|
||||
return c.json(data);
|
||||
};
|
||||
25
src/routes/getProfile.tsx
Normal file
25
src/routes/getProfile.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Handler } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { fetchProfile } from "../lib/fetchProfile";
|
||||
import { Profile } from "../components/Profile";
|
||||
|
||||
export const getProfile: Handler<
|
||||
Env,
|
||||
"/profile/:user" | "/https://bsky.app/profile/:user"
|
||||
> = async (c) => {
|
||||
const { user } = c.req.param();
|
||||
const agent = c.get("Agent");
|
||||
const { data, success } = await fetchProfile(agent, { user });
|
||||
if (!success) {
|
||||
throw new HTTPException(500, {
|
||||
message: "Failed to fetch the profile!",
|
||||
});
|
||||
}
|
||||
return c.html(
|
||||
<Profile
|
||||
profile={data}
|
||||
url={c.req.url}
|
||||
appDomain={c.env.FIXBLUESKY_APP_DOMAIN}
|
||||
/>
|
||||
);
|
||||
};
|
||||
20
src/routes/getProfileData.ts
Normal file
20
src/routes/getProfileData.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @jsx jsx */
|
||||
import { jsx } from "hono/jsx";
|
||||
import { Handler } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { fetchProfile } from "../lib/fetchProfile";
|
||||
|
||||
export const getProfileData: Handler<
|
||||
Env,
|
||||
"/profile/:user/json" | "/https://bsky.app/profile/:user/json"
|
||||
> = async (c) => {
|
||||
const { user } = c.req.param();
|
||||
const agent = c.get("Agent");
|
||||
const { data, success } = await fetchProfile(agent, { user });
|
||||
if (!success) {
|
||||
throw new HTTPException(500, {
|
||||
message: "Failed to fetch the profile!",
|
||||
});
|
||||
}
|
||||
return c.json(data);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyFeedPost,
|
||||
} from "@atproto/api";
|
||||
import { html } from "hono/html";
|
||||
|
||||
export function genHTML(post: AppBskyFeedDefs.PostView, url: string) {
|
||||
const removeLeadingSlash = url.substring(1);
|
||||
const redirectUrl = removeLeadingSlash.startsWith("https://")
|
||||
? removeLeadingSlash
|
||||
: `https://bsky.app/${removeLeadingSlash}`;
|
||||
return html`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="canonical" href="${url.substring(1)}" />
|
||||
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
|
||||
<meta content="#0085ff" name="theme-color" />
|
||||
<meta property="og:site_name" content="FixBluesky" />
|
||||
<meta name="twitter:creator" content="@${post.author.handle}" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="${AppBskyFeedPost.isRecord(post.record) && post.record.text}"
|
||||
/>
|
||||
|
||||
${AppBskyEmbedImages.isView(post.embed) &&
|
||||
html`<meta name="twitter:card" content="summary_large_image" />`}
|
||||
<meta
|
||||
property="og:image"
|
||||
content="${AppBskyEmbedImages.isView(post.embed)
|
||||
? post.embed.images[0].fullsize
|
||||
: post.author.avatar}"
|
||||
/>
|
||||
|
||||
<meta http-equiv="refresh" content="0;url=${redirectUrl}" />
|
||||
|
||||
<link
|
||||
type="application/json+oembed"
|
||||
href="https://bsyy.app/oembed?display_name=${encodeURIComponent(
|
||||
post.author.displayName ?? ""
|
||||
)}&handle=${encodeURIComponent(
|
||||
post.author.handle
|
||||
)}&avatar=${encodeURIComponent(post.author.avatar ?? "")}"
|
||||
/>
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -5,13 +5,9 @@
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"lib": [
|
||||
"esnext"
|
||||
],
|
||||
"types": [
|
||||
"@cloudflare/workers-types"
|
||||
],
|
||||
"lib": ["esnext"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user