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:
ItsRauf
2023-07-18 23:35:33 -04:00
parent 8749964c4d
commit a26b3746be
18 changed files with 352 additions and 98 deletions

30
src/components/Layout.tsx Normal file
View 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
View 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>
);

View 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
View File

@@ -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;

View File

@@ -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
View 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
View 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,
});
}

View File

@@ -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));
};

View File

@@ -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,
});
};

View 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 "";
}

View 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
View 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
View 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}
/>
);
};

View File

@@ -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
View 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}
/>
);
};

View 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);
};

View File

@@ -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>
`;
}

View File

@@ -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"
},
}
}