✨ added embeds for posts :3
This commit is contained in:
parent
114c9f93d4
commit
12f5b1eb68
16 changed files with 523 additions and 6 deletions
27
src/components/Layout.tsx
Normal file
27
src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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}
|
||||
<meta http-equiv="refresh" content="0;url=${redirectUrl}" />
|
||||
</head>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
52
src/components/Post.tsx
Normal file
52
src/components/Post.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { AppBskyFeedDefs } from "@atproto/api";
|
||||
|
||||
import { parseEmbedDescription } from "../lib/parseEmbedDescription.ts";
|
||||
import { parseEmbedImage } from "../lib/parseEmbedImage.ts";
|
||||
import { OEmbedTypes } from "../routes/getOEmbed.ts";
|
||||
import { Layout } from "./Layout.tsx";
|
||||
|
||||
interface PostProps {
|
||||
post: AppBskyFeedDefs.PostView;
|
||||
url: string;
|
||||
appDomain: string;
|
||||
}
|
||||
|
||||
export const Post = ({ post, url, appDomain }: PostProps) => {
|
||||
const image = parseEmbedImage(post);
|
||||
return (
|
||||
<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})`}
|
||||
/>
|
||||
|
||||
{!(image === post.author.avatar) && (
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
)}
|
||||
|
||||
{Array.isArray(image) ? (
|
||||
image.map((i) => (
|
||||
<meta property="og:image" content={i} key={i} />
|
||||
))
|
||||
) : (
|
||||
<meta property="og:image" content={image} />
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
9
src/globals.d.ts
vendored
Normal file
9
src/globals.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { BskyAgent } from "@atproto/api";
|
||||
|
||||
declare global {
|
||||
interface HonoEnv {
|
||||
Variables: {
|
||||
Agent: BskyAgent;
|
||||
};
|
||||
}
|
||||
}
|
||||
18
src/lib/fetchPostData.ts
Normal file
18
src/lib/fetchPostData.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { 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}`],
|
||||
});
|
||||
}
|
||||
46
src/lib/parseEmbedDescription.ts
Normal file
46
src/lib/parseEmbedDescription.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedPost,
|
||||
type AppBskyFeedDefs,
|
||||
} 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 "";
|
||||
}
|
||||
58
src/lib/parseEmbedImage.ts
Normal file
58
src/lib/parseEmbedImage.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
type 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 &&
|
||||
post.embed.record.embeds.every((v) =>
|
||||
AppBskyEmbedImages.isView(v),
|
||||
)
|
||||
) {
|
||||
const validImageView = post.embed.record.embeds.every((v) =>
|
||||
AppBskyEmbedImages.validateView(v),
|
||||
);
|
||||
if (validImageView) {
|
||||
// return post.embed.record.embeds[0].images[0].fullsize;
|
||||
const embeds = post.embed.record
|
||||
.embeds as AppBskyEmbedImages.View[];
|
||||
return embeds.flatMap((e) => e.images.map((i) => i.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;
|
||||
return post.embed.media.images.map((i) => i.fullsize);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (AppBskyEmbedImages.isView(post.embed)) {
|
||||
const { success: isImageView } = AppBskyEmbedImages.validateView(
|
||||
post.embed,
|
||||
);
|
||||
if (isImageView) {
|
||||
// return post.embed.images[0].fullsize;
|
||||
return post.embed.images.map((i) => i.fullsize);
|
||||
}
|
||||
}
|
||||
return post.author.avatar ?? "";
|
||||
}
|
||||
57
src/main.ts
57
src/main.ts
|
|
@ -1,7 +1,62 @@
|
|||
import "./util/checkEnv.ts";
|
||||
|
||||
import { BskyAgent } from "@atproto/api";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { Redis } from "ioredis";
|
||||
import { getPost } from "./routes/getPost.tsx";
|
||||
|
||||
const app = new Hono();
|
||||
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||
const redis = new Redis(6379, process.env.REDIS_HOSTNAME!);
|
||||
|
||||
const agent = new BskyAgent({
|
||||
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||
service: process.env.BSKY_SERVICE_URL!,
|
||||
persistSession: async (_evt, session) => {
|
||||
if (session) {
|
||||
await redis.set("session", JSON.stringify(session));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const app = new Hono<HonoEnv>();
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
const session = await redis.get("session");
|
||||
try {
|
||||
if (session) {
|
||||
const login = await agent.resumeSession(JSON.parse(session));
|
||||
if (!login.success) {
|
||||
await agent.login({
|
||||
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||
identifier: process.env.BSKY_AUTH_USERNAME!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||
password: process.env.BSKY_AUTH_PASSWORD!,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await agent.login({
|
||||
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||
identifier: process.env.BSKY_AUTH_USERNAME!,
|
||||
// biome-ignore lint/style/noNonNullAssertion: check is ran at app start
|
||||
password: process.env.BSKY_AUTH_PASSWORD!,
|
||||
});
|
||||
}
|
||||
c.set("Agent", agent);
|
||||
} catch (error) {
|
||||
const err = new Error("Failed to login to Bluesky!", {
|
||||
cause: error,
|
||||
});
|
||||
throw new HTTPException(500, {
|
||||
message: `${err.message} \n\n ${err.cause} \n\n ${err.stack}`,
|
||||
});
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
app.get("/profile/:user/post/:post", getPost);
|
||||
app.get("/https://bsky.app/profile/:user/post/:post", getPost);
|
||||
|
||||
serve(
|
||||
{
|
||||
|
|
|
|||
35
src/routes/getOEmbed.ts
Normal file
35
src/routes/getOEmbed.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { Handler } from "hono";
|
||||
|
||||
export enum OEmbedTypes {
|
||||
Post = 1,
|
||||
Profile = 2,
|
||||
}
|
||||
|
||||
export const getOEmbed: Handler<HonoEnv, "/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);
|
||||
};
|
||||
27
src/routes/getPost.tsx
Normal file
27
src/routes/getPost.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { Handler } from "hono";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { Post } from "../components/Post.tsx";
|
||||
import { fetchPost } from "../lib/fetchPostData.ts";
|
||||
|
||||
export const getPost: Handler<
|
||||
HonoEnv,
|
||||
| "/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(
|
||||
<Post
|
||||
post={data.posts[0]}
|
||||
url={c.req.path}
|
||||
appDomain={process.env.FIXBLUESKY_APP_DOMAIN ?? "bsyy.app"}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
15
src/util/checkEnv.ts
Normal file
15
src/util/checkEnv.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
if (!process.env.REDIS_HOSTNAME) {
|
||||
throw new Error("REDIS_HOSTNAME is not defined");
|
||||
}
|
||||
|
||||
if (!process.env.BSKY_SERVICE_URL) {
|
||||
throw new Error("BSKY_SERVICE_URL is not defined");
|
||||
}
|
||||
|
||||
if (!process.env.BSKY_AUTH_USERNAME) {
|
||||
throw new Error("BSKY_AUTH_USERNAME is not defined");
|
||||
}
|
||||
|
||||
if (!process.env.BSKY_AUTH_PASSWORD) {
|
||||
throw new Error("BSKY_AUTH_PASSWORD is not defined");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue