├── .changeset
├── README.md
└── config.json
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc.json
├── .vscode
└── settings.json
├── README.md
├── apps
├── create-react-app
│ ├── .env
│ ├── package.json
│ ├── public
│ │ └── index.html
│ ├── readme.md
│ ├── src
│ │ ├── app.jsx
│ │ ├── app.module.css
│ │ ├── base.css
│ │ └── index.jsx
│ └── vercel.json
├── next-app
│ ├── .eslintrc.json
│ ├── .vscode
│ │ └── settings.json
│ ├── app
│ │ ├── api
│ │ │ └── postThread
│ │ │ │ └── [did]
│ │ │ │ └── [rkey]
│ │ │ │ └── route.ts
│ │ ├── layout.tsx
│ │ └── light
│ │ │ ├── [did]
│ │ │ └── [rkey]
│ │ │ │ ├── page.tsx
│ │ │ │ └── postThread-components.tsx
│ │ │ ├── cache
│ │ │ └── [did]
│ │ │ │ └── [rkey]
│ │ │ │ ├── page.tsx
│ │ │ │ └── postThread-page.tsx
│ │ │ ├── layout.module.css
│ │ │ ├── layout.tsx
│ │ │ ├── mdx
│ │ │ ├── page.tsx
│ │ │ └── post.mdx
│ │ │ ├── suspense
│ │ │ └── [did]
│ │ │ │ └── [rkey]
│ │ │ │ ├── page.tsx
│ │ │ │ └── postThread-page.tsx
│ │ │ └── vercel-kv
│ │ │ └── [did]
│ │ │ └── [rkey]
│ │ │ ├── page.tsx
│ │ │ └── postThread-page.tsx
│ ├── base.css
│ ├── components
│ │ ├── postThread-page.module.css
│ │ └── postThread-page.tsx
│ ├── mdx-components.tsx
│ ├── mdx.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── pages
│ │ ├── _app.js
│ │ └── dark
│ │ │ ├── [did]
│ │ │ └── [rkey]
│ │ │ │ └── index.tsx
│ │ │ └── swr
│ │ │ └── [did]
│ │ │ └── [rkey]
│ │ │ └── index.tsx
│ ├── readme.md
│ ├── tsconfig.json
│ └── vercel.json
├── site
│ ├── README.md
│ ├── app
│ │ └── api
│ │ │ └── postThread
│ │ │ └── [did]
│ │ │ └── [rkey]
│ │ │ └── route.ts
│ ├── components
│ │ ├── Sample.tsx
│ │ ├── counters.module.css
│ │ └── counters.tsx
│ ├── next.config.mjs
│ ├── package.json
│ ├── pages
│ │ ├── _app.tsx
│ │ ├── _document.tsx
│ │ ├── _meta.ts
│ │ ├── angular.mdx
│ │ ├── contributing.mdx
│ │ ├── index.mdx
│ │ ├── react
│ │ │ ├── _meta.ts
│ │ │ ├── api-reference.mdx
│ │ │ ├── create-react-app.mdx
│ │ │ ├── next.mdx
│ │ │ └── vite.mdx
│ │ ├── solid.mdx
│ │ ├── svelte.mdx
│ │ └── vue.mdx
│ ├── postcss.config.mjs
│ ├── public
│ │ ├── favicon.ico
│ │ └── opengraph-image.png
│ ├── styles
│ │ └── base.css
│ ├── tailwind.config.mjs
│ ├── theme.config.tsx
│ ├── tsconfig.json
│ └── vercel.json
└── vite-app
│ ├── .gitignore
│ ├── api
│ └── postThread
│ │ └── [postThread].ts
│ ├── index.html
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ └── vite.svg
│ ├── readme.md
│ ├── src
│ ├── base.css
│ ├── layout.tsx
│ ├── main.tsx
│ ├── pages
│ │ ├── index.tsx
│ │ └── postThread.tsx
│ └── vite-env.d.ts
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── vercel.json
│ └── vite.config.ts
├── license.md
├── package.json
├── packages
├── bluesky-embed-core
│ ├── .eslintignore
│ ├── .eslintrc.cjs
│ ├── .swcrc
│ ├── CHANGELOG.md
│ ├── license.md
│ ├── package.json
│ ├── src
│ │ ├── api
│ │ │ ├── fetch-postThread.ts
│ │ │ ├── get-postThread.ts
│ │ │ ├── index.ts
│ │ │ └── types
│ │ │ │ ├── index.ts
│ │ │ │ └── postThread.ts
│ │ ├── index.ts
│ │ ├── labels.ts
│ │ └── utils.ts
│ └── tsconfig.json
└── react-bluesky-embed
│ ├── .eslintignore
│ ├── .eslintrc.cjs
│ ├── .swcrc
│ ├── CHANGELOG.md
│ ├── global.d.ts
│ ├── license.md
│ ├── package.json
│ ├── postcss.config.cjs
│ ├── readme.md
│ ├── src
│ ├── api
│ │ ├── fetch-postThread.ts
│ │ ├── get-postThread.ts
│ │ ├── index.ts
│ │ └── types
│ │ │ ├── index.ts
│ │ │ └── postThread.ts
│ ├── assets
│ │ ├── arrowBottom_stroke2_corner0_rounded.svg
│ │ ├── bubble_filled_stroke2_corner2_rounded.svg
│ │ ├── circleInfo_stroke2_corner0_rounded.svg
│ │ ├── heart2_filled_stroke2_corner0_rounded.svg
│ │ ├── logo.svg
│ │ ├── play_filled_corner2_rounded.svg
│ │ ├── repost_stroke2_corner2_rounded.svg
│ │ └── starterPack.svg
│ ├── bluesky-theme
│ │ ├── comments.tsx
│ │ ├── components.tsx
│ │ ├── container.tsx
│ │ ├── embed.tsx
│ │ ├── embedded-postThread.tsx
│ │ ├── icons.tsx
│ │ ├── labels.ts
│ │ ├── link.tsx
│ │ ├── post.tsx
│ │ ├── postThread-not-found.tsx
│ │ ├── postThread-skeleton.tsx
│ │ ├── theme-container.tsx
│ │ └── utils.ts
│ ├── hooks.ts
│ ├── index.client.ts
│ ├── index.ts
│ ├── main.css
│ ├── main.scss
│ ├── postThread.tsx
│ ├── swr.tsx
│ └── utils.ts
│ ├── tailwind.config.js
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json
/.changeset/README.md:
--------------------------------------------------------------------------------
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "restricted",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": ["*-app", "site"]
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # Dependencies
4 | node_modules
5 | .pnp
6 | .pnp.js
7 |
8 | # Testing
9 | /coverage
10 |
11 | # Next.js
12 | .next
13 | out
14 | next-env.d.ts
15 |
16 | # Production
17 | build
18 | dist
19 |
20 | # Misc
21 | .DS_Store
22 | *.pem
23 | tsconfig.tsbuildinfo
24 |
25 | # Debug
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 |
30 | # Local ENV files
31 | .env.local
32 | .env.development.local
33 | .env.test.local
34 | .env.production.local
35 |
36 | # Vercel
37 | .vercel
38 |
39 | # Turborepo
40 | .turbo
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | auto-install-peers = true
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .next
3 | .vercel
4 | public
5 | dist
6 | .vscode
7 |
8 | # Ignore dependency locks
9 | pnpm-lock.yaml
10 | package-lock.json
11 | yarn.lock
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "singleQuote": false,
6 | "semi": true,
7 | "bracketSameLine": false,
8 | "bracketSpacing": true,
9 | "jsxSingleQuote": false,
10 | "quoteProps": "as-needed",
11 | "endOfLine": "lf"
12 | }
13 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[mdx]": {
3 | "editor.wordWrap": "on",
4 | }
5 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Bluesky Embed
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | React Bluesky Embed allows you to embed post threads, profiles, and comments in your React application when using Next.js, Create React App, Vite, and more.
11 |
12 | Profiles and comments support coming soon.
13 |
14 | Adapters for Solid, Vue, Angular, and Svelte are coming soon.
15 |
16 | 
17 |
18 | ## Documentation
19 |
20 | For documentation visit [react-bluesky-embed.vercel.app](https://react-bluesky-embed.vercel.app).
21 |
22 | ## Installation
23 |
24 | ```sh
25 | npm i react-bluesky-embed
26 | ```
27 |
28 | ## Usage
29 |
30 | ```tsx
31 |
46 | ```
47 |
48 | ## Contributing
49 |
50 | Visit our [contributing docs](https://react-bluesky-embed.vercel.app/contributing).
51 |
--------------------------------------------------------------------------------
/apps/create-react-app/.env:
--------------------------------------------------------------------------------
1 | BROWSER = none
2 | PORT = 3002
--------------------------------------------------------------------------------
/apps/create-react-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-react-app",
3 | "private": true,
4 | "license": "MIT",
5 | "repository": "https://github.com/hichemfantar/react-bluesky-embed.git",
6 | "author": "Hichem Fantar (https://bsky.app/profile/opensauced.bsky.social)",
7 | "main": "src/index.js",
8 | "scripts": {
9 | "dev": "react-scripts start",
10 | "build": "react-scripts build"
11 | },
12 | "dependencies": {
13 | "clsx": "^2.1.1",
14 | "react": "^18.3.1",
15 | "react-bluesky-embed": "workspace:*",
16 | "react-dom": "^18.3.1",
17 | "react-scripts": "5.0.1"
18 | },
19 | "devDependencies": {
20 | "@babel/runtime": "7.22.6",
21 | "@types/node": "20.10.4",
22 | "postcss-flexbugs-fixes": "^5.0.2"
23 | },
24 | "browserslist": [
25 | ">0.2%",
26 | "not dead",
27 | "not ie <= 11",
28 | "not op_mini all"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/apps/create-react-app/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
18 | React App
19 |
20 |
21 |
22 |
23 | You need to enable JavaScript to run this app.
24 |
25 |
26 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/apps/create-react-app/readme.md:
--------------------------------------------------------------------------------
1 | # react-bluesky-embed for Create React App
2 |
3 | Follow the instructions in the [official docs](https://react-bluesky-embed.vercel.app/create-react-app) to learn more about `react-bluesky-embed` for Create React App.
4 |
--------------------------------------------------------------------------------
/apps/create-react-app/src/app.jsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { PostThread } from "react-bluesky-embed";
3 | import styles from "./app.module.css";
4 | import "./base.css";
5 |
6 | export default function App() {
7 | return (
8 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/apps/create-react-app/src/app.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-family: var(--postThread-font-family);
3 | color: var(--postThread-font-color);
4 | background: var(--postThread-bg-color);
5 | height: 100vh;
6 | overflow: auto;
7 | padding: 2rem 1rem;
8 | }
9 | .main {
10 | display: flex;
11 | justify-content: center;
12 | }
13 |
--------------------------------------------------------------------------------
/apps/create-react-app/src/base.css:
--------------------------------------------------------------------------------
1 | *,
2 | *::before,
3 | *::after {
4 | margin: 0;
5 | padding: 0;
6 | box-sizing: inherit;
7 | }
8 | html {
9 | height: 100%;
10 | box-sizing: border-box;
11 | }
12 | body {
13 | position: relative;
14 | min-height: 100%;
15 | margin: 0;
16 | line-height: 1.65;
17 | font-family: -apple-system, BlinkMacSystemFont, sans-serif;
18 | font-weight: 400;
19 | text-rendering: optimizeLegibility;
20 | -webkit-font-smoothing: antialiased;
21 | -moz-osx-font-smoothing: grayscale;
22 | scroll-behavior: smooth;
23 | }
24 | html,
25 | body {
26 | background: #fff;
27 | }
28 |
--------------------------------------------------------------------------------
/apps/create-react-app/src/index.jsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from "react";
2 | import { createRoot } from "react-dom/client";
3 | import App from "./app";
4 |
5 | const rootElement = document.getElementById("root");
6 | const root = createRoot(rootElement);
7 |
8 | root.render(
9 |
10 |
11 |
12 | );
13 |
--------------------------------------------------------------------------------
/apps/create-react-app/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "pnpm turbo build --filter=create-react-app...",
3 | "ignoreCommand": "pnpm dlx turbo-ignore"
4 | }
5 |
--------------------------------------------------------------------------------
/apps/next-app/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/apps/next-app/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true
4 | }
--------------------------------------------------------------------------------
/apps/next-app/app/api/postThread/[did]/[rkey]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { getPostThread } from "react-bluesky-embed/api";
3 |
4 | type RouteSegment = { params: { did: string; rkey: string } };
5 |
6 | export const fetchCache = "only-cache";
7 |
8 | export async function GET(_req: NextRequest, { params }: RouteSegment) {
9 | try {
10 | const did = params.did;
11 | const rkey = params.rkey;
12 |
13 | const postThread = await getPostThread({
14 | did: did,
15 | rkey: rkey,
16 | });
17 | return NextResponse.json(
18 | { data: postThread ?? null },
19 | { status: postThread ? 200 : 404 }
20 | );
21 | } catch (error: any) {
22 | console.error(error);
23 | return NextResponse.json(
24 | { error: error.message ?? "Bad request." },
25 | { status: 400 }
26 | );
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/apps/next-app/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, ReactNode } from "react";
2 | import "../base.css";
3 |
4 | const RootLayout: FC<{ children: ReactNode }> = ({ children }) => (
5 |
6 |
7 | {children}
8 |
9 | );
10 |
11 | export default RootLayout;
12 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/[did]/[rkey]/page.tsx:
--------------------------------------------------------------------------------
1 | import { PostThread } from "react-bluesky-embed";
2 | import { getPostThread } from "react-bluesky-embed/api";
3 |
4 | type Props = {
5 | params: { did: string; rkey: string };
6 | };
7 |
8 | export const revalidate = 1800;
9 |
10 | export async function generateMetadata({ params }: Props) {
11 | const did = params.did;
12 | const rkey = params.rkey;
13 |
14 | const postThread = await getPostThread({
15 | did: did,
16 | rkey: rkey,
17 | }).catch(() => undefined);
18 |
19 | if (!postThread) return { title: "Next PostThread" };
20 |
21 | // const username = ` - @${postThread.user.screen_name}`;
22 | // const maxLength = 68 - username.length;
23 | // const text =
24 | // postThread.text.length > maxLength
25 | // ? `${postThread.text.slice(0, maxLength)}…`
26 | // : postThread.text;
27 |
28 | return { title: `` };
29 | // return { title: `${text}${username}` };
30 | }
31 |
32 | export default function Page({ params }: Props) {
33 | const did = params.did;
34 | const rkey = params.rkey;
35 |
36 | return (
37 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/[did]/[rkey]/postThread-components.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/alt-text */
2 | // import Image from 'next/image'
3 | // import type { BlueskyComponents } from 'react-bluesky-embed'
4 |
5 | // export const components: BlueskyComponents = {
6 | // AvatarImg: (props) => ,
7 | // MediaImg: (props) => ,
8 | // }
9 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/cache/[did]/[rkey]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import { PostThreadSkeleton } from "react-bluesky-embed";
3 | import PostThreadPage from "./postThread-page";
4 |
5 | export const revalidate = 86400;
6 |
7 | const Page = ({ params }: { params: { did: string; rkey: string } }) => {
8 | const did = params.did;
9 | const rkey = params.rkey;
10 |
11 | return (
12 | }>
13 |
19 |
20 | );
21 | };
22 |
23 | export default Page;
24 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/cache/[did]/[rkey]/postThread-page.tsx:
--------------------------------------------------------------------------------
1 | import { unstable_cache } from "next/cache";
2 | import {
3 | getPostThread as _getPostThread,
4 | PostThreadParams,
5 | } from "react-bluesky-embed/api";
6 | import { EmbeddedPostThread, PostThreadNotFound } from "react-bluesky-embed";
7 |
8 | const getPostThread = unstable_cache(
9 | async (params: PostThreadParams) => _getPostThread(params),
10 | ["postThread"],
11 | { revalidate: 3600 * 24 }
12 | );
13 |
14 | const PostThreadPage = async ({ params }: { params: PostThreadParams }) => {
15 | try {
16 | const postThread = await getPostThread(params);
17 | return postThread ? (
18 |
19 | ) : (
20 |
21 | );
22 | } catch (error) {
23 | console.error(error);
24 | return ;
25 | }
26 | };
27 |
28 | export default PostThreadPage;
29 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/layout.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-family: var(--postThread-font-family);
3 | color: var(--postThread-font-color);
4 | background: var(--postThread-bg-color);
5 | height: 100vh;
6 | overflow: auto;
7 | padding: 2rem 1rem;
8 | }
9 | .main {
10 | display: flex;
11 | justify-content: center;
12 | }
13 | .footer {
14 | font-size: 0.875rem;
15 | text-align: center;
16 | }
17 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, ReactNode } from "react";
2 | import clsx from "clsx";
3 | import s from "./layout.module.css";
4 |
5 | const Layout: FC<{ children: ReactNode }> = ({ children }) => (
6 |
14 | );
15 |
16 | export default Layout;
17 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/mdx/page.tsx:
--------------------------------------------------------------------------------
1 | import PostThread from "./post.mdx";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/mdx/post.mdx:
--------------------------------------------------------------------------------
1 | import { PostThread } from "react-bluesky-embed";
2 |
3 |
6 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/suspense/[did]/[rkey]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import { PostThreadSkeleton } from "react-bluesky-embed";
3 | import PostThreadPage from "./postThread-page";
4 |
5 | export const revalidate = 3600;
6 |
7 | const Page = ({ params }: { params: { did: string; rkey: string } }) => {
8 | const did = params.did;
9 | const rkey = params.rkey;
10 |
11 | return (
12 | }>
13 |
19 |
20 | );
21 | };
22 |
23 | export default Page;
24 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/suspense/[did]/[rkey]/postThread-page.tsx:
--------------------------------------------------------------------------------
1 | import { getPostThread, PostThreadParams } from "react-bluesky-embed/api";
2 | import { EmbeddedPostThread, PostThreadNotFound } from "react-bluesky-embed";
3 |
4 | const PostThreadPage = async ({ params }: { params: PostThreadParams }) => {
5 | const did = params.did;
6 | const rkey = params.rkey;
7 |
8 | try {
9 | const postThread = await getPostThread({
10 | did,
11 | rkey,
12 | });
13 | return postThread ? (
14 |
15 | ) : (
16 |
17 | );
18 | } catch (error) {
19 | console.error(error);
20 | return ;
21 | }
22 | };
23 |
24 | export default PostThreadPage;
25 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/vercel-kv/[did]/[rkey]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import { PostThreadSkeleton } from "react-bluesky-embed";
3 | import PostThreadPage from "./postThread-page";
4 |
5 | export const revalidate = 86400;
6 |
7 | const Page = ({ params }: { params: { did: string; rkey: string } }) => {
8 | const did = params.did;
9 | const rkey = params.rkey;
10 |
11 | return (
12 | }>
13 |
19 |
20 | );
21 | };
22 |
23 | export default Page;
24 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/vercel-kv/[did]/[rkey]/postThread-page.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | fetchPostThread,
3 | PostThread,
4 | PostThreadParams,
5 | } from "react-bluesky-embed/api";
6 | import { EmbeddedPostThread, PostThreadNotFound } from "react-bluesky-embed";
7 | import { kv } from "@vercel/kv";
8 |
9 | async function getPostThread(params: {
10 | did: string;
11 | rkey: string;
12 | }): Promise {
13 | const serializedParams = JSON.stringify(params);
14 |
15 | try {
16 | const { data, notFound } = await fetchPostThread({ params });
17 |
18 | if (data) {
19 | await kv.set(`postThread:${serializedParams}`, data);
20 | return data;
21 | } else if (notFound) {
22 | // remove the postThread from the cache if it has been made private by the author (tombstone)
23 | // or if it no longer exists.
24 | await kv.del(`postThread:${serializedParams}`);
25 | }
26 | } catch (error) {
27 | console.error("fetching the postThread failed with:", error);
28 | }
29 |
30 | const cachedPostThread = await kv.get(
31 | `postThread:${serializedParams}`
32 | );
33 | return cachedPostThread ?? undefined;
34 | }
35 |
36 | const PostThreadPage = async ({ params }: { params: PostThreadParams }) => {
37 | try {
38 | const postThread = await getPostThread(params);
39 | return postThread ? (
40 |
41 | ) : (
42 |
43 | );
44 | } catch (error) {
45 | console.error(error);
46 | return ;
47 | }
48 | };
49 |
50 | export default PostThreadPage;
51 |
--------------------------------------------------------------------------------
/apps/next-app/base.css:
--------------------------------------------------------------------------------
1 | *,
2 | *::before,
3 | *::after {
4 | margin: 0;
5 | padding: 0;
6 | box-sizing: inherit;
7 | }
8 | html {
9 | height: 100%;
10 | box-sizing: border-box;
11 | }
12 | body {
13 | position: relative;
14 | min-height: 100%;
15 | margin: 0;
16 | line-height: 1.65;
17 | font-family: -apple-system, BlinkMacSystemFont, sans-serif;
18 | font-weight: 400;
19 | text-rendering: optimizeLegibility;
20 | -webkit-font-smoothing: antialiased;
21 | -moz-osx-font-smoothing: grayscale;
22 | scroll-behavior: smooth;
23 | }
24 | html,
25 | body {
26 | background: #fff;
27 | }
28 |
--------------------------------------------------------------------------------
/apps/next-app/components/postThread-page.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-family: var(--postThread-font-family);
3 | color: var(--postThread-font-color);
4 | background: var(--postThread-bg-color);
5 | height: 100vh;
6 | overflow: auto;
7 | padding: 2rem 1rem;
8 | }
9 | .main {
10 | display: flex;
11 | justify-content: center;
12 | }
13 | .footer {
14 | font-size: 0.875rem;
15 | text-align: center;
16 | }
17 |
--------------------------------------------------------------------------------
/apps/next-app/components/postThread-page.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 | import clsx from "clsx";
3 | import s from "./postThread-page.module.css";
4 |
5 | type Props = { children?: ReactNode; footer?: boolean };
6 |
7 | export const PostThreadPage = ({ children, footer }: Props) => (
8 |
9 |
10 |
{children}
11 | {footer && (
12 |
15 | )}
16 |
17 |
18 | );
19 |
--------------------------------------------------------------------------------
/apps/next-app/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | export const useMDXComponents = (components: any) => components;
2 |
--------------------------------------------------------------------------------
/apps/next-app/mdx.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.mdx" {
2 | let MDXComponent: (props) => JSX.Element;
3 | export default MDXComponent;
4 | }
5 |
--------------------------------------------------------------------------------
/apps/next-app/next.config.mjs:
--------------------------------------------------------------------------------
1 | import mdx from "@next/mdx";
2 |
3 | const withMDX = mdx();
4 |
5 | /** @type {import('next').NextConfig} */
6 | const nextConfig = {
7 | pageExtensions: ["ts", "tsx", "js", "jsx", "md", "mdx"],
8 | images: {
9 | remotePatterns: [{ protocol: "https", hostname: "cdn.bsky.app" }],
10 | },
11 | experimental: {
12 | mdxRs: true,
13 | },
14 | };
15 |
16 | export default withMDX(nextConfig);
17 |
--------------------------------------------------------------------------------
/apps/next-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-app",
3 | "private": true,
4 | "license": "MIT",
5 | "repository": "https://github.com/hichemfantar/react-bluesky-embed.git",
6 | "author": "Hichem Fantar (https://bsky.app/profile/opensauced.bsky.social)",
7 | "scripts": {
8 | "dev": "next dev -p 3001",
9 | "build": "next build",
10 | "start": "next start -p 3001",
11 | "lint": "next lint",
12 | "clean": "rm -rf .next && rm -rf .turbo"
13 | },
14 | "dependencies": {
15 | "@next/mdx": "^14.2.18",
16 | "@vercel/kv": "^3.0.0",
17 | "clsx": "^2.1.1",
18 | "next": "^14.2.18",
19 | "react": "^18.3.1",
20 | "react-bluesky-embed": "workspace:*",
21 | "react-dom": "^18.3.1"
22 | },
23 | "devDependencies": {
24 | "@types/node": "20.10.4",
25 | "@types/react": "^18.3.12",
26 | "eslint": "^8.57.1",
27 | "eslint-config-next": "^14.2.19",
28 | "typescript": "^5.7.2"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/apps/next-app/pages/_app.js:
--------------------------------------------------------------------------------
1 | import "../base.css";
2 |
3 | export default function App({ Component, pageProps }) {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/apps/next-app/pages/dark/[did]/[rkey]/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { EmbeddedPostThread, PostThreadSkeleton } from "react-bluesky-embed";
3 | import { getPostThread, type PostThread } from "react-bluesky-embed/api";
4 | import { PostThreadPage } from "../../../../components/postThread-page";
5 |
6 | export async function getStaticProps({
7 | params,
8 | }: {
9 | params: { did: string; rkey: string };
10 | }) {
11 | try {
12 | const did = params.did;
13 | const rkey = params.rkey;
14 | const postThread = await getPostThread({
15 | did,
16 | rkey,
17 | });
18 | return postThread ? { props: { postThread } } : { notFound: true };
19 | } catch (error) {
20 | console.error(error);
21 | return { notFound: true };
22 | }
23 | }
24 |
25 | export async function getStaticPaths() {
26 | return { paths: [], fallback: true };
27 | }
28 |
29 | export default function Page({ postThread }: { postThread: PostThread }) {
30 | const { isFallback } = useRouter();
31 |
32 | return (
33 |
34 | {isFallback ? (
35 |
36 | ) : (
37 |
38 | )}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/apps/next-app/pages/dark/swr/[did]/[rkey]/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "next/router";
2 | import { PostThread } from "react-bluesky-embed";
3 | import { PostThreadPage } from "../../../../../components/postThread-page";
4 |
5 | export default function Page() {
6 | const router = useRouter();
7 |
8 | const did = router.query.did;
9 | const rkey = router.query.rkey;
10 |
11 | // https://github.com/vercel/next.js/discussions/11484
12 | if (!router.isReady) {
13 | return null;
14 | }
15 |
16 | return (
17 |
18 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/apps/next-app/readme.md:
--------------------------------------------------------------------------------
1 | # react-bluesky-embed for Next.js
2 |
3 | Follow the instructions in the [official docs](https://react-bluesky-embed.vercel.app/react/next) to learn more about `react-bluesky-embed` for Next.js.
4 |
--------------------------------------------------------------------------------
/apps/next-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "ESNext",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": false,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "incremental": true,
12 | "esModuleInterop": true,
13 | "module": "esnext",
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve",
18 | "plugins": [
19 | {
20 | "name": "next"
21 | }
22 | ],
23 | "strictNullChecks": false
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/apps/next-app/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "pnpm turbo build --filter=next-app...",
3 | "ignoreCommand": "pnpm dlx turbo-ignore"
4 | }
5 |
--------------------------------------------------------------------------------
/apps/site/README.md:
--------------------------------------------------------------------------------
1 | # react-bluesky-embed site
2 |
3 | This is documentation site app for `react-bluesky-embed`. It uses [Nextra](https://nextra.site).
4 |
5 | ## Running the app locally
6 |
7 | Clone this repository and run the following command:
8 |
9 | ```bash
10 | pnpm install && pnpm dev --filter=site
11 | ```
12 |
13 | The app will be up at running at .
14 |
--------------------------------------------------------------------------------
/apps/site/app/api/postThread/[did]/[rkey]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { getPostThread } from "react-bluesky-embed/api";
3 | import cors from "edge-cors";
4 | import { type NextRequest } from "next/server";
5 |
6 | type RouteSegment = { params: { did: string; rkey: string } };
7 |
8 | export const fetchCache = "only-cache";
9 |
10 | // TODO: refactor all [postthread] routes with nested routes [did]->[rkey]
11 |
12 | export async function GET(req: NextRequest, { params }: RouteSegment) {
13 | try {
14 | const did = params.did;
15 | const rkey = params.rkey;
16 |
17 | const postThread = await getPostThread({
18 | did: did,
19 | rkey: rkey,
20 | });
21 |
22 | return cors(
23 | req,
24 | NextResponse.json(
25 | { data: postThread ?? null },
26 | { status: postThread ? 200 : 404 }
27 | )
28 | );
29 | } catch (error: any) {
30 | console.error(error);
31 | return cors(
32 | req,
33 | NextResponse.json(
34 | { error: error.message ?? "Bad request." },
35 | { status: 400 }
36 | )
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/apps/site/components/Sample.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "nextra-theme-docs";
2 | import React, { useEffect, useState } from "react";
3 | import { PostThread, Theme } from "react-bluesky-embed";
4 |
5 | export function Sample() {
6 | const { resolvedTheme } = useTheme();
7 |
8 | return (
9 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/apps/site/components/counters.module.css:
--------------------------------------------------------------------------------
1 | .counter {
2 | border: 1px solid #ccc;
3 | border-radius: 5px;
4 | padding: 2px 6px;
5 | margin: 12px 0 0;
6 | }
7 |
--------------------------------------------------------------------------------
/apps/site/components/counters.tsx:
--------------------------------------------------------------------------------
1 | // Example from https://beta.reactjs.org/learn
2 |
3 | import { useState } from "react";
4 | import styles from "./counters.module.css";
5 |
6 | function MyButton() {
7 | const [count, setCount] = useState(0);
8 |
9 | function handleClick() {
10 | setCount(count + 1);
11 | }
12 |
13 | return (
14 |
15 |
16 | Clicked {count} times
17 |
18 |
19 | );
20 | }
21 |
22 | export default function MyApp() {
23 | return ;
24 | }
25 |
--------------------------------------------------------------------------------
/apps/site/next.config.mjs:
--------------------------------------------------------------------------------
1 | import nextra from "nextra";
2 |
3 | const withNextra = nextra({
4 | // ... your Nextra config
5 | theme: "nextra-theme-docs",
6 | themeConfig: "./theme.config.tsx",
7 | defaultShowCopyCode: true,
8 | });
9 |
10 | export default withNextra({
11 | // ... your Next.js config
12 | });
13 |
--------------------------------------------------------------------------------
/apps/site/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "site",
3 | "private": true,
4 | "license": "MIT",
5 | "repository": "https://github.com/hichemfantar/react-bluesky-embed.git",
6 | "author": "Hichem Fantar (https://bsky.app/profile/opensauced.bsky.social)",
7 | "description": "Official site and documentation for react-bluesky-embed",
8 | "type": "module",
9 | "scripts": {
10 | "dev": "next dev",
11 | "build": "next build",
12 | "start": "next start"
13 | },
14 | "dependencies": {
15 | "edge-cors": "^0.2.1",
16 | "next": "^14.2.18",
17 | "nextra": "^3.2.4",
18 | "nextra-theme-docs": "^3.2.4",
19 | "react": "^18.3.1",
20 | "react-bluesky-embed": "workspace:*",
21 | "react-dom": "^18.3.1"
22 | },
23 | "devDependencies": {
24 | "@types/node": "20.10.5",
25 | "@types/react": "^18.3.12",
26 | "autoprefixer": "^10.4.20",
27 | "postcss": "^8.4.49",
28 | "tailwindcss": "^3.4.15",
29 | "typescript": "^5.7.2"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/apps/site/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import type { AppProps } from "next/app";
2 | import "../styles/base.css";
3 |
4 | export default function App({ Component, pageProps }: AppProps) {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/apps/site/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from "next/document";
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/apps/site/pages/_meta.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | index: "Introduction",
3 | "-- Usage": {
4 | type: "separator",
5 | title: "Usage",
6 | },
7 | react: "React",
8 | vue: "Vue",
9 | angular: "Angular",
10 | solid: "Solid",
11 | svelte: "Svelte",
12 | "-- More": {
13 | type: "separator",
14 | title: "More",
15 | },
16 | contributing: "",
17 | "next.js-link": {
18 | title: "Next.js Docs ↗",
19 | href: "https://nextjs.org?utm_source=react-bluesky-embed.site&utm_medium=referral&utm_campaign=sidebar",
20 | newWindow: true,
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/apps/site/pages/angular.mdx:
--------------------------------------------------------------------------------
1 | # Coming Soon
--------------------------------------------------------------------------------
/apps/site/pages/contributing.mdx:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | To contribute, clone the [`react-bluesky-embed` repository](https://github.com/hichemfantar/react-bluesky-embed) and run the [Next.js test app](/react/next#running-the-test-app) to start an app locally that uses the [`react-bluesky-embed` package](https://github.com/hichemfantar/react-bluesky-embed/blob/main/packages/react-bluesky-embed). Any changes you make to the package will be reflected in the test app.
4 |
5 | Once you're done making changes, [submit a pull request](https://github.com/hichemfantar/react-bluesky-embed/compare).
6 |
7 | It's recommended to [open an issue](https://github.com/hichemfantar/react-bluesky-embed/issues/new) first before making any major changes to the package so that we can discuss the changes before you start working on them.
8 |
--------------------------------------------------------------------------------
/apps/site/pages/index.mdx:
--------------------------------------------------------------------------------
1 | import { Sample } from "../components/Sample";
2 |
3 | # Introduction
4 |
5 | `react-bluesky-embed` allows you to embed post threads in your React application when using Next.js, Create React App, Vite, and more. This library does require using the Bluesky API. Post threads can be rendered statically, preventing the need to include an iframe and additional client-side JavaScript.
6 |
7 |
8 |
9 |
10 | You can see how it in action in [react-bluesky-embed-next.vercel.app/light/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](https://react-bluesky-embed-next.vercel.app/light/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u). Replace the postThread ID in the URL to see other post threads.
11 |
12 | This library is fully compatible with [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components).
13 |
14 | ## Installation
15 |
16 | Install `react-bluesky-embed` using your package manager of choice:
17 |
18 | ```bash
19 | npm install react-bluesky-embed
20 | ```
21 |
22 | Now follow the usage instructions for your framework or builder:
23 |
24 | - [Next.js](/react/next)
25 | - [Vite](/react/vite)
26 | - [Create React App](/react/create-react-app)
27 |
28 | > **Important**: Before going to production, we recommend [enabling cache for the Bluesky API](#enabling-cache-for-the-bluesky-api) as server IPs might get rate limited by Bluesky.
29 |
30 | ## Choosing a theme
31 |
32 | ### Toggling theme manually
33 |
34 | The closest `theme` prop can determine the theme of the postThread. You can set it to `light` or `dark`, like so:
35 |
36 | ```tsx
37 |
52 | ```
53 |
54 | ## Enabling cache for the Bluesky API
55 |
56 | Rendering post threads requires making a call to Bluesky's API. Getting rate limited by that API is very hard but it's possible if you're relying only on the endpoint we provide for SWR (`react-bluesky-embed.vercel.app/api/postThread/:did-:rkey`) as the IPs of the server are making many requests to the syndication API. This also applies to RSC where the API endpoint is not required but the server is still making the request from the same IP.
57 |
58 | To prevent this, you can use a db like Redis or [Vercel KV](https://vercel.com/docs/storage/vercel-kv) to cache the post threads. For example using [Vercel KV](https://vercel.com/docs/storage/vercel-kv):
59 |
60 | ```tsx
61 | import { Suspense } from "react";
62 | import {
63 | PostThreadSkeleton,
64 | EmbeddedPostThread,
65 | PostThreadNotFound,
66 | } from "react-bluesky-embed";
67 | import { fetchPostThread, PostThread } from "react-bluesky-embed/api";
68 | import { kv } from "@vercel/kv";
69 |
70 | async function getPostThread(
71 | params: PostThreadParams,
72 | config?: PostThreadConfig
73 | ): Promise {
74 | try {
75 | const { data, tombstone, notFound } = await fetchPostThread(
76 | params,
77 | config
78 | );
79 |
80 | if (data) {
81 | await kv.set(`postThread:${params}`, data);
82 | return data;
83 | } else if (tombstone || notFound) {
84 | // remove the postThread from the cache if it has been made private by the author (tombstone)
85 | // or if it no longer exists.
86 | await kv.del(`postThread:${params}`);
87 | }
88 | } catch (error) {
89 | console.error("fetching the postThread failed with:", error);
90 | }
91 |
92 | const cachedPostThread = await kv.get(`postThread:${params}`);
93 | return cachedPostThread ?? undefined;
94 | }
95 |
96 | const PostThreadPage = async ({
97 | params,
98 | }: {
99 | params: { PostThreadParams };
100 | }) => {
101 | try {
102 | const postThread = await getPostThread(params);
103 | return postThread ? (
104 |
105 | ) : (
106 |
107 | );
108 | } catch (error) {
109 | console.error(error);
110 | return ;
111 | }
112 | };
113 |
114 | const Page = ({
115 | params,
116 | }: {
117 | params: { postThread: PostThreadParams };
118 | }) => (
119 | }>
120 |
121 |
122 | );
123 |
124 | export default Page;
125 | ```
126 |
127 | You can see it working at [react-bluesky-embed-next.vercel.app/light/vercel-kv/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](https://react-bluesky-embed-next.vercel.app/light/vercel-kv/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u) ([source](https://github.com/vercel/react-bluesky-embed/blob/main/apps/next-app/app/light/vercel-kv/%5BpostThread%5D/page.tsx)).
128 |
129 | If you're using Next.js then using [`unstable_cache`](/react/next#enabling-cache) works too.
130 |
--------------------------------------------------------------------------------
/apps/site/pages/react/_meta.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | next: "Next.js",
3 | vite: "Vite",
4 | "create-react-app": "CRA",
5 | "api-reference": "",
6 | };
7 |
--------------------------------------------------------------------------------
/apps/site/pages/react/api-reference.mdx:
--------------------------------------------------------------------------------
1 | # API Reference
2 |
3 | This is the reference for the utility functions that `react-bluesky-embed` provides for [building your own postThread components](/custom-theme) or simply fetching a postThread. Navigate to the docs for the [Bluesky theme](/bluesky-theme) if you want to render the existing PostThread components instead.
4 |
5 | ## `getPostThread`
6 |
7 | ```tsx
8 | import { getPostThread, type PostThread } from "react-bluesky-embed/api";
9 |
10 | function getPostThread(
11 | params: PostThreadParams,
12 | config?: PostThreadConfig
13 | ): Promise;
14 | ```
15 |
16 | Fetches and returns a [`PostThread`](https://github.com/hichemfantar/react-bluesky-embed/blob/main/packages/react-bluesky-embed/src/api/types/postThread.ts). It accepts the following params:
17 |
18 | - **params** - `{ did: string; rkey: string }`: the postThread ID. For example in `at://did:plc:zl7kgfro2rx3pavbslhhdhuy/app.bsky.feed.post/3lblfjf4evs2v` the DID is `did:plc:zl7kgfro2rx3pavbslhhdhuy` and the Rkey is `3lblfjf4evs2v`.
19 | - **config** - `PostThreadConfig` (Optional): options to pass to [`getPostThread`](https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread#request).
20 |
21 | If a postThread is not found it returns `undefined`.
22 |
23 | ## `fetchPostThread`
24 |
25 | ```tsx
26 | function fetchPostThread(
27 | params: PostThreadParams,
28 | config?: PostThreadConfig
29 | ): Promise<{
30 | data?: PostThread | undefined;
31 | tombstone?: true | undefined;
32 | notFound?: true | undefined;
33 | }>;
34 | ```
35 |
36 | Fetches and returns a [`PostThread`](https://github.com/hichemfantar/react-bluesky-embed/blob/main/packages/react-bluesky-embed/src/api/types/postThread.ts) just like [`getPostThread`](#getpostthread), but it also returns additional information about the postThread:
37 |
38 | - **data** - `PostThread` (Optional): The postThread data.
39 | - **tombstone** - `true` (Optional): Indicates if the postThread has been made private.
40 | - **notFound** - `true` (Optional): Indicates if the postThread was not found.
41 |
42 | ## `enrichPostThread`
43 |
44 | ```tsx
45 | import { enrichPostThread, type EnrichedPostThread } from "react-bluesky-embed";
46 |
47 | const enrichPostThread: (postThread: PostThread) => EnrichedPostThread;
48 | ```
49 |
50 | Enriches a [`PostThread`](https://github.com/hichemfantar/react-bluesky-embed/blob/main/packages/react-bluesky-embed/src/api/types/postThread.ts) as returned by [`getPostThread`](#getpostthread) with additional data. This is useful to more easily build custom postThread components.
51 |
52 | It returns an [`EnrichedPostThread`](https://github.com/hichemfantar/react-bluesky-embed/blob/main/packages/react-bluesky-embed/src/utils.ts).
53 |
54 | ## `usePostThread`
55 |
56 | > If your app supports React Server Components, use [`getPostThread`](#getpostthread) instead.
57 |
58 | ```tsx
59 | import { usePostThread } from "react-bluesky-embed";
60 |
61 | const usePostThread: (
62 | params?: PostThreadParams,
63 | config?: PostThreadConfig
64 | ) => {
65 | isLoading: boolean;
66 | data: PostThread | null | undefined;
67 | error: any;
68 | };
69 | ```
70 |
71 | SWR hook for fetching a postThread in the browser. It accepts the following parameters:
72 |
73 | - **params** - `{ did: string; rkey: string }`: the postThread ID. For example in `at://did:plc:zl7kgfro2rx3pavbslhhdhuy/app.bsky.feed.post/3lblfjf4evs2v` the DID is `did:plc:zl7kgfro2rx3pavbslhhdhuy` and the Rkey is `3lblfjf4evs2v`.
74 | - **config** - `PostThreadConfig` (Optional): [`getPostThread`](https://docs.bsky.app/docs/api/app-bsky-feed-get-post-thread#request). Try to pass down a reference to the same object to avoid unnecessary re-renders.
75 |
76 | We highly recommend adding your own API endpoint in `apiUrl` for production:
77 |
78 | ```ts
79 | const postThread = usePostThread(params);
80 | ```
81 |
82 | It's likely you'll never use this hook directly, and `apiUrl` is passed as a prop to a component instead:
83 |
84 | ```tsx
85 |
86 | ```
87 |
--------------------------------------------------------------------------------
/apps/site/pages/react/create-react-app.mdx:
--------------------------------------------------------------------------------
1 | # Create React App
2 |
3 | ## Installation
4 |
5 | Follow the [installation docs in the Introduction](/#installation).
6 |
7 | ## Usage
8 |
9 | In any component, import `PostThread` from `react-bluesky-embed` and use it like so:
10 |
11 | ```tsx
12 | import { PostThread } from "react-bluesky-embed";
13 |
14 | export default function App() {
15 | return (
16 |
22 | );
23 | }
24 | ```
25 |
26 | You can learn more about `PostThread` in the [Bluesky theme docs](/bluesky-theme).
27 |
28 | ## Running the test app
29 |
30 | Clone the [`react-bluesky-embed`](https://github.com/hichemfantar/react-bluesky-embed) repository and then run the following command:
31 |
32 | ```bash
33 | pnpm install && pnpm dev --filter=create-react-app...
34 | ```
35 |
36 | The app will be up and running at (localhost:3002)[http://localhost:3002] for the [CRA example](https://github.com/hichemfantar/react-bluesky-embed/tree/main/apps/create-react-app).
37 |
38 | The source code for `react-bluesky-embed` is imported from [packages/react-bluesky-embed](https://github.com/hichemfantar/react-bluesky-embed/tree/main/packages/react-bluesky-embed) and any changes you make to it will be reflected in the app immediately.
39 |
--------------------------------------------------------------------------------
/apps/site/pages/react/next.mdx:
--------------------------------------------------------------------------------
1 | # Next.js
2 |
3 | ## Installation
4 |
5 | > Next.js 13.2.1 or higher is required in order to use `react-bluesky-embed`.
6 |
7 | Follow the [installation docs in the Introduction](/#installation).
8 |
9 | ## Usage
10 |
11 | In any component, import `PostThread` from `react-bluesky-embed` and use it like so:
12 |
13 | ```tsx
14 | import { PostThread } from "react-bluesky-embed";
15 |
16 | export default function Page() {
17 | return (
18 |
24 | );
25 | }
26 | ```
27 |
28 | `PostThread` works differently depending on where it's used. If it's used in the App Router it will fetch the postThread in the server. If it's used in the pages directory it will fetch the postThread in the client with [SWR](https://swr.vercel.app/).
29 |
30 | You can learn more about `PostThread` in the [Bluesky theme docs](/bluesky-theme). And you can learn more about the usage in [Running the test app](#running-the-test-app).
31 |
32 | ### Troubleshooting
33 |
34 | If you see an error saying that CSS can't be imported from `node_modules` in the `pages` directory. Add the following config to `next.config.js`:
35 |
36 | ```js
37 | transpilePackages: ["react-bluesky-embed"];
38 | ```
39 |
40 | The error won't happen if the App Router is enabled, where [Next.js supports CSS imports from `node_modules`](https://github.com/vercel/next.js/discussions/27953#discussioncomment-3978605).
41 |
42 | ### Enabling cache
43 |
44 | It's recommended to enable cache for the Bluesky API if you intend to go to production. This is how you can do it with [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache):
45 |
46 | ```tsx
47 | import { Suspense } from "react";
48 | import { unstable_cache } from "next/cache";
49 | import {
50 | PostThreadSkeleton,
51 | EmbeddedPostThread,
52 | PostThreadNotFound,
53 | } from "react-bluesky-embed";
54 | import { getPostThread as _getPostThread } from "react-bluesky-embed/api";
55 |
56 | const getPostThread = unstable_cache(
57 | async (params: PostThreadParams) => _getPostThread(params),
58 | ["postThread"],
59 | { revalidate: 3600 * 24 }
60 | );
61 |
62 | const PostThreadPage = async ({
63 | params,
64 | }: {
65 | params: PostThreadParams;
66 | }) => {
67 | try {
68 | const postThread = await getPostThread(params);
69 | return postThread ? (
70 |
71 | ) : (
72 |
73 | );
74 | } catch (error) {
75 | console.error(error);
76 | return ;
77 | }
78 | };
79 |
80 | const Page = ({
81 | params,
82 | }: {
83 | params: { postThread: PostThreadParams };
84 | }) => (
85 | }>
86 |
87 |
88 | );
89 |
90 | export default Page;
91 | ```
92 |
93 | This can prevent getting your server IPs rate limited if they are making too many requests to the Bluesky API.
94 |
95 | ## Advanced usage
96 |
97 | ### Manual data fetching
98 |
99 | You can use the [`getPostThread`](/api-reference#getpostthread) function from `react-bluesky-embed/api` to fetch the postThread manually. This is useful for SSG pages and for other [Next.js data fetching methods](https://nextjs.org/docs/basic-features/data-fetching/overview) in the `pages` directory.
100 |
101 | For example, using `getStaticProps` in `pages/[postThread].tsx` to fetch the postThread and send it as props to the page component:
102 |
103 | ```tsx
104 | import { useRouter } from "next/router";
105 | import { getPostThread, type PostThread } from "react-bluesky-embed/api";
106 | import { EmbeddedPostThread, PostThreadSkeleton } from "react-bluesky-embed";
107 |
108 | export async function getStaticProps({
109 | params,
110 | }: {
111 | params: { postThread: string };
112 | }) {
113 | const postThreadId = params.postThread;
114 |
115 | try {
116 | const postThread = await getPostThread(postThreadId);
117 | return postThread ? { props: { postThread } } : { notFound: true };
118 | } catch (error) {
119 | return { notFound: true };
120 | }
121 | }
122 |
123 | export async function getStaticPaths() {
124 | return { paths: [], fallback: true };
125 | }
126 |
127 | export default function Page({ postThread }: { postThread: PostThread }) {
128 | const { isFallback } = useRouter();
129 | return isFallback ? (
130 |
131 | ) : (
132 |
133 | );
134 | }
135 | ```
136 |
137 | ### Adding `next/image`
138 |
139 | Add the domain URLs from Bluesky to [`images.remotePatterns`](https://nextjs.org/docs/api-reference/next/image#remote-patterns) in `next.config.js`:
140 |
141 | ```js
142 | /** @type {import('next').NextConfig} */
143 | const nextConfig = {
144 | images: {
145 | remotePatterns: [{ protocol: "https", hostname: "cdn.bsky.app" }],
146 | },
147 | };
148 | ```
149 |
150 | In `postThread-components.tsx` or elsewhere, import the `Image` component from `next/image` and use it to define custom image components for the postThread:
151 |
152 | ```tsx
153 | import Image from "next/image";
154 | import type { BlueskyComponents } from "react-bluesky-embed";
155 |
156 | export const components: BlueskyComponents = {
157 | AvatarImg: (props) => ,
158 | MediaImg: (props) => ,
159 | };
160 | ```
161 |
162 | Then pass the `components` prop to `PostThread`:
163 |
164 | ```tsx
165 | import { PostThread } from "react-bluesky-embed";
166 | import { components } from "./postThread-components";
167 |
168 | export default function Page() {
169 | return (
170 |
177 | );
178 | }
179 | ```
180 |
181 | ## Running the test app
182 |
183 | Clone the [`react-bluesky-embed`](https://github.com/hichemfantar/react-bluesky-embed) repository and then run the following command:
184 |
185 | ```bash
186 | pnpm install && pnpm dev --filter=next-app...
187 | ```
188 |
189 | The app will be up and running at http://localhost:3001 for the [Next.js app example](https://github.com/hichemfantar/react-bluesky-embed/tree/main/apps/next-app).
190 |
191 | The app shows the usage of `react-bluesky-embed` in different scenarios:
192 |
193 | - [localhost:3001/light/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](http://localhost:3001/light/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u) renders the postThread in the app router.
194 | - [localhost:3001/dark/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](http://localhost:3001/dark/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u) renders the postThread using SSG in the pages directory.
195 | - [localhost:3001/light/mdx](http://localhost:3001/light/mdx) rendes the postThread in MDX (with the experimental `mdxRs` config enabled).
196 | - [localhost:3001/light/suspense/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](http://localhost:3001/light/suspense/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u) renders the postThread with a custom `Suspense` wrapper.
197 | - [localhost:3001/dark/swr/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](http://localhost:3001/dark/swr/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u) uses `apiUrl` to change the API endpoint from which the postThread is fetched in SWR mode.
198 | - [localhost:3001/light/cache/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](http://localhost:3001/light/suspense/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u) renders the postThread while caching the postThread data with [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache).
199 | - [localhost:3001/light/vercel-kv/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](http://localhost:3001/light/suspense/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u) renders the postThread while caching the postThread data with [Vercel KV](https://vercel.com/docs/storage/vercel-kv).
200 |
201 | The source code for `react-bluesky-embed` is imported from [packages/react-bluesky-embed](https://github.com/hichemfantar/react-bluesky-embed/tree/main/packages/react-bluesky-embed) and any changes you make to it will be reflected in the app immediately.
202 |
--------------------------------------------------------------------------------
/apps/site/pages/react/vite.mdx:
--------------------------------------------------------------------------------
1 | # Vite
2 |
3 | ## Installation
4 |
5 | Follow the [installation docs in the Introduction](/#installation).
6 |
7 | ## Usage
8 |
9 | In any component, import `PostThread` from `react-bluesky-embed` and use it like so:
10 |
11 | ```tsx
12 | import { PostThread } from "react-bluesky-embed";
13 |
14 | export const IndexPage = () => (
15 |
21 | );
22 | ```
23 |
24 | You can learn more about `PostThread` in the [Bluesky theme docs](/bluesky-theme).
25 |
26 | ## Running the test app
27 |
28 | Clone the [`react-bluesky-embed`](https://github.com/hichemfantar/react-bluesky-embed) repository and then run the following command:
29 |
30 | ```bash
31 | pnpm install && pnpm dev --filter=vite-app...
32 | ```
33 |
34 | The app will be up and running at http://localhost:5173 for the [Vite app example](https://github.com/hichemfantar/react-bluesky-embed/tree/main/apps/vite-app).
35 |
36 | The app shows the usage of `react-bluesky-embed` in different scenarios:
37 |
38 | - [localhost:5173/](http://localhost:5173) renders a single postThread.
39 | - [localhost:5173/postThread/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u](http://localhost:5173/postThread/did:plc:gru662w3omynujkgwebgeeof/3lbirib5xnc2u) renders dynamic post threads with SWR. `PostThread` already uses SWR and this page shows how to implement it manually.
40 |
41 | The source code for `react-bluesky-embed` is imported from [packages/react-bluesky-embed](https://github.com/hichemfantar/react-bluesky-embed/tree/main/packages/react-bluesky-embed) and any changes you make to it will be reflected in the app immediately.
42 |
--------------------------------------------------------------------------------
/apps/site/pages/solid.mdx:
--------------------------------------------------------------------------------
1 | # Coming Soon
--------------------------------------------------------------------------------
/apps/site/pages/svelte.mdx:
--------------------------------------------------------------------------------
1 | # Coming Soon
--------------------------------------------------------------------------------
/apps/site/pages/vue.mdx:
--------------------------------------------------------------------------------
1 | # Coming Soon
--------------------------------------------------------------------------------
/apps/site/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | // If you want to use other PostCSS plugins, see the following:
2 | // https://tailwindcss.com/docs/using-with-preprocessors
3 | export default {
4 | plugins: {
5 | tailwindcss: {},
6 | autoprefixer: {},
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/apps/site/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hichemfantar/react-bluesky-embed/cd77117894154ab2b911aa449aef9738e966b043/apps/site/public/favicon.ico
--------------------------------------------------------------------------------
/apps/site/public/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hichemfantar/react-bluesky-embed/cd77117894154ab2b911aa449aef9738e966b043/apps/site/public/opengraph-image.png
--------------------------------------------------------------------------------
/apps/site/styles/base.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/apps/site/tailwind.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: [
4 | "./pages/**/*.{js,ts,jsx,tsx,md,mdx}",
5 | "./components/**/*.{js,ts,jsx,tsx,md,mdx}",
6 | "./theme.config.tsx",
7 | ],
8 | theme: {
9 | extend: {},
10 | },
11 | plugins: [],
12 | darkMode: "class",
13 | };
14 |
--------------------------------------------------------------------------------
/apps/site/theme.config.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { DocsThemeConfig, Link, useConfig } from "nextra-theme-docs";
3 |
4 | const projectName = "React Bluesky Embed";
5 |
6 | const config: DocsThemeConfig = {
7 | logo: (
8 |
9 |
14 |
18 |
19 |
{projectName}
20 |
21 | ),
22 | head: function useHead() {
23 | const { title } = useConfig();
24 |
25 | return (
26 | <>
27 | {title + " - " + projectName}
28 |
29 |
30 |
31 |
32 |
36 |
40 |
41 |
42 |
43 |
44 |
48 |
52 |
56 |
57 | >
58 | );
59 | },
60 | project: {
61 | link: "https://github.com/hichemfantar/react-bluesky-embed",
62 | },
63 | sidebar: {
64 | defaultMenuCollapseLevel: 1,
65 | },
66 | banner: {
67 | // content: "🎉 React Bluesky Embed is now in Beta.",
68 | key: "beta",
69 | dismissible: false,
70 |
71 | // key: '3.0-release',
72 | content: (
73 |
74 | {/* React Bluesky Embed is now in Beta.{" "} */}
75 | Contribute to Bluesky Community.
76 |
80 | Read more
81 |
82 |
83 | ),
84 | },
85 | docsRepositoryBase:
86 | "https://github.com/hichemfantar/react-bluesky-embed/tree/main/apps/site",
87 | editLink: {
88 | content: "Edit this page on GitHub →",
89 | },
90 | footer: {
91 | content: (
92 |
93 |
94 | © {new Date().getFullYear()} Hichem Fantar. All rights reserved.
95 |
96 |
97 | ),
98 | },
99 | };
100 |
101 | export default config;
102 |
--------------------------------------------------------------------------------
/apps/site/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "incremental": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ]
22 | },
23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
24 | "exclude": ["node_modules"]
25 | }
26 |
--------------------------------------------------------------------------------
/apps/site/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "pnpm turbo build --filter=site...",
3 | "ignoreCommand": "pnpm dlx turbo-ignore"
4 | }
5 |
--------------------------------------------------------------------------------
/apps/vite-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/apps/vite-app/api/postThread/[postThread].ts:
--------------------------------------------------------------------------------
1 | import type { VercelRequest, VercelResponse } from "@vercel/node";
2 | import { getPostThread } from "react-bluesky-embed/api";
3 |
4 | const handler = async (req: VercelRequest, res: VercelResponse) => {
5 | const postThreadId = req.query.postThread;
6 |
7 | if (req.method !== "GET" || typeof postThreadId !== "string") {
8 | res.status(400).json({ error: "Bad Request." });
9 | return;
10 | }
11 |
12 | try {
13 | const postThread = await getPostThread(postThreadId);
14 | res.status(postThread ? 200 : 404).json({ data: postThread ?? null });
15 | } catch (error) {
16 | console.error(error);
17 | res.status(400).json({ error: error.message ?? "Bad request." });
18 | }
19 | };
20 |
21 | export default handler;
22 |
--------------------------------------------------------------------------------
/apps/vite-app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/apps/vite-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite-app",
3 | "private": true,
4 | "license": "MIT",
5 | "repository": "https://github.com/hichemfantar/react-bluesky-embed.git",
6 | "author": "Hichem Fantar (https://bsky.app/profile/opensauced.bsky.social)",
7 | "type": "module",
8 | "scripts": {
9 | "dev": "vite",
10 | "build": "vite build",
11 | "start": "vite preview"
12 | },
13 | "dependencies": {
14 | "@atproto/api": "^0.13.17",
15 | "clsx": "^2.1.1",
16 | "react": "^18.3.1",
17 | "react-bluesky-embed": "workspace:*",
18 | "react-dom": "^18.3.1",
19 | "react-router-dom": "^6.28.0",
20 | "swr": "^2.2.5"
21 | },
22 | "devDependencies": {
23 | "@types/react": "^18.3.12",
24 | "@types/react-dom": "^18.3.1",
25 | "@vercel/node": "^2.9.12",
26 | "@vitejs/plugin-react-swc": "^3.0.0",
27 | "autoprefixer": "^10.4.20",
28 | "postcss": "^8.4.49",
29 | "tailwindcss": "^3.4.15",
30 | "typescript": "^4.9.3",
31 | "vite": "^4.1.0"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/apps/vite-app/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/apps/vite-app/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/vite-app/readme.md:
--------------------------------------------------------------------------------
1 | # react-bluesky-embed for Vite
2 |
3 | Follow the instructions in the [official docs](https://react-bluesky-embed.vercel.app/vite) to learn more about `react-bluesky-embed` for Vite.
4 |
--------------------------------------------------------------------------------
/apps/vite-app/src/base.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | @apply bg-black dark:text-gray-100;
7 | }
8 |
9 | html {
10 | color-scheme: dark;
11 | }
12 |
--------------------------------------------------------------------------------
/apps/vite-app/src/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router-dom";
2 |
3 | export const Layout = () => (
4 |
5 |
6 |
7 |
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/apps/vite-app/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import { createBrowserRouter, RouterProvider } from "react-router-dom";
4 | import { Layout } from "./layout";
5 | import { IndexPage } from "./pages/index";
6 | import { PostThreadPage } from "./pages/postThread";
7 | import "./base.css";
8 |
9 | const router = createBrowserRouter([
10 | {
11 | path: "/",
12 | element: ,
13 | children: [
14 | { index: true, element: },
15 | { path: "/postThread", element: },
16 | ],
17 | },
18 | ]);
19 |
20 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
21 |
22 |
23 |
24 | );
25 |
--------------------------------------------------------------------------------
/apps/vite-app/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { PostThread } from "react-bluesky-embed";
2 |
3 | export const IndexPage = () => (
4 |
17 | );
18 |
--------------------------------------------------------------------------------
/apps/vite-app/src/pages/postThread.tsx:
--------------------------------------------------------------------------------
1 | import { AtpAgent } from "@atproto/api";
2 | import { useState } from "react";
3 | import {
4 | EmbeddedPostThread,
5 | PostThreadNotFound,
6 | PostThreadSkeleton,
7 | Theme,
8 | } from "react-bluesky-embed";
9 | import {
10 | getPostThread,
11 | PostThreadConfig,
12 | PostThreadParams,
13 | } from "react-bluesky-embed/api";
14 | import { useSearchParams } from "react-router-dom";
15 | import useSWR from "swr";
16 |
17 | async function fetcher([params, config]: [PostThreadParams, PostThreadConfig]) {
18 | const res = await getPostThread(
19 | {
20 | did: params.did,
21 | rkey: params.rkey,
22 | },
23 | config
24 | );
25 | return res;
26 | }
27 |
28 | type SearchParamsObj = {
29 | did?: string;
30 | rkey?: string;
31 | };
32 |
33 | const DEFAULT_URI =
34 | "at://did:plc:zl7kgfro2rx3pavbslhhdhuy/app.bsky.feed.post/3lblfjf4evs2v";
35 |
36 | export const PostThreadPage = () => {
37 | const [searchParams, setSearchParams] = useSearchParams();
38 | const [theme, setTheme] = useState("dark");
39 | const [pastedUri, setPastedUri] = useState(DEFAULT_URI);
40 | const [resolvedUri, setResolvedUri] = useState(DEFAULT_URI);
41 | const searchParamsObj: SearchParamsObj = Object.fromEntries(
42 | searchParams.entries()
43 | );
44 |
45 | const urlp = new URL("http://localhost:3000/api/postThread");
46 | if (searchParamsObj.did && searchParamsObj.rkey) {
47 | urlp.searchParams.append("did", searchParamsObj.did);
48 | urlp.searchParams.append("rkey", searchParamsObj.rkey);
49 | }
50 |
51 | const { data, error, isLoading } = useSWR(
52 | () => [
53 | {
54 | did: searchParamsObj.did,
55 | rkey: searchParamsObj.rkey,
56 | },
57 | {
58 | depth: 6,
59 | },
60 | ],
61 | fetcher,
62 | {}
63 | // urlp.href,
64 | // `http://localhost:3000/api/postThread/${params.uri}`,
65 | );
66 |
67 | return (
68 |
69 |
70 |
71 |
72 |
73 | DID
74 |
75 |
92 |
93 |
94 | Rkey
95 |
96 |
112 |
113 |
114 | Uri
115 |
116 |
185 |
186 |
187 | setTheme(theme === "light" ? "dark" : "light")}
189 | >
190 | Switch Theme
191 |
192 |
193 |
194 |
195 | {isLoading &&
}
196 | {/* {
} */}
197 | {!isLoading && (error || !data) && (
198 |
199 | )}
200 | {data &&
}
201 |
202 |
203 | );
204 | };
205 |
--------------------------------------------------------------------------------
/apps/vite-app/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/apps/vite-app/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/apps/vite-app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/apps/vite-app/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/apps/vite-app/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "pnpm turbo build --filter=vite-app...",
3 | "ignoreCommand": "pnpm dlx turbo-ignore",
4 | "rewrites": [
5 | {
6 | "source": "/((?!api/)[^.]+)",
7 | "destination": "/"
8 | }
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/apps/vite-app/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react-swc";
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | });
8 |
--------------------------------------------------------------------------------
/license.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Hichem Fantar.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bluesky-embed-monorepo",
3 | "repository": "https://github.com/hichemfantar/react-bluesky-embed.git",
4 | "license": "MIT",
5 | "private": true,
6 | "author": "Hichem Fantar (https://bsky.app/profile/opensauced.bsky.social)",
7 | "scripts": {
8 | "build": "turbo run build",
9 | "dev": "turbo run dev",
10 | "start": "turbo run start",
11 | "clean": "turbo run clean",
12 | "lint": "turbo run lint",
13 | "format": "prettier --write .",
14 | "changeset": "changeset",
15 | "version-packages": "changeset version",
16 | "release": "turbo run build && pnpm publish -r"
17 | },
18 | "devDependencies": {
19 | "@changesets/cli": "^2.27.10",
20 | "prettier": "^2.8.8",
21 | "turbo": "^2.3.3"
22 | },
23 | "packageManager": "pnpm@9.5.0"
24 | }
25 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/.eslintignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 |
4 | # Production
5 | dist
6 |
7 | # Misc
8 | .DS_Store
9 | *.pem
10 | tsconfig.tsbuildinfo
11 |
12 | # Debug
13 | npm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 |
17 | # Vercel
18 | .vercel
19 |
20 | # Turborepo
21 | .turbo
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | parser: "@typescript-eslint/parser",
4 | parserOptions: {
5 | sourceType: "module",
6 | ecmaVersion: "latest",
7 | ecmaFeatures: {
8 | jsx: true,
9 | modules: true,
10 | },
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/.swcrc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/swcrc",
3 | "module": {
4 | "type": "es6"
5 | },
6 | "jsc": {
7 | "target": "es2018",
8 | "loose": true,
9 | "parser": {
10 | "syntax": "typescript",
11 | "tsx": true,
12 | "dynamicImport": true
13 | },
14 | "transform": {
15 | "react": {
16 | "runtime": "automatic",
17 | "throwIfNamespace": true,
18 | "development": false
19 | }
20 | },
21 | "externalHelpers": true
22 | },
23 | "minify": true
24 | }
25 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # bluesky-embed-core
2 |
3 | ## 0.4.0
4 |
5 | ### Minor Changes
6 |
7 | - better comment support
8 |
9 | ## 0.3.0
10 |
11 | ### Minor Changes
12 |
13 | - use client fix
14 |
15 | ## 0.2.0
16 |
17 | ### Minor Changes
18 |
19 | - Recursive comments
20 |
21 | ## 0.1.0
22 |
23 | ### Minor Changes
24 |
25 | - new release
26 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/license.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Hichem Fantar.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bluesky-embed-core",
3 | "version": "0.4.0",
4 | "repository": {
5 | "type": "git",
6 | "url": "https://github.com/hichemfantar/react-bluesky-embed.git",
7 | "directory": "packages/react-bluesky-core"
8 | },
9 | "author": "Hichem Fantar (https://bsky.app/profile/opensauced.bsky.social)",
10 | "homepage": "https://bluesky-embed.vercel.app",
11 | "keywords": [
12 | "bluesky",
13 | "bsky",
14 | "embed"
15 | ],
16 | "scripts": {
17 | "build": "pnpm build:swc && pnpm types",
18 | "build:swc": "swc src -d dist --copy-files",
19 | "dev": "pnpm build:swc -w",
20 | "types": "tsc --emitDeclarationOnly",
21 | "lint": "TIMING=1 eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix",
22 | "clean": "rm -rf dist && rm -rf .turbo"
23 | },
24 | "license": "MIT",
25 | "bugs": {
26 | "url": "https://github.com/hichemfantar/react-bluesky-embed/issues"
27 | },
28 | "type": "module",
29 | "exports": {
30 | ".": {
31 | "default": "./dist/index.js"
32 | },
33 | "./api": "./dist/api/index.js"
34 | },
35 | "files": [
36 | "dist/**/*.{js,d.ts,css}"
37 | ],
38 | "typesVersions": {
39 | "*": {
40 | "index": [
41 | "src/index"
42 | ],
43 | "api": [
44 | "src/api/index"
45 | ],
46 | "*": []
47 | }
48 | },
49 | "publishConfig": {
50 | "access": "public",
51 | "typesVersions": {
52 | "*": {
53 | "index": [
54 | "dist/index.d.ts"
55 | ],
56 | "api": [
57 | "dist/api/index.d.ts"
58 | ],
59 | "*": []
60 | }
61 | }
62 | },
63 | "dependencies": {
64 | "@atproto/api": "^0.13.17",
65 | "@swc/helpers": "^0.5.15"
66 | },
67 | "devDependencies": {
68 | "@swc/cli": "^0.1.65",
69 | "@swc/core": "^1.9.3",
70 | "@types/node": "20.10.5",
71 | "chokidar": "^3.5.3",
72 | "eslint": "^8.57.1",
73 | "prettier": "^3.3.3",
74 | "typescript": "^5.7.2"
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/src/api/fetch-postThread.ts:
--------------------------------------------------------------------------------
1 | import { AppBskyFeedDefs, AtpAgent } from "@atproto/api";
2 | import type {
3 | PostThread,
4 | PostThreadConfig,
5 | PostThreadParams,
6 | } from "./types/index.js";
7 |
8 | export class BlueskyApiError extends Error {
9 | status: number;
10 | data: any;
11 |
12 | constructor({
13 | message,
14 | status,
15 | data,
16 | }: {
17 | message: string;
18 | status: number;
19 | data: any;
20 | }) {
21 | super(message);
22 | this.name = "BlueskyApiError";
23 | this.status = status;
24 | this.data = data;
25 | }
26 | }
27 |
28 | const DEFAULT_URI =
29 | "at://did:plc:zl7kgfro2rx3pavbslhhdhuy/app.bsky.feed.post/3lblfjf4evs2v";
30 |
31 | export async function fetchPostThread({
32 | params,
33 | config = {
34 | depth: 0,
35 | parentHeight: 0,
36 | },
37 | }: {
38 | params: PostThreadParams;
39 | config?: PostThreadConfig;
40 | }): Promise<{ data?: PostThread; notFound?: true; tombstone?: true }> {
41 | const agent = new AtpAgent({
42 | service: "https://public.api.bsky.app",
43 | });
44 | try {
45 | let atUri = DEFAULT_URI;
46 |
47 | atUri = `at://${decodeURIComponent(params.did)}/app.bsky.feed.post/${decodeURIComponent(params.rkey)}`;
48 |
49 | const { data } = await agent.getPostThread({
50 | uri: atUri,
51 | depth: config.depth ?? 0,
52 | parentHeight: config.parentHeight ?? 0,
53 | });
54 |
55 | if (!AppBskyFeedDefs.isThreadViewPost(data.thread)) {
56 | return { notFound: true };
57 | }
58 | const pwiOptOut = !!data.thread.post.author.labels?.find(
59 | (label) => label.val === "!no-unauthenticated"
60 | );
61 | if (pwiOptOut) {
62 | // return { tombstone: true };
63 | throw new BlueskyApiError({
64 | message:
65 | "The author of this post has requested their posts not be displayed on external sites.",
66 | status: 400,
67 | data: null,
68 | });
69 | }
70 |
71 | return { data: JSON.parse(JSON.stringify(data.thread)) };
72 | } catch (error) {
73 | console.error(error);
74 |
75 | throw new BlueskyApiError({
76 | message: error instanceof Error ? error.message : "Invalid Bluesky URL",
77 | status: 500,
78 | data: "null",
79 | });
80 |
81 | // return { notFound: true };
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/src/api/get-postThread.ts:
--------------------------------------------------------------------------------
1 | import { fetchPostThread } from "./fetch-postThread.js";
2 | import type {
3 | PostThread,
4 | PostThreadConfig,
5 | PostThreadParams,
6 | } from "./types/index.js";
7 |
8 | export async function getPostThread(
9 | params: PostThreadParams,
10 | config?: PostThreadConfig
11 | ): Promise {
12 | const { data, notFound, tombstone } = await fetchPostThread({
13 | params,
14 | config,
15 | });
16 |
17 | if (notFound) {
18 | console.error(
19 | `The postThread ${JSON.stringify(params)} does not exist or has been deleted by the account owner. Update your code to remove this postThread when possible.`
20 | );
21 | } else if (tombstone) {
22 | console.error(
23 | `The postThread ${JSON.stringify(params)} has been made private by the account owner. Update your code to remove this thread post when possible.`
24 | );
25 | }
26 |
27 | return data;
28 | }
29 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/src/api/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./types/index.js";
2 | export * from "./fetch-postThread.js";
3 | export * from "./get-postThread.js";
4 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/src/api/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./postThread.js";
2 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/src/api/types/postThread.ts:
--------------------------------------------------------------------------------
1 | import { AppBskyFeedDefs } from "@atproto/api";
2 |
3 | /**
4 | * A postThread as returned by the the Bluesky API.
5 | */
6 | export interface PostThread extends AppBskyFeedDefs.ThreadViewPost {}
7 |
8 | export interface PostThreadParams {
9 | did: string;
10 | rkey: string;
11 | config?: {
12 | depth?: number;
13 | parentHeight?: number;
14 | };
15 | }
16 |
17 | export interface PostThreadConfig {
18 | depth?: number;
19 | parentHeight?: number;
20 | }
21 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./utils.js";
2 | export * from "./labels.js";
3 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/src/labels.ts:
--------------------------------------------------------------------------------
1 | import { AppBskyFeedDefs } from "@atproto/api";
2 |
3 | export const CONTENT_LABELS = ["porn", "sexual", "nudity", "graphic-media"];
4 |
5 | export function labelsToInfo(
6 | labels?: AppBskyFeedDefs.PostView["labels"]
7 | ): string | undefined {
8 | const label = labels?.find((label) => CONTENT_LABELS.includes(label.val));
9 |
10 | switch (label?.val) {
11 | case "porn":
12 | case "sexual":
13 | return "Adult Content";
14 | case "nudity":
15 | return "Non-sexual Nudity";
16 | case "graphic-media":
17 | return "Graphic Media";
18 | default:
19 | return undefined;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { AtUri } from "@atproto/api";
2 |
3 | export function niceDate(date: number | string | Date) {
4 | const d = new Date(date);
5 | return `${d.toLocaleDateString("en-us", {
6 | year: "numeric",
7 | month: "short",
8 | day: "numeric",
9 | })} at ${d.toLocaleTimeString(undefined, {
10 | hour: "numeric",
11 | minute: "2-digit",
12 | })}`;
13 | }
14 |
15 | export function getRkey({ uri }: { uri: string }): string {
16 | const at = new AtUri(uri);
17 | return at.rkey;
18 | }
19 |
20 | const formatter = new Intl.NumberFormat("en-US", {
21 | notation: "compact",
22 | maximumFractionDigits: 1,
23 | roundingMode: "trunc",
24 | });
25 |
26 | export function prettyNumber(number: number) {
27 | return formatter.format(number);
28 | }
29 |
--------------------------------------------------------------------------------
/packages/bluesky-embed-core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "outDir": "dist",
6 | "lib": ["dom", "dom.iterable", "esnext"],
7 | "allowJs": true,
8 | "skipLibCheck": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "declaration": true,
12 | "incremental": true,
13 | "esModuleInterop": true,
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve"
18 | },
19 | "include": ["global.d.ts", "src/**/*.ts", "src/**/*.tsx"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/.eslintignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 |
4 | # Production
5 | dist
6 |
7 | # Misc
8 | .DS_Store
9 | *.pem
10 | tsconfig.tsbuildinfo
11 |
12 | # Debug
13 | npm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 |
17 | # Vercel
18 | .vercel
19 |
20 | # Turborepo
21 | .turbo
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ["next"],
4 | rules: {
5 | "@next/next/no-html-link-for-pages": "off",
6 | "@next/next/no-img-element": "off",
7 | },
8 | };
9 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/.swcrc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/swcrc",
3 | "module": {
4 | "type": "es6"
5 | },
6 | "jsc": {
7 | "target": "es2018",
8 | "loose": true,
9 | "parser": {
10 | "syntax": "typescript",
11 | "tsx": true,
12 | "dynamicImport": true
13 | },
14 | "transform": {
15 | "react": {
16 | "runtime": "automatic",
17 | "throwIfNamespace": true,
18 | "development": false
19 | }
20 | },
21 | "externalHelpers": true
22 | },
23 | "minify": true
24 | }
25 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # react-bluesky-embed
2 |
3 | ## 0.5.0
4 |
5 | ### Minor Changes
6 |
7 | - better comment support
8 |
9 | ### Patch Changes
10 |
11 | - Updated dependencies
12 | - bluesky-embed-core@0.4.0
13 |
14 | ## 0.4.0
15 |
16 | ### Minor Changes
17 |
18 | - use client fix
19 |
20 | ### Patch Changes
21 |
22 | - Updated dependencies
23 | - bluesky-embed-core@0.3.0
24 |
25 | ## 0.3.0
26 |
27 | ### Minor Changes
28 |
29 | - Recursive comments
30 |
31 | ### Patch Changes
32 |
33 | - Updated dependencies
34 | - bluesky-embed-core@0.2.0
35 |
36 | ## 0.2.0
37 |
38 | ### Minor Changes
39 |
40 | - new release
41 |
42 | ### Patch Changes
43 |
44 | - Updated dependencies
45 | - bluesky-embed-core@0.1.0
46 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.module.css" {
2 | const classes: { readonly [key: string]: string };
3 | export default classes;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/license.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2024 Hichem Fantar.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-bluesky-embed",
3 | "version": "0.5.0",
4 | "repository": {
5 | "type": "git",
6 | "url": "https://github.com/hichemfantar/react-bluesky-embed.git",
7 | "directory": "packages/react-bluesky-embed"
8 | },
9 | "author": "Hichem Fantar (https://bsky.app/profile/opensauced.bsky.social)",
10 | "homepage": "https://react-bluesky-embed.vercel.app",
11 | "keywords": [
12 | "bluesky",
13 | "bsky",
14 | "embed",
15 | "react",
16 | "next",
17 | "rsc",
18 | "client",
19 | "ssr",
20 | "csr",
21 | "fast"
22 | ],
23 | "scripts": {
24 | "build": "pnpm build:swc && pnpm types",
25 | "build:swc": "postcss src/main.scss -o src/main.css && swc src -d dist --copy-files",
26 | "dev": "pnpm build:swc -w",
27 | "types": "tsc --emitDeclarationOnly",
28 | "lint": "TIMING=1 eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix",
29 | "clean": "rm -rf dist && rm -rf .turbo",
30 | "pcss": "postcss src/main.scss -o src/main.css -w"
31 | },
32 | "license": "MIT",
33 | "bugs": {
34 | "url": "https://github.com/hichemfantar/react-bluesky-embed/issues"
35 | },
36 | "sideEffects": [
37 | "./dist/bluesky-theme/postThread-container.js"
38 | ],
39 | "type": "module",
40 | "exports": {
41 | ".": {
42 | "react-server": "./dist/index.js",
43 | "default": "./dist/index.client.js"
44 | },
45 | "./api": "./dist/api/index.js",
46 | "./theme.css": "./dist/main.css"
47 | },
48 | "files": [
49 | "dist/**/*.{js,d.ts,css}"
50 | ],
51 | "typesVersions": {
52 | "*": {
53 | "index": [
54 | "src/index"
55 | ],
56 | "api": [
57 | "src/api/index"
58 | ],
59 | "*": []
60 | }
61 | },
62 | "publishConfig": {
63 | "access": "public",
64 | "typesVersions": {
65 | "*": {
66 | "index": [
67 | "dist/index.d.ts"
68 | ],
69 | "api": [
70 | "dist/api/index.d.ts"
71 | ],
72 | "*": []
73 | }
74 | }
75 | },
76 | "peerDependencies": {
77 | "react": ">= 18.0.0",
78 | "react-dom": ">= 18.0.0"
79 | },
80 | "dependencies": {
81 | "@atproto/api": "^0.13.17",
82 | "@swc/helpers": "^0.5.15",
83 | "bluesky-embed-core": "workspace:*",
84 | "swr": "^2.2.5"
85 | },
86 | "devDependencies": {
87 | "@swc/cli": "^0.1.65",
88 | "@swc/core": "^1.9.3",
89 | "@types/node": "20.10.5",
90 | "@types/react": "^18.3.12",
91 | "autoprefixer": "^10.4.16",
92 | "chokidar": "^3.5.3",
93 | "clsx": "^2.0.0",
94 | "eslint": "^8.57.1",
95 | "eslint-config-next": "^14.2.19",
96 | "postcss": "^8.4.32",
97 | "postcss-cli": "^11.0.0",
98 | "postcss-scss": "^4.0.9",
99 | "prettier": "^3.3.3",
100 | "prettier-plugin-tailwindcss": "^0.6.9",
101 | "tailwindcss": "^3.4.15",
102 | "typescript": "^5.7.2"
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | syntax: "postcss-scss",
3 | plugins: {
4 | "postcss-import": {},
5 | // 'tailwindcss/nesting': 'postcss-nesting',
6 | // require('postcss-nested'),
7 | "postcss-nested": {},
8 | // 'postcss-nesting': {},
9 | // 'tailwindcss/nesting': {},
10 | tailwindcss: {},
11 | autoprefixer: {},
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/readme.md:
--------------------------------------------------------------------------------
1 | # React Bluesky Embed
2 |
3 | React Bluesky Embed allows you to embed post threads, profiles, and comments in your React application when using Next.js, Create React App, Vite, and more.
4 |
5 | Profiles and comments support coming soon.
6 |
7 | Adapters for Solid, Vue, Angular, and Svelte are coming soon.
8 |
9 | 
10 |
11 | ## Documentation
12 |
13 | For documentation visit [react-bluesky-embed.vercel.app](https://react-bluesky-embed.vercel.app).
14 |
15 | ## Installation
16 |
17 | ```sh
18 | npm i react-bluesky-embed
19 | ```
20 |
21 | ## Usage
22 |
23 | ```tsx
24 |
39 | ```
40 |
41 | ## Contributing
42 |
43 | Visit our [contributing docs](https://react-bluesky-embed.vercel.app/contributing).
44 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/api/fetch-postThread.ts:
--------------------------------------------------------------------------------
1 | import { BlueskyApiError, fetchPostThread } from "bluesky-embed-core/api";
2 |
3 | export { BlueskyApiError, fetchPostThread };
4 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/api/get-postThread.ts:
--------------------------------------------------------------------------------
1 | import { getPostThread } from "bluesky-embed-core/api";
2 |
3 | export { getPostThread };
4 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/api/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./types/index.js";
2 | export * from "./fetch-postThread.js";
3 | export * from "./get-postThread.js";
4 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/api/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./postThread.js";
2 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/api/types/postThread.ts:
--------------------------------------------------------------------------------
1 | import {
2 | PostThread,
3 | PostThreadParams,
4 | PostThreadConfig,
5 | } from "bluesky-embed-core/api";
6 |
7 | export type { PostThread, PostThreadParams, PostThreadConfig };
8 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/assets/arrowBottom_stroke2_corner0_rounded.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/assets/bubble_filled_stroke2_corner2_rounded.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/assets/circleInfo_stroke2_corner0_rounded.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/assets/heart2_filled_stroke2_corner0_rounded.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/assets/play_filled_corner2_rounded.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/assets/repost_stroke2_corner2_rounded.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/assets/starterPack.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/comments.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AppBskyFeedDefs, AppBskyFeedPost } from "@atproto/api";
4 | import { useState } from "react";
5 | import { PostThread } from "../api/index.js";
6 | import { Embed } from "./embed.js";
7 | import { PostContent } from "./post.js";
8 |
9 | export const CommentSection = ({ thread }: { thread: PostThread }) => {
10 | const [, , did, _, rkey] = thread.post.uri.split("/");
11 | const postUrl = `https://bsky.app/profile/${did}/post/${rkey}`;
12 |
13 | const [error, setError] = useState(null);
14 | const [visibleCount, setVisibleCount] = useState(4);
15 |
16 | if (error) {
17 | return {error}
;
18 | }
19 |
20 | if (!thread) {
21 | return Loading comments...
;
22 | }
23 |
24 | const showMore = () => {
25 | setVisibleCount((prevCount) => prevCount + 4);
26 | };
27 |
28 | const sortedReplies = thread.replies?.sort(sortByLikes) ?? [];
29 |
30 | return (
31 |
101 | );
102 | };
103 |
104 | const Comment = ({ comment }: { comment: AppBskyFeedDefs.ThreadViewPost }) => {
105 | const author = comment.post.author;
106 | const avatarClassName = "size-6 shrink-0 rounded-full bg-gray-300";
107 |
108 | if (!AppBskyFeedPost.isRecord(comment.post.record)) return null;
109 |
110 | return (
111 |
112 |
160 | {comment.replies && comment.replies.length > 0 && (
161 |
162 | {comment.replies.sort(sortByLikes).map((reply) => {
163 | if (!AppBskyFeedDefs.isThreadViewPost(reply)) return null;
164 | return ;
165 | })}
166 |
167 | )}
168 |
169 | );
170 | };
171 | const Actions = ({ post }: { post: AppBskyFeedDefs.PostView }) => (
172 |
173 |
174 |
182 |
187 |
188 |
189 |
{post.replyCount ?? 0}
190 |
191 |
192 |
200 |
205 |
206 |
{post.repostCount ?? 0}
207 |
208 |
209 |
217 |
222 |
223 |
{post.likeCount ?? 0}
224 |
225 |
226 | );
227 |
228 | const sortByLikes = (a: unknown, b: unknown) => {
229 | if (
230 | !AppBskyFeedDefs.isThreadViewPost(a) ||
231 | !AppBskyFeedDefs.isThreadViewPost(b)
232 | ) {
233 | return 0;
234 | }
235 | return (b.post.likeCount ?? 0) - (a.post.likeCount ?? 0);
236 | };
237 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/components.tsx:
--------------------------------------------------------------------------------
1 | export * from "./container.js";
2 | export * from "./embed.js";
3 | export * from "./link.js";
4 | export * from "./post.js";
5 | export * from "./embedded-postThread.js";
6 | export * from "./postThread-skeleton.js";
7 | export * from "./postThread-not-found.js";
8 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/container.tsx:
--------------------------------------------------------------------------------
1 | import { Theme } from "../utils.js";
2 | import { Link } from "./link.js";
3 | import { ReactNode } from "react";
4 | import { ThemeContainer } from "./theme-container.js";
5 |
6 | export function Container({
7 | children,
8 | href,
9 | theme = "light",
10 | }: {
11 | children: ReactNode;
12 | href?: string;
13 | theme?: Theme;
14 | }) {
15 | return (
16 |
17 |
18 | {/* {href &&
} */}
19 |
{children}
20 |
21 |
22 | );
23 | }
24 |
25 | // import { ComponentChildren, h } from 'preact'
26 |
27 | // import { Link } from './link'
28 | // import a from './container.module.css'
29 |
30 | // export function Container({
31 | // children,
32 | // href,
33 | // }: {
34 | // children: ComponentChildren
35 | // href?: string
36 | // }) {
37 | // return (
38 | //
39 | //
40 | // {href &&
}
41 | //
{children}
42 | //
43 | //
44 | // )
45 | // }
46 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/embed.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AppBskyEmbedExternal,
3 | AppBskyEmbedImages,
4 | AppBskyEmbedRecord,
5 | AppBskyEmbedRecordWithMedia,
6 | AppBskyEmbedVideo,
7 | AppBskyFeedDefs,
8 | AppBskyFeedPost,
9 | AppBskyGraphDefs,
10 | AppBskyGraphStarterpack,
11 | AppBskyLabelerDefs,
12 | } from "@atproto/api";
13 |
14 | import { CONTENT_LABELS, labelsToInfo } from "./labels.js";
15 | import { getRkey } from "./utils.js";
16 | import { Link } from "./link.js";
17 | import { ReactNode, useMemo } from "react";
18 |
19 | const InfoIcon = (props: React.SVGProps) => (
20 |
26 |
32 |
33 | );
34 |
35 | const PlayIconSvgComponent = (props: React.SVGProps) => (
36 |
42 |
46 |
47 | );
48 |
49 | const StarterPackIcon = (props: React.SVGProps) => (
50 |
56 |
57 |
65 |
66 |
67 |
68 |
69 |
75 |
81 |
82 | );
83 |
84 | export function Embed({
85 | content,
86 | labels,
87 | hideRecord,
88 | }: {
89 | content: AppBskyFeedDefs.PostView["embed"];
90 | labels: AppBskyFeedDefs.PostView["labels"];
91 | hideRecord?: boolean;
92 | }) {
93 | // TODO: usememo causes an issue on ssg
94 | // const labelInfo = useMemo(() => labelsToInfo(labels), [labels]);
95 | const labelInfo = labelsToInfo(labels);
96 |
97 | if (!content) return null;
98 |
99 | try {
100 | // Case 1: Image
101 | if (AppBskyEmbedImages.isView(content)) {
102 | return ;
103 | }
104 |
105 | // Case 2: External link
106 | if (AppBskyEmbedExternal.isView(content)) {
107 | return ;
108 | }
109 |
110 | // Case 3: Record (quote or linked post)
111 | if (AppBskyEmbedRecord.isView(content)) {
112 | if (hideRecord) {
113 | return null;
114 | }
115 |
116 | const record = content.record;
117 |
118 | // Case 3.1: Post
119 | if (AppBskyEmbedRecord.isViewRecord(record)) {
120 | const pwiOptOut = !!record.author.labels?.find(
121 | (label) => label.val === "!no-unauthenticated"
122 | );
123 | if (pwiOptOut) {
124 | return (
125 |
126 | The author of the quoted post has requested their posts not be
127 | displayed on external sites.
128 |
129 | );
130 | }
131 |
132 | let text;
133 | if (AppBskyFeedPost.isRecord(record.value)) {
134 | text = record.value.text;
135 | }
136 |
137 | const isAuthorLabeled = record.author.labels?.some((label) =>
138 | CONTENT_LABELS.includes(label.val)
139 | );
140 |
141 | return (
142 |
146 |
147 |
148 |
156 |
157 |
158 | {record.author.displayName}
159 |
160 | @{record.author.handle}
161 |
162 |
163 |
164 | {text && {text}
}
165 | {record.embeds?.map((embed) => (
166 |
172 | ))}
173 |
174 | );
175 | }
176 |
177 | // Case 3.2: List
178 | if (AppBskyGraphDefs.isListView(record)) {
179 | return (
180 |
191 | );
192 | }
193 |
194 | // Case 3.3: Feed
195 | if (AppBskyFeedDefs.isGeneratorView(record)) {
196 | return (
197 |
204 | );
205 | }
206 |
207 | // Case 3.4: Labeler
208 | if (AppBskyLabelerDefs.isLabelerView(record)) {
209 | // Embed type does not exist in the app, so show nothing
210 | return null;
211 | }
212 |
213 | // Case 3.5: Starter pack
214 | if (AppBskyGraphDefs.isStarterPackViewBasic(record)) {
215 | return ;
216 | }
217 |
218 | // Case 3.6: Post not found
219 | if (AppBskyEmbedRecord.isViewNotFound(record)) {
220 | return Quoted post not found, it may have been deleted. ;
221 | }
222 |
223 | // Case 3.7: Post blocked
224 | if (AppBskyEmbedRecord.isViewBlocked(record)) {
225 | return The quoted post is blocked. ;
226 | }
227 |
228 | // Case 3.8: Detached quote post
229 | if (AppBskyEmbedRecord.isViewDetached(record)) {
230 | // Just don't show anything
231 | return null;
232 | }
233 |
234 | // Unknown embed type
235 | return null;
236 | }
237 |
238 | // Case 4: Video
239 | if (AppBskyEmbedVideo.isView(content)) {
240 | return ;
241 | }
242 |
243 | // Case 5: Record with media
244 | if (
245 | AppBskyEmbedRecordWithMedia.isView(content) &&
246 | AppBskyEmbedRecord.isViewRecord(content.record.record)
247 | ) {
248 | return (
249 |
250 |
255 |
263 |
264 | );
265 | }
266 |
267 | // Unknown embed type
268 | return null;
269 | } catch (err) {
270 | return (
271 | {err instanceof Error ? err.message : "An error occurred"}
272 | );
273 | }
274 | }
275 |
276 | function Info({ children }: { children: ReactNode }) {
277 | return (
278 |
279 |
280 |
{children}
281 |
282 | );
283 | }
284 |
285 | function ImageEmbed({
286 | content,
287 | labelInfo,
288 | }: {
289 | content: AppBskyEmbedImages.View;
290 | labelInfo?: string;
291 | }) {
292 | if (labelInfo) {
293 | return {labelInfo} ;
294 | }
295 |
296 | switch (content.images.length) {
297 | case 1:
298 | return (
299 |
305 | );
306 | case 2:
307 | return (
308 |
309 | {content.images.map((image, i) => (
310 |
316 | ))}
317 |
318 | );
319 | case 3:
320 | return (
321 |
322 |
328 |
329 | {content.images.slice(1).map((image, i) => (
330 |
337 | ))}
338 |
339 |
340 | );
341 | case 4:
342 | return (
343 |
344 | {content.images.map((image, i) => (
345 |
352 | ))}
353 |
354 | );
355 | default:
356 | return null;
357 | }
358 | }
359 |
360 | function ExternalEmbed({
361 | content,
362 | labelInfo,
363 | }: {
364 | content: AppBskyEmbedExternal.View;
365 | labelInfo?: string;
366 | }) {
367 | function toNiceDomain(url: string): string {
368 | try {
369 | const urlp = new URL(url);
370 | return urlp.host ? urlp.host : url;
371 | } catch (e) {
372 | return url;
373 | }
374 | }
375 |
376 | if (labelInfo) {
377 | return {labelInfo} ;
378 | }
379 |
380 | return (
381 |
386 | {content.external.thumb && (
387 |
393 | )}
394 |
395 |
396 | {toNiceDomain(content.external.uri)}
397 |
398 |
{content.external.title}
399 |
400 | {content.external.description}
401 |
402 |
403 |
404 | );
405 | }
406 |
407 | function GenericWithImageEmbed({
408 | title,
409 | subtitle,
410 | href,
411 | image,
412 | description,
413 | }: {
414 | title: string;
415 | subtitle: string;
416 | href: string;
417 | image?: string;
418 | description?: string;
419 | }) {
420 | return (
421 |
425 |
426 | {image ? (
427 |
433 | ) : (
434 |
435 | )}
436 |
437 |
{title}
438 |
439 | {subtitle}
440 |
441 |
442 |
443 | {description && (
444 |
445 | {description}
446 |
447 | )}
448 |
449 | );
450 | }
451 |
452 | // just the thumbnail and a play button
453 | function VideoEmbed({ content }: { content: AppBskyEmbedVideo.View }) {
454 | let aspectRatio = 1;
455 |
456 | if (content.aspectRatio) {
457 | const { width, height } = content.aspectRatio;
458 | aspectRatio = clamp(width / height, 1 / 1, 3 / 1);
459 | }
460 |
461 | return (
462 |
466 |
472 |
475 |
476 | );
477 | }
478 |
479 | function StarterPackEmbed({
480 | content,
481 | }: {
482 | content: AppBskyGraphDefs.StarterPackViewBasic;
483 | }) {
484 | if (!AppBskyGraphStarterpack.isRecord(content.record)) {
485 | return null;
486 | }
487 |
488 | const starterPackHref = getStarterPackHref(content);
489 | const imageUri = getStarterPackImage(content);
490 |
491 | return (
492 |
496 |
502 |
503 |
504 |
505 |
506 |
507 | {content.record.name}
508 |
509 |
510 | Starter pack by{" "}
511 | {content.creator.displayName || `@${content.creator.handle}`}
512 |
513 |
514 |
515 | {content.record.description && (
516 |
{content.record.description}
517 | )}
518 | {!!content.joinedAllTimeCount && content.joinedAllTimeCount > 50 && (
519 |
520 | {content.joinedAllTimeCount} users have joined!
521 |
522 | )}
523 |
524 |
525 | );
526 | }
527 |
528 | // from #/lib/strings/starter-pack.ts
529 | function getStarterPackImage(starterPack: AppBskyGraphDefs.StarterPackView) {
530 | const rkey = getRkey({ uri: starterPack.uri });
531 | return `https://ogcard.cdn.bsky.app/start/${starterPack.creator.did}/${rkey}`;
532 | }
533 |
534 | function getStarterPackHref(
535 | starterPack: AppBskyGraphDefs.StarterPackViewBasic
536 | ) {
537 | const rkey = getRkey({ uri: starterPack.uri });
538 | const handleOrDid = starterPack.creator.handle || starterPack.creator.did;
539 | return `/starter-pack/${handleOrDid}/${rkey}`;
540 | }
541 |
542 | function clamp(num: number, min: number, max: number) {
543 | return Math.max(min, Math.min(num, max));
544 | }
545 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/embedded-postThread.tsx:
--------------------------------------------------------------------------------
1 | import type { PostThread } from "../api/index.js";
2 | import { Post } from "./post.js";
3 | import "../main.css";
4 | import { Theme } from "../utils.js";
5 |
6 | type Props = {
7 | postThread: PostThread;
8 | theme?: Theme;
9 | hidePost?: boolean;
10 | };
11 |
12 | export const EmbeddedPostThread = ({
13 | postThread,
14 | theme = "light",
15 | hidePost,
16 | }: Props) => {
17 | // useMemo does nothing for RSC but it helps when the component is used in the client (e.g by SWR)
18 | // const postThread = useMemo(() => enrichPostThread(t), [t])
19 | const thread = postThread;
20 |
21 | return (
22 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/icons.tsx:
--------------------------------------------------------------------------------
1 | export const RepostIcon = (props: React.SVGProps) => (
2 |
9 |
13 |
14 | );
15 |
16 | export const BlueskyLogo = (props: React.SVGProps) => (
17 |
24 |
28 |
29 | );
30 |
31 | export const ReplyIcon = (props: React.SVGProps) => (
32 |
39 |
43 |
44 | );
45 | export const LikeIcon = (props: React.SVGProps) => (
46 |
53 |
57 |
58 | );
59 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/labels.ts:
--------------------------------------------------------------------------------
1 | import { labelsToInfo, CONTENT_LABELS } from "bluesky-embed-core";
2 |
3 | export { labelsToInfo, CONTENT_LABELS };
4 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/link.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export function Link({
4 | href,
5 | className,
6 | disableTracking,
7 | ...props
8 | }: {
9 | href: string;
10 | className?: string;
11 | disableTracking?: boolean;
12 | } & React.DetailedHTMLProps<
13 | React.AnchorHTMLAttributes,
14 | HTMLAnchorElement
15 | >) {
16 | // const searchParam = new URLSearchParams(window.location.search)
17 | // const ref_url = searchParam.get('ref_url')
18 |
19 | // const newSearchParam = new URLSearchParams()
20 | // newSearchParam.set('ref_src', 'embed')
21 | // if (ref_url) {
22 | // newSearchParam.set('ref_url', ref_url)
23 | // }
24 |
25 | return (
26 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/post.tsx:
--------------------------------------------------------------------------------
1 | import { AppBskyFeedPost, AppBskyRichtextFacet, RichText } from "@atproto/api";
2 |
3 | import { PostThread } from "../api/index.js";
4 | import { Theme } from "../utils.js";
5 | import { CommentSection } from "./comments.js";
6 | import { Container } from "./container.js";
7 | import { Embed } from "./embed.js";
8 | import { BlueskyLogo, LikeIcon, ReplyIcon, RepostIcon } from "./icons.js";
9 | import { CONTENT_LABELS } from "./labels.js";
10 | import { Link } from "./link.js";
11 | import { getRkey, niceDate, prettyNumber } from "./utils.js";
12 |
13 | interface Props {
14 | thread: PostThread;
15 | theme: Theme;
16 | hidePost?: boolean;
17 | }
18 |
19 | export function Post({ thread, theme, hidePost = false }: Props) {
20 | const post = thread.post;
21 |
22 | const isAuthorLabeled = post.author.labels?.some((label) =>
23 | CONTENT_LABELS.includes(label.val)
24 | );
25 |
26 | let record: AppBskyFeedPost.Record | null = null;
27 | if (AppBskyFeedPost.isRecord(post.record)) {
28 | record = post.record;
29 | }
30 |
31 | const href = `/profile/${post.author.did}/post/${getRkey(post)}`;
32 |
33 | return (
34 |
35 |
36 | {!hidePost && (
37 |
38 |
39 |
43 |
44 |
52 |
53 |
54 |
55 |
59 |
{post.author.displayName}
60 |
61 |
65 |
@{post.author.handle}
66 |
67 |
68 |
69 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
84 | {niceDate(post.indexedAt)}
85 |
86 |
87 |
91 | {!!post.likeCount && (
92 |
93 |
94 |
95 | {prettyNumber(post.likeCount)}
96 |
97 |
98 | )}
99 | {!!post.repostCount && (
100 |
101 |
102 |
103 | {prettyNumber(post.repostCount)}
104 |
105 |
106 | )}
107 |
108 | {/*
*/}
109 |
110 |
111 | Reply
112 |
113 |
114 |
115 |
116 | {post.replyCount
117 | ? `Read ${prettyNumber(post.replyCount)} ${
118 | post.replyCount > 1 ? "replies" : "reply"
119 | } on Bluesky`
120 | : `View on Bluesky`}
121 |
122 |
123 | View on
124 | Bluesky
125 |
126 |
127 |
128 | )}
129 |
130 |
131 |
132 | );
133 | }
134 |
135 | export function PostContent({
136 | record,
137 | isComment = false,
138 | }: {
139 | record: AppBskyFeedPost.Record | null;
140 | isComment?: boolean;
141 | }) {
142 | if (!record) return null;
143 |
144 | const rt = new RichText({
145 | text: record.text,
146 | facets: record.facets,
147 | });
148 |
149 | const richText = [];
150 |
151 | let counter = 0;
152 | for (const segment of rt.segments()) {
153 | if (
154 | segment.link &&
155 | AppBskyRichtextFacet.validateLink(segment.link).success
156 | ) {
157 | richText.push(
158 |
167 | {segment.text}
168 |
169 | );
170 | } else if (
171 | segment.mention &&
172 | AppBskyRichtextFacet.validateMention(segment.mention).success
173 | ) {
174 | richText.push(
175 |
180 | {segment.text}
181 |
182 | );
183 | } else if (
184 | segment.tag &&
185 | AppBskyRichtextFacet.validateTag(segment.tag).success
186 | ) {
187 | richText.push(
188 |
193 | {segment.text}
194 |
195 | );
196 | } else {
197 | richText.push(segment.text);
198 | }
199 |
200 | counter++;
201 | }
202 |
203 | if (isComment)
204 | return (
205 |
206 | {richText}
207 |
208 | );
209 |
210 | return (
211 |
216 | {richText}
217 |
218 | );
219 | }
220 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/postThread-not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Theme } from "../utils";
2 | import { ThemeContainer } from "./theme-container.js";
3 |
4 | // TODO: handle error properly
5 |
6 | export const PostThreadNotFound = ({
7 | error,
8 | theme,
9 | }: {
10 | error?: string;
11 | theme: Theme;
12 | }) => {
13 | return (
14 |
15 |
16 |
17 | Post Thread not found
18 |
19 |
20 | The embedded post thread could not be found...
21 | {/* {JSON.stringify(error)} */}
22 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/postThread-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Theme } from "../utils";
2 | import { ThemeContainer } from "./theme-container.js";
3 |
4 | export const PostThreadSkeleton = ({ theme = "light" }: { theme?: Theme }) => {
5 | return (
6 |
7 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/theme-container.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Theme } from "../utils.js";
3 |
4 | export function ThemeContainer({
5 | children,
6 | theme = "light",
7 | }: {
8 | children: ReactNode;
9 | theme?: Theme;
10 | }) {
11 | return (
12 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/bluesky-theme/utils.ts:
--------------------------------------------------------------------------------
1 | import { niceDate, getRkey, prettyNumber } from "bluesky-embed-core";
2 |
3 | export { niceDate, getRkey, prettyNumber };
4 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/hooks.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import swr from "swr";
5 | import {
6 | getPostThread,
7 | PostThreadConfig,
8 | PostThreadParams,
9 | } from "./api/index.js";
10 |
11 | // Avoids an error when used in the pages directory where useSWR might be in `default`.
12 | const useSWR = ((swr as any).default as typeof swr) || swr;
13 |
14 | async function fetcher([params, config]: [PostThreadParams, PostThreadConfig]) {
15 | const res = await getPostThread(
16 | {
17 | did: params.did,
18 | rkey: params.rkey,
19 | },
20 | config
21 | );
22 | return res;
23 |
24 | // // We return null in case `json.data` is undefined, that way we can check for "loading" by
25 | // // checking if data is `undefined`. `null` means it was fetched.
26 | // if (res.ok) return json.data || null;
27 |
28 | // throw new BlueskyApiError({
29 | // message: `Failed to fetch postThread at "${url}" with "${res.status}".`,
30 | // data: json,
31 | // status: res.status,
32 | // });
33 | }
34 |
35 | /**
36 | * SWR hook for fetching a postThread in the browser.
37 | */
38 | export const usePostThread = (
39 | params: PostThreadParams,
40 | config?: PostThreadConfig
41 | ) => {
42 | const { isLoading, data, error } = useSWR(() => [params, config], fetcher, {
43 | revalidateIfStale: false,
44 | revalidateOnFocus: false,
45 | shouldRetryOnError: false,
46 | });
47 |
48 | return {
49 | // If data is `undefined` then it might be the first render where SWR hasn't started doing
50 | // any work, so we set `isLoading` to `true`.
51 | isLoading: Boolean(isLoading || (data === undefined && !error)),
52 | data,
53 | error,
54 | };
55 | };
56 |
57 | export const useMounted = () => {
58 | const [mounted, setMounted] = useState(false);
59 |
60 | useEffect(() => setMounted(true), []);
61 |
62 | return mounted;
63 | };
64 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/index.client.ts:
--------------------------------------------------------------------------------
1 | export * from "./bluesky-theme/components.js";
2 | export * from "./swr.js";
3 | export * from "./utils.js";
4 | export * from "./hooks.js";
5 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/index.ts:
--------------------------------------------------------------------------------
1 | // Export every other component that's part of our default theme (the Bluesky theme) as that
2 | // can be useful for anyone that wans to do more deep edits in the default theme.
3 | export * from "./bluesky-theme/components";
4 | export * from "./postThread.js";
5 | export * from "./utils.js";
6 | export * from "./hooks.js";
7 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/main.css:
--------------------------------------------------------------------------------
1 | #bluesky-embed *, #bluesky-embed ::before, #bluesky-embed ::after {
2 | --tw-border-spacing-x: 0;
3 | --tw-border-spacing-y: 0;
4 | --tw-translate-x: 0;
5 | --tw-translate-y: 0;
6 | --tw-rotate: 0;
7 | --tw-skew-x: 0;
8 | --tw-skew-y: 0;
9 | --tw-scale-x: 1;
10 | --tw-scale-y: 1;
11 | --tw-pan-x: ;
12 | --tw-pan-y: ;
13 | --tw-pinch-zoom: ;
14 | --tw-scroll-snap-strictness: proximity;
15 | --tw-gradient-from-position: ;
16 | --tw-gradient-via-position: ;
17 | --tw-gradient-to-position: ;
18 | --tw-ordinal: ;
19 | --tw-slashed-zero: ;
20 | --tw-numeric-figure: ;
21 | --tw-numeric-spacing: ;
22 | --tw-numeric-fraction: ;
23 | --tw-ring-inset: ;
24 | --tw-ring-offset-width: 0px;
25 | --tw-ring-offset-color: #fff;
26 | --tw-ring-color: rgb(59 130 246 / 0.5);
27 | --tw-ring-offset-shadow: 0 0 #0000;
28 | --tw-ring-shadow: 0 0 #0000;
29 | --tw-shadow: 0 0 #0000;
30 | --tw-shadow-colored: 0 0 #0000;
31 | --tw-blur: ;
32 | --tw-brightness: ;
33 | --tw-contrast: ;
34 | --tw-grayscale: ;
35 | --tw-hue-rotate: ;
36 | --tw-invert: ;
37 | --tw-saturate: ;
38 | --tw-sepia: ;
39 | --tw-drop-shadow: ;
40 | --tw-backdrop-blur: ;
41 | --tw-backdrop-brightness: ;
42 | --tw-backdrop-contrast: ;
43 | --tw-backdrop-grayscale: ;
44 | --tw-backdrop-hue-rotate: ;
45 | --tw-backdrop-invert: ;
46 | --tw-backdrop-opacity: ;
47 | --tw-backdrop-saturate: ;
48 | --tw-backdrop-sepia: ;
49 | --tw-contain-size: ;
50 | --tw-contain-layout: ;
51 | --tw-contain-paint: ;
52 | --tw-contain-style: ;
53 | }
54 | #bluesky-embed ::backdrop {
55 | --tw-border-spacing-x: 0;
56 | --tw-border-spacing-y: 0;
57 | --tw-translate-x: 0;
58 | --tw-translate-y: 0;
59 | --tw-rotate: 0;
60 | --tw-skew-x: 0;
61 | --tw-skew-y: 0;
62 | --tw-scale-x: 1;
63 | --tw-scale-y: 1;
64 | --tw-pan-x: ;
65 | --tw-pan-y: ;
66 | --tw-pinch-zoom: ;
67 | --tw-scroll-snap-strictness: proximity;
68 | --tw-gradient-from-position: ;
69 | --tw-gradient-via-position: ;
70 | --tw-gradient-to-position: ;
71 | --tw-ordinal: ;
72 | --tw-slashed-zero: ;
73 | --tw-numeric-figure: ;
74 | --tw-numeric-spacing: ;
75 | --tw-numeric-fraction: ;
76 | --tw-ring-inset: ;
77 | --tw-ring-offset-width: 0px;
78 | --tw-ring-offset-color: #fff;
79 | --tw-ring-color: rgb(59 130 246 / 0.5);
80 | --tw-ring-offset-shadow: 0 0 #0000;
81 | --tw-ring-shadow: 0 0 #0000;
82 | --tw-shadow: 0 0 #0000;
83 | --tw-shadow-colored: 0 0 #0000;
84 | --tw-blur: ;
85 | --tw-brightness: ;
86 | --tw-contrast: ;
87 | --tw-grayscale: ;
88 | --tw-hue-rotate: ;
89 | --tw-invert: ;
90 | --tw-saturate: ;
91 | --tw-sepia: ;
92 | --tw-drop-shadow: ;
93 | --tw-backdrop-blur: ;
94 | --tw-backdrop-brightness: ;
95 | --tw-backdrop-contrast: ;
96 | --tw-backdrop-grayscale: ;
97 | --tw-backdrop-hue-rotate: ;
98 | --tw-backdrop-invert: ;
99 | --tw-backdrop-opacity: ;
100 | --tw-backdrop-saturate: ;
101 | --tw-backdrop-sepia: ;
102 | --tw-contain-size: ;
103 | --tw-contain-layout: ;
104 | --tw-contain-paint: ;
105 | --tw-contain-style: ;
106 | }
107 | /*
108 | ! tailwindcss v3.4.15 | MIT License | https://tailwindcss.com
109 | */
110 | /*
111 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
112 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
113 | */
114 | #bluesky-embed *,
115 | #bluesky-embed ::before,
116 | #bluesky-embed ::after {
117 | box-sizing: border-box; /* 1 */
118 | border-width: 0; /* 2 */
119 | border-style: solid; /* 2 */
120 | border-color: #e5e7eb; /* 2 */
121 | }
122 | #bluesky-embed ::before,
123 | #bluesky-embed ::after {
124 | --tw-content: '';
125 | }
126 | /*
127 | 1. Use a consistent sensible line-height in all browsers.
128 | 2. Prevent adjustments of font size after orientation changes in iOS.
129 | 3. Use a more readable tab size.
130 | 4. Use the user's configured `sans` font-family by default.
131 | 5. Use the user's configured `sans` font-feature-settings by default.
132 | 6. Use the user's configured `sans` font-variation-settings by default.
133 | 7. Disable tap highlights on iOS
134 | */
135 | #bluesky-embed html,
136 | #bluesky-embed :host {
137 | line-height: 1.5; /* 1 */
138 | -webkit-text-size-adjust: 100%; /* 2 */
139 | -moz-tab-size: 4; /* 3 */
140 | -o-tab-size: 4;
141 | tab-size: 4; /* 3 */
142 | font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */
143 | font-feature-settings: normal; /* 5 */
144 | font-variation-settings: normal; /* 6 */
145 | -webkit-tap-highlight-color: transparent; /* 7 */
146 | }
147 | /*
148 | 1. Remove the margin in all browsers.
149 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
150 | */
151 | #bluesky-embed body {
152 | margin: 0; /* 1 */
153 | line-height: inherit; /* 2 */
154 | }
155 | /*
156 | 1. Add the correct height in Firefox.
157 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
158 | 3. Ensure horizontal rules are visible by default.
159 | */
160 | #bluesky-embed hr {
161 | height: 0; /* 1 */
162 | color: inherit; /* 2 */
163 | border-top-width: 1px; /* 3 */
164 | }
165 | /*
166 | Add the correct text decoration in Chrome, Edge, and Safari.
167 | */
168 | #bluesky-embed abbr:where([title]) {
169 | -webkit-text-decoration: underline dotted;
170 | text-decoration: underline dotted;
171 | }
172 | /*
173 | Remove the default font size and weight for headings.
174 | */
175 | #bluesky-embed h1,
176 | #bluesky-embed h2,
177 | #bluesky-embed h3,
178 | #bluesky-embed h4,
179 | #bluesky-embed h5,
180 | #bluesky-embed h6 {
181 | font-size: inherit;
182 | font-weight: inherit;
183 | }
184 | /*
185 | Reset links to optimize for opt-in styling instead of opt-out.
186 | */
187 | #bluesky-embed a {
188 | color: inherit;
189 | text-decoration: inherit;
190 | }
191 | /*
192 | Add the correct font weight in Edge and Safari.
193 | */
194 | #bluesky-embed b,
195 | #bluesky-embed strong {
196 | font-weight: bolder;
197 | }
198 | /*
199 | 1. Use the user's configured `mono` font-family by default.
200 | 2. Use the user's configured `mono` font-feature-settings by default.
201 | 3. Use the user's configured `mono` font-variation-settings by default.
202 | 4. Correct the odd `em` font sizing in all browsers.
203 | */
204 | #bluesky-embed code,
205 | #bluesky-embed kbd,
206 | #bluesky-embed samp,
207 | #bluesky-embed pre {
208 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* 1 */
209 | font-feature-settings: normal; /* 2 */
210 | font-variation-settings: normal; /* 3 */
211 | font-size: 1em; /* 4 */
212 | }
213 | /*
214 | Add the correct font size in all browsers.
215 | */
216 | #bluesky-embed small {
217 | font-size: 80%;
218 | }
219 | /*
220 | Prevent `sub` and `sup` elements from affecting the line height in all browsers.
221 | */
222 | #bluesky-embed sub,
223 | #bluesky-embed sup {
224 | font-size: 75%;
225 | line-height: 0;
226 | position: relative;
227 | vertical-align: baseline;
228 | }
229 | #bluesky-embed sub {
230 | bottom: -0.25em;
231 | }
232 | #bluesky-embed sup {
233 | top: -0.5em;
234 | }
235 | /*
236 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
237 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
238 | 3. Remove gaps between table borders by default.
239 | */
240 | #bluesky-embed table {
241 | text-indent: 0; /* 1 */
242 | border-color: inherit; /* 2 */
243 | border-collapse: collapse; /* 3 */
244 | }
245 | /*
246 | 1. Change the font styles in all browsers.
247 | 2. Remove the margin in Firefox and Safari.
248 | 3. Remove default padding in all browsers.
249 | */
250 | #bluesky-embed button,
251 | #bluesky-embed input,
252 | #bluesky-embed optgroup,
253 | #bluesky-embed select,
254 | #bluesky-embed textarea {
255 | font-family: inherit; /* 1 */
256 | font-feature-settings: inherit; /* 1 */
257 | font-variation-settings: inherit; /* 1 */
258 | font-size: 100%; /* 1 */
259 | font-weight: inherit; /* 1 */
260 | line-height: inherit; /* 1 */
261 | letter-spacing: inherit; /* 1 */
262 | color: inherit; /* 1 */
263 | margin: 0; /* 2 */
264 | padding: 0; /* 3 */
265 | }
266 | /*
267 | Remove the inheritance of text transform in Edge and Firefox.
268 | */
269 | #bluesky-embed button,
270 | #bluesky-embed select {
271 | text-transform: none;
272 | }
273 | /*
274 | 1. Correct the inability to style clickable types in iOS and Safari.
275 | 2. Remove default button styles.
276 | */
277 | #bluesky-embed button,
278 | #bluesky-embed input:where([type='button']),
279 | #bluesky-embed input:where([type='reset']),
280 | #bluesky-embed input:where([type='submit']) {
281 | -webkit-appearance: button; /* 1 */
282 | background-color: transparent; /* 2 */
283 | background-image: none; /* 2 */
284 | }
285 | /*
286 | Use the modern Firefox focus style for all focusable elements.
287 | */
288 | #bluesky-embed :-moz-focusring {
289 | outline: auto;
290 | }
291 | /*
292 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
293 | */
294 | #bluesky-embed :-moz-ui-invalid {
295 | box-shadow: none;
296 | }
297 | /*
298 | Add the correct vertical alignment in Chrome and Firefox.
299 | */
300 | #bluesky-embed progress {
301 | vertical-align: baseline;
302 | }
303 | /*
304 | Correct the cursor style of increment and decrement buttons in Safari.
305 | */
306 | #bluesky-embed ::-webkit-inner-spin-button,
307 | #bluesky-embed ::-webkit-outer-spin-button {
308 | height: auto;
309 | }
310 | /*
311 | 1. Correct the odd appearance in Chrome and Safari.
312 | 2. Correct the outline style in Safari.
313 | */
314 | #bluesky-embed [type='search'] {
315 | -webkit-appearance: textfield; /* 1 */
316 | outline-offset: -2px; /* 2 */
317 | }
318 | /*
319 | Remove the inner padding in Chrome and Safari on macOS.
320 | */
321 | #bluesky-embed ::-webkit-search-decoration {
322 | -webkit-appearance: none;
323 | }
324 | /*
325 | 1. Correct the inability to style clickable types in iOS and Safari.
326 | 2. Change font properties to `inherit` in Safari.
327 | */
328 | #bluesky-embed ::-webkit-file-upload-button {
329 | -webkit-appearance: button; /* 1 */
330 | font: inherit; /* 2 */
331 | }
332 | /*
333 | Add the correct display in Chrome and Safari.
334 | */
335 | #bluesky-embed summary {
336 | display: list-item;
337 | }
338 | /*
339 | Removes the default spacing and border for appropriate elements.
340 | */
341 | #bluesky-embed blockquote,
342 | #bluesky-embed dl,
343 | #bluesky-embed dd,
344 | #bluesky-embed h1,
345 | #bluesky-embed h2,
346 | #bluesky-embed h3,
347 | #bluesky-embed h4,
348 | #bluesky-embed h5,
349 | #bluesky-embed h6,
350 | #bluesky-embed hr,
351 | #bluesky-embed figure,
352 | #bluesky-embed p,
353 | #bluesky-embed pre {
354 | margin: 0;
355 | }
356 | #bluesky-embed fieldset {
357 | margin: 0;
358 | padding: 0;
359 | }
360 | #bluesky-embed legend {
361 | padding: 0;
362 | }
363 | #bluesky-embed ol,
364 | #bluesky-embed ul,
365 | #bluesky-embed menu {
366 | list-style: none;
367 | margin: 0;
368 | padding: 0;
369 | }
370 | /*
371 | Reset default styling for dialogs.
372 | */
373 | #bluesky-embed dialog {
374 | padding: 0;
375 | }
376 | /*
377 | Prevent resizing textareas horizontally by default.
378 | */
379 | #bluesky-embed textarea {
380 | resize: vertical;
381 | }
382 | /*
383 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
384 | 2. Set the default placeholder color to the user's configured gray 400 color.
385 | */
386 | #bluesky-embed input::-moz-placeholder, #bluesky-embed textarea::-moz-placeholder {
387 | opacity: 1; /* 1 */
388 | color: #9ca3af; /* 2 */
389 | }
390 | #bluesky-embed input::placeholder,
391 | #bluesky-embed textarea::placeholder {
392 | opacity: 1; /* 1 */
393 | color: #9ca3af; /* 2 */
394 | }
395 | /*
396 | Set the default cursor for buttons.
397 | */
398 | #bluesky-embed button,
399 | #bluesky-embed [role="button"] {
400 | cursor: pointer;
401 | }
402 | /*
403 | Make sure disabled buttons don't get the pointer cursor.
404 | */
405 | #bluesky-embed :disabled {
406 | cursor: default;
407 | }
408 | /*
409 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
410 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
411 | This can trigger a poorly considered lint error in some tools but is included by design.
412 | */
413 | #bluesky-embed img,
414 | #bluesky-embed svg,
415 | #bluesky-embed video,
416 | #bluesky-embed canvas,
417 | #bluesky-embed audio,
418 | #bluesky-embed iframe,
419 | #bluesky-embed embed,
420 | #bluesky-embed object {
421 | display: block; /* 1 */
422 | vertical-align: middle; /* 2 */
423 | }
424 | /*
425 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
426 | */
427 | #bluesky-embed img,
428 | #bluesky-embed video {
429 | max-width: 100%;
430 | height: auto;
431 | }
432 | /* Make elements with the HTML hidden attribute stay hidden by default */
433 | #bluesky-embed [hidden]:where(:not([hidden="until-found"])) {
434 | display: none;
435 | }
436 | #bluesky-embed *,
437 | #bluesky-embed ::before,
438 | #bluesky-embed ::after {
439 | --tw-border-opacity: 1;
440 | border-color: rgb(212 219 226 / var(--tw-border-opacity, 1));
441 | }
442 | #bluesky-embed *:where(#bluesky-embed.dark, #bluesky-embed.dark *), #bluesky-embed :where(#bluesky-embed.dark, #bluesky-embed.dark *)
443 | ::before, #bluesky-embed :where(#bluesky-embed.dark, #bluesky-embed.dark *)
444 | ::after {
445 | --tw-border-opacity: 1;
446 | border-color: rgb(46 63 81 / var(--tw-border-opacity, 1));
447 | }
448 | #bluesky-embed .container {
449 | width: 100%;
450 | }
451 | @media (min-width: 640px) {
452 | #bluesky-embed .container {
453 | max-width: 640px;
454 | }
455 | }
456 | @media (min-width: 768px) {
457 | #bluesky-embed .container {
458 | max-width: 768px;
459 | }
460 | }
461 | @media (min-width: 1024px) {
462 | #bluesky-embed .container {
463 | max-width: 1024px;
464 | }
465 | }
466 | @media (min-width: 1280px) {
467 | #bluesky-embed .container {
468 | max-width: 1280px;
469 | }
470 | }
471 | @media (min-width: 1536px) {
472 | #bluesky-embed .container {
473 | max-width: 1536px;
474 | }
475 | }
476 | #bluesky-embed {
477 | line-height: 1.5;
478 | /* 1 */
479 | -webkit-text-size-adjust: 100%;
480 | /* 2 */
481 | -moz-tab-size: 4;
482 | /* 3 */
483 | -o-tab-size: 4;
484 | tab-size: 4;
485 | /* 3 */
486 | font-family:
487 | ui-sans-serif,
488 | system-ui,
489 | -apple-system,
490 | BlinkMacSystemFont,
491 | "Segoe UI",
492 | Roboto,
493 | "Helvetica Neue",
494 | Arial,
495 | "Noto Sans",
496 | sans-serif,
497 | "Apple Color Emoji",
498 | "Segoe UI Emoji",
499 | "Segoe UI Symbol",
500 | "Noto Color Emoji";
501 | /* 4 */
502 | font-feature-settings: normal;
503 | /* 5 */
504 | font-variation-settings: normal;
505 | /* 6 */
506 | }
507 | #bluesky-embed {
508 | margin: 0;
509 | /* 1 */
510 | line-height: inherit;
511 | /* 2 */
512 | }
513 | #bluesky-embed .absolute {
514 | position: absolute;
515 | }
516 | #bluesky-embed .relative {
517 | position: relative;
518 | }
519 | #bluesky-embed .left-1\/2 {
520 | left: 50%;
521 | }
522 | #bluesky-embed .top-1\/2 {
523 | top: 50%;
524 | }
525 | #bluesky-embed .my-2 {
526 | margin-top: 0.5rem;
527 | margin-bottom: 0.5rem;
528 | }
529 | #bluesky-embed .ml-1 {
530 | margin-left: 0.25rem;
531 | }
532 | #bluesky-embed .mt-0\.5 {
533 | margin-top: 0.125rem;
534 | }
535 | #bluesky-embed .mt-1 {
536 | margin-top: 0.25rem;
537 | }
538 | #bluesky-embed .mt-2 {
539 | margin-top: 0.5rem;
540 | }
541 | #bluesky-embed .line-clamp-1 {
542 | overflow: hidden;
543 | display: -webkit-box;
544 | -webkit-box-orient: vertical;
545 | -webkit-line-clamp: 1;
546 | }
547 | #bluesky-embed .line-clamp-2 {
548 | overflow: hidden;
549 | display: -webkit-box;
550 | -webkit-box-orient: vertical;
551 | -webkit-line-clamp: 2;
552 | }
553 | #bluesky-embed .line-clamp-3 {
554 | overflow: hidden;
555 | display: -webkit-box;
556 | -webkit-box-orient: vertical;
557 | -webkit-line-clamp: 3;
558 | }
559 | #bluesky-embed .flex {
560 | display: flex;
561 | }
562 | #bluesky-embed .grid {
563 | display: grid;
564 | }
565 | #bluesky-embed .hidden {
566 | display: none;
567 | }
568 | #bluesky-embed .aspect-\[1\.91\/1\] {
569 | aspect-ratio: 1.91/1;
570 | }
571 | #bluesky-embed .aspect-\[2\/1\] {
572 | aspect-ratio: 2/1;
573 | }
574 | #bluesky-embed .aspect-square {
575 | aspect-ratio: 1 / 1;
576 | }
577 | #bluesky-embed .size-24 {
578 | width: 6rem;
579 | height: 6rem;
580 | }
581 | #bluesky-embed .size-3\/5 {
582 | width: 60%;
583 | height: 60%;
584 | }
585 | #bluesky-embed .size-4 {
586 | width: 1rem;
587 | height: 1rem;
588 | }
589 | #bluesky-embed .size-6 {
590 | width: 1.5rem;
591 | height: 1.5rem;
592 | }
593 | #bluesky-embed .size-full {
594 | width: 100%;
595 | height: 100%;
596 | }
597 | #bluesky-embed .h-10 {
598 | height: 2.5rem;
599 | }
600 | #bluesky-embed .h-3 {
601 | height: 0.75rem;
602 | }
603 | #bluesky-embed .h-4 {
604 | height: 1rem;
605 | }
606 | #bluesky-embed .h-5 {
607 | height: 1.25rem;
608 | }
609 | #bluesky-embed .h-7 {
610 | height: 1.75rem;
611 | }
612 | #bluesky-embed .h-8 {
613 | height: 2rem;
614 | }
615 | #bluesky-embed .h-auto {
616 | height: auto;
617 | }
618 | #bluesky-embed .h-full {
619 | height: 100%;
620 | }
621 | #bluesky-embed .max-h-\[1000px\] {
622 | max-height: 1000px;
623 | }
624 | #bluesky-embed .w-1\/2 {
625 | width: 50%;
626 | }
627 | #bluesky-embed .w-10 {
628 | width: 2.5rem;
629 | }
630 | #bluesky-embed .w-3\/4 {
631 | width: 75%;
632 | }
633 | #bluesky-embed .w-32 {
634 | width: 8rem;
635 | }
636 | #bluesky-embed .w-4 {
637 | width: 1rem;
638 | }
639 | #bluesky-embed .w-5 {
640 | width: 1.25rem;
641 | }
642 | #bluesky-embed .w-5\/6 {
643 | width: 83.333333%;
644 | }
645 | #bluesky-embed .w-64 {
646 | width: 16rem;
647 | }
648 | #bluesky-embed .w-8 {
649 | width: 2rem;
650 | }
651 | #bluesky-embed .w-full {
652 | width: 100%;
653 | }
654 | #bluesky-embed .max-w-\[150px\] {
655 | max-width: 150px;
656 | }
657 | #bluesky-embed .max-w-\[600px\] {
658 | max-width: 600px;
659 | }
660 | #bluesky-embed .max-w-xl {
661 | max-width: 36rem;
662 | }
663 | #bluesky-embed .flex-1 {
664 | flex: 1 1 0%;
665 | }
666 | #bluesky-embed .flex-\[2\] {
667 | flex: 2;
668 | }
669 | #bluesky-embed .flex-\[3\] {
670 | flex: 3;
671 | }
672 | #bluesky-embed .shrink-0 {
673 | flex-shrink: 0;
674 | }
675 | #bluesky-embed .-translate-x-1\/2 {
676 | --tw-translate-x: -50%;
677 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
678 | }
679 | #bluesky-embed .-translate-y-1\/2 {
680 | --tw-translate-y: -50%;
681 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
682 | }
683 | @keyframes pulse {
684 | 50% {
685 | opacity: .5;
686 | }
687 | }
688 | #bluesky-embed .animate-pulse {
689 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
690 | }
691 | #bluesky-embed .cursor-pointer {
692 | cursor: pointer;
693 | }
694 | #bluesky-embed .grid-cols-2 {
695 | grid-template-columns: repeat(2, minmax(0, 1fr));
696 | }
697 | #bluesky-embed .flex-row {
698 | flex-direction: row;
699 | }
700 | #bluesky-embed .flex-col {
701 | flex-direction: column;
702 | }
703 | #bluesky-embed .items-center {
704 | align-items: center;
705 | }
706 | #bluesky-embed .items-stretch {
707 | align-items: stretch;
708 | }
709 | #bluesky-embed .justify-center {
710 | justify-content: center;
711 | }
712 | #bluesky-embed .justify-between {
713 | justify-content: space-between;
714 | }
715 | #bluesky-embed .gap-1 {
716 | gap: 0.25rem;
717 | }
718 | #bluesky-embed .gap-1\.5 {
719 | gap: 0.375rem;
720 | }
721 | #bluesky-embed .gap-2 {
722 | gap: 0.5rem;
723 | }
724 | #bluesky-embed .gap-2\.5 {
725 | gap: 0.625rem;
726 | }
727 | #bluesky-embed .gap-5 {
728 | gap: 1.25rem;
729 | }
730 | #bluesky-embed :is(.space-x-2 > :not([hidden]) ~ :not([hidden])) {
731 | --tw-space-x-reverse: 0;
732 | margin-right: calc(0.5rem * var(--tw-space-x-reverse));
733 | margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
734 | }
735 | #bluesky-embed :is(.space-y-4 > :not([hidden]) ~ :not([hidden])) {
736 | --tw-space-y-reverse: 0;
737 | margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
738 | margin-bottom: calc(1rem * var(--tw-space-y-reverse));
739 | }
740 | #bluesky-embed .self-start {
741 | align-self: flex-start;
742 | }
743 | #bluesky-embed .overflow-hidden {
744 | overflow: hidden;
745 | }
746 | #bluesky-embed .whitespace-pre-wrap {
747 | white-space: pre-wrap;
748 | }
749 | #bluesky-embed .break-words {
750 | overflow-wrap: break-word;
751 | }
752 | #bluesky-embed .rounded {
753 | border-radius: 0.25rem;
754 | }
755 | #bluesky-embed .rounded-full {
756 | border-radius: 9999px;
757 | }
758 | #bluesky-embed .rounded-lg {
759 | border-radius: 0.5rem;
760 | }
761 | #bluesky-embed .rounded-md {
762 | border-radius: 0.375rem;
763 | }
764 | #bluesky-embed .rounded-sm {
765 | border-radius: 0.125rem;
766 | }
767 | #bluesky-embed .rounded-xl {
768 | border-radius: 0.75rem;
769 | }
770 | #bluesky-embed .border {
771 | border-width: 1px;
772 | }
773 | #bluesky-embed .border-l-2 {
774 | border-left-width: 2px;
775 | }
776 | #bluesky-embed .border-t {
777 | border-top-width: 1px;
778 | }
779 | #bluesky-embed .bg-black\/50 {
780 | background-color: rgb(0 0 0 / 0.5);
781 | }
782 | #bluesky-embed .bg-brand {
783 | --tw-bg-opacity: 1;
784 | background-color: rgb(10 122 255 / var(--tw-bg-opacity, 1));
785 | }
786 | #bluesky-embed .bg-gray-300 {
787 | --tw-bg-opacity: 1;
788 | background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
789 | }
790 | #bluesky-embed .bg-neutral-100 {
791 | --tw-bg-opacity: 1;
792 | background-color: rgb(245 245 245 / var(--tw-bg-opacity, 1));
793 | }
794 | #bluesky-embed .bg-neutral-300 {
795 | --tw-bg-opacity: 1;
796 | background-color: rgb(212 212 212 / var(--tw-bg-opacity, 1));
797 | }
798 | #bluesky-embed .bg-neutral-50 {
799 | --tw-bg-opacity: 1;
800 | background-color: rgb(250 250 250 / var(--tw-bg-opacity, 1));
801 | }
802 | #bluesky-embed .bg-red-50 {
803 | --tw-bg-opacity: 1;
804 | background-color: rgb(254 242 242 / var(--tw-bg-opacity, 1));
805 | }
806 | #bluesky-embed .bg-white {
807 | --tw-bg-opacity: 1;
808 | background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
809 | }
810 | #bluesky-embed .object-cover {
811 | -o-object-fit: cover;
812 | object-fit: cover;
813 | }
814 | #bluesky-embed .p-2 {
815 | padding: 0.5rem;
816 | }
817 | #bluesky-embed .p-4 {
818 | padding: 1rem;
819 | }
820 | #bluesky-embed .px-2\.5 {
821 | padding-left: 0.625rem;
822 | padding-right: 0.625rem;
823 | }
824 | #bluesky-embed .px-3 {
825 | padding-left: 0.75rem;
826 | padding-right: 0.75rem;
827 | }
828 | #bluesky-embed .px-4 {
829 | padding-left: 1rem;
830 | padding-right: 1rem;
831 | }
832 | #bluesky-embed .px-6 {
833 | padding-left: 1.5rem;
834 | padding-right: 1.5rem;
835 | }
836 | #bluesky-embed .py-2 {
837 | padding-top: 0.5rem;
838 | padding-bottom: 0.5rem;
839 | }
840 | #bluesky-embed .py-3 {
841 | padding-top: 0.75rem;
842 | padding-bottom: 0.75rem;
843 | }
844 | #bluesky-embed .pb-3 {
845 | padding-bottom: 0.75rem;
846 | }
847 | #bluesky-embed .pl-2 {
848 | padding-left: 0.5rem;
849 | }
850 | #bluesky-embed .pt-2\.5 {
851 | padding-top: 0.625rem;
852 | }
853 | #bluesky-embed .pt-3 {
854 | padding-top: 0.75rem;
855 | }
856 | #bluesky-embed .text-center {
857 | text-align: center;
858 | }
859 | #bluesky-embed .text-start {
860 | text-align: start;
861 | }
862 | #bluesky-embed .text-\[15px\] {
863 | font-size: 15px;
864 | }
865 | #bluesky-embed .text-\[17px\] {
866 | font-size: 17px;
867 | }
868 | #bluesky-embed .text-base {
869 | font-size: 1rem;
870 | line-height: 1.5rem;
871 | }
872 | #bluesky-embed .text-lg {
873 | font-size: 1.125rem;
874 | line-height: 1.75rem;
875 | }
876 | #bluesky-embed .text-sm {
877 | font-size: 0.875rem;
878 | line-height: 1.25rem;
879 | }
880 | #bluesky-embed .text-xl {
881 | font-size: 1.25rem;
882 | line-height: 1.75rem;
883 | }
884 | #bluesky-embed .text-xs {
885 | font-size: 0.75rem;
886 | line-height: 1rem;
887 | }
888 | #bluesky-embed .font-bold {
889 | font-weight: 700;
890 | }
891 | #bluesky-embed .font-semibold {
892 | font-weight: 600;
893 | }
894 | #bluesky-embed .leading-5 {
895 | line-height: 1.25rem;
896 | }
897 | #bluesky-embed .leading-6 {
898 | line-height: 1.5rem;
899 | }
900 | #bluesky-embed .leading-\[18px\] {
901 | line-height: 18px;
902 | }
903 | #bluesky-embed .leading-\[21px\] {
904 | line-height: 21px;
905 | }
906 | #bluesky-embed .text-black {
907 | --tw-text-opacity: 1;
908 | color: rgb(0 0 0 / var(--tw-text-opacity, 1));
909 | }
910 | #bluesky-embed .text-blue-400 {
911 | --tw-text-opacity: 1;
912 | color: rgb(96 165 250 / var(--tw-text-opacity, 1));
913 | }
914 | #bluesky-embed .text-blue-600 {
915 | --tw-text-opacity: 1;
916 | color: rgb(37 99 235 / var(--tw-text-opacity, 1));
917 | }
918 | #bluesky-embed .text-brand {
919 | --tw-text-opacity: 1;
920 | color: rgb(10 122 255 / var(--tw-text-opacity, 1));
921 | }
922 | #bluesky-embed .text-neutral-500 {
923 | --tw-text-opacity: 1;
924 | color: rgb(115 115 115 / var(--tw-text-opacity, 1));
925 | }
926 | #bluesky-embed .text-red-800 {
927 | --tw-text-opacity: 1;
928 | color: rgb(153 27 27 / var(--tw-text-opacity, 1));
929 | }
930 | #bluesky-embed .text-textLight {
931 | --tw-text-opacity: 1;
932 | color: rgb(66 87 108 / var(--tw-text-opacity, 1));
933 | }
934 | #bluesky-embed .underline {
935 | text-decoration-line: underline;
936 | }
937 | #bluesky-embed .decoration-2 {
938 | text-decoration-thickness: 2px;
939 | }
940 | #bluesky-embed .underline-offset-2 {
941 | text-underline-offset: 2px;
942 | }
943 | #bluesky-embed .opacity-60 {
944 | opacity: 0.6;
945 | }
946 | #bluesky-embed .blur {
947 | --tw-blur: blur(8px);
948 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
949 | }
950 | #bluesky-embed .filter {
951 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
952 | }
953 | #bluesky-embed .transition {
954 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
955 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
956 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
957 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
958 | transition-duration: 150ms;
959 | }
960 | #bluesky-embed .transition-colors {
961 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
962 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
963 | transition-duration: 150ms;
964 | }
965 | #bluesky-embed .transition-transform {
966 | transition-property: transform;
967 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
968 | transition-duration: 150ms;
969 | }
970 | #bluesky-embed .break-word {
971 | word-break: break-word;
972 | }
973 | #bluesky-embed .hover\:scale-110:hover {
974 | --tw-scale-x: 1.1;
975 | --tw-scale-y: 1.1;
976 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
977 | }
978 | #bluesky-embed .hover\:border-\[\#3b5169\]:hover {
979 | --tw-border-opacity: 1;
980 | border-color: rgb(59 81 105 / var(--tw-border-opacity, 1));
981 | }
982 | #bluesky-embed .hover\:bg-\[\#2e3f51\]:hover {
983 | --tw-bg-opacity: 1;
984 | background-color: rgb(46 63 81 / var(--tw-bg-opacity, 1));
985 | }
986 | #bluesky-embed .hover\:bg-neutral-100:hover {
987 | --tw-bg-opacity: 1;
988 | background-color: rgb(245 245 245 / var(--tw-bg-opacity, 1));
989 | }
990 | #bluesky-embed .hover\:bg-neutral-50:hover {
991 | --tw-bg-opacity: 1;
992 | background-color: rgb(250 250 250 / var(--tw-bg-opacity, 1));
993 | }
994 | #bluesky-embed .hover\:underline:hover {
995 | text-decoration-line: underline;
996 | }
997 | @media (min-width: 300px) {
998 | #bluesky-embed .min-\[300px\]\:text-lg {
999 | font-size: 1.125rem;
1000 | line-height: 1.75rem;
1001 | }
1002 | }
1003 | @media (min-width: 380px) {
1004 | #bluesky-embed .min-\[380px\]\:inline {
1005 | display: inline;
1006 | }
1007 | }
1008 | @media (min-width: 450px) {
1009 | #bluesky-embed .min-\[450px\]\:inline {
1010 | display: inline;
1011 | }
1012 | #bluesky-embed .min-\[450px\]\:hidden {
1013 | display: none;
1014 | }
1015 | }
1016 | @media (min-width: 640px) {
1017 | #bluesky-embed .sm\:min-w-\[300px\] {
1018 | min-width: 300px;
1019 | }
1020 | }
1021 | #bluesky-embed .dark\:bg-\[\#161e27\]:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1022 | --tw-bg-opacity: 1;
1023 | background-color: rgb(22 30 39 / var(--tw-bg-opacity, 1));
1024 | }
1025 | #bluesky-embed .dark\:bg-gray-700:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1026 | --tw-bg-opacity: 1;
1027 | background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
1028 | }
1029 | #bluesky-embed .dark\:bg-gray-800:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1030 | --tw-bg-opacity: 1;
1031 | background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
1032 | }
1033 | #bluesky-embed .dark\:text-brandDark:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1034 | --tw-text-opacity: 1;
1035 | color: rgb(51 153 255 / var(--tw-text-opacity, 1));
1036 | }
1037 | #bluesky-embed .dark\:text-gray-200:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1038 | --tw-text-opacity: 1;
1039 | color: rgb(229 231 235 / var(--tw-text-opacity, 1));
1040 | }
1041 | #bluesky-embed .dark\:text-neutral-400:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1042 | --tw-text-opacity: 1;
1043 | color: rgb(163 163 163 / var(--tw-text-opacity, 1));
1044 | }
1045 | #bluesky-embed .dark\:text-red-400:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1046 | --tw-text-opacity: 1;
1047 | color: rgb(248 113 113 / var(--tw-text-opacity, 1));
1048 | }
1049 | #bluesky-embed .dark\:text-textDark:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1050 | --tw-text-opacity: 1;
1051 | color: rgb(140 158 178 / var(--tw-text-opacity, 1));
1052 | }
1053 | #bluesky-embed .dark\:hover\:border-\[\#4c6784\]:hover:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1054 | --tw-border-opacity: 1;
1055 | border-color: rgb(76 103 132 / var(--tw-border-opacity, 1));
1056 | }
1057 | #bluesky-embed .dark\:hover\:bg-\[\#161e27f3\]:hover:where(#bluesky-embed.dark, #bluesky-embed.dark *) {
1058 | background-color: #161e27f3;
1059 | }
1060 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/main.scss:
--------------------------------------------------------------------------------
1 | #bluesky-embed {
2 | @tailwind base;
3 | @tailwind components;
4 |
5 | @layer base {
6 | *,
7 | ::before,
8 | ::after {
9 | @apply border-[#d4dbe2] dark:border-[#2e3f51];
10 | }
11 | }
12 |
13 | & {
14 | line-height: 1.5;
15 | /* 1 */
16 | -webkit-text-size-adjust: 100%;
17 | /* 2 */
18 | -moz-tab-size: 4;
19 | /* 3 */
20 | -o-tab-size: 4;
21 | tab-size: 4;
22 | /* 3 */
23 | font-family:
24 | ui-sans-serif,
25 | system-ui,
26 | -apple-system,
27 | BlinkMacSystemFont,
28 | "Segoe UI",
29 | Roboto,
30 | "Helvetica Neue",
31 | Arial,
32 | "Noto Sans",
33 | sans-serif,
34 | "Apple Color Emoji",
35 | "Segoe UI Emoji",
36 | "Segoe UI Symbol",
37 | "Noto Color Emoji";
38 | /* 4 */
39 | font-feature-settings: normal;
40 | /* 5 */
41 | font-variation-settings: normal;
42 | /* 6 */
43 | }
44 |
45 | & {
46 | margin: 0;
47 | /* 1 */
48 | line-height: inherit;
49 | /* 2 */
50 | }
51 | }
52 | @tailwind utilities;
53 |
54 | @layer utilities {
55 | .break-word {
56 | word-break: break-word;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/postThread.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import { getPostThread } from "./api/index.js";
3 | import {
4 | EmbeddedPostThread,
5 | PostThreadNotFound,
6 | PostThreadSkeleton,
7 | } from "./bluesky-theme/components.js";
8 | import type { PostThreadProps } from "./swr.js";
9 |
10 | // This is not ideal because we don't use the `apiUrl` prop here and `uri` is required. But as the
11 | // type is shared with the SWR version when the PostThread component is imported, we need to have a type
12 | // that supports both versions of the component.
13 | export type { PostThreadProps };
14 |
15 | type PostThreadContentProps = Omit;
16 |
17 | const PostThreadContent = async ({
18 | params,
19 | theme = "light",
20 | config,
21 | onError,
22 | hidePost,
23 | }: PostThreadContentProps) => {
24 | let error;
25 | const postThread = params
26 | ? await getPostThread(params, config).catch((err) => {
27 | if (onError) {
28 | error = onError(err);
29 | } else {
30 | console.error(err);
31 | error = err;
32 | }
33 | })
34 | : undefined;
35 |
36 | if (!postThread) {
37 | const NotFound = PostThreadNotFound;
38 | return ;
39 | }
40 |
41 | return (
42 | <>
43 |
49 | >
50 | );
51 | };
52 |
53 | export const PostThread = ({
54 | theme,
55 | fallback = ,
56 | ...props
57 | }: PostThreadProps) => (
58 |
59 | {/* @ts-ignore: Async components are valid in the app directory */}
60 |
61 |
62 | );
63 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/swr.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { type ReactNode } from "react";
4 | import {
5 | EmbeddedPostThread,
6 | PostThreadNotFound,
7 | PostThreadSkeleton,
8 | } from "./bluesky-theme/components.js";
9 | import { Theme, type PostThreadCoreProps } from "./utils.js";
10 | import { usePostThread } from "./hooks.js";
11 | import { PostThreadConfig, PostThreadParams } from "./api/index.js";
12 |
13 | export type PostThreadProps = Omit & {
14 | fallback?: ReactNode;
15 | } & {
16 | params: PostThreadParams;
17 | theme?: Theme;
18 | hidePost?: boolean;
19 | config?: PostThreadConfig;
20 | };
21 |
22 | export const PostThread = ({
23 | params,
24 | theme = "light",
25 | fallback = ,
26 | config,
27 | onError,
28 | hidePost,
29 | }: PostThreadProps) => {
30 | const { data, error, isLoading } = usePostThread(params, config);
31 |
32 | if (isLoading) return fallback;
33 | if (error || !data) {
34 | const NotFound = PostThreadNotFound;
35 | return ;
36 | }
37 |
38 | return (
39 | <>
40 |
41 | >
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { PostThreadParams } from "./api";
2 |
3 | export type PostThreadCoreProps = {
4 | params: PostThreadParams;
5 | onError?(error: any): any;
6 | };
7 |
8 | export type Theme = "light" | "dark";
9 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | // prefix: 'bsky-', // Add this line
4 | darkMode: ["selector", "#bluesky-embed.dark"],
5 | // darkMode: ["selector", "[data-theme="dark"],.dark"],
6 | content: [
7 | "./src/**/*.{js,ts,jsx,tsx,mdx}",
8 | // './node_modules/react-bluesky-embed/**/*.{vue,js,ts,jsx,tsx}', // Add this line
9 | ],
10 | theme: {
11 | extend: {
12 | // colors: ({ theme }) => ({
13 | // ...colors,
14 | // headline: theme.,
15 | // }),
16 |
17 | colors: {
18 | brand: "rgb(10,122,255)",
19 | brandDark: "#3399FF",
20 | textLight: "rgb(66,87,108)",
21 | textDark: "#8c9eb2",
22 | },
23 | },
24 | },
25 | plugins: [],
26 | // https://tailwindcss.com/docs/configuration#important
27 | // TODO: outside styles with important will override those inside the important component
28 | important: "#bluesky-embed",
29 | };
30 |
--------------------------------------------------------------------------------
/packages/react-bluesky-embed/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "outDir": "dist",
6 | "lib": ["dom", "dom.iterable", "esnext"],
7 | "allowJs": true,
8 | "skipLibCheck": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "declaration": true,
12 | "incremental": true,
13 | "esModuleInterop": true,
14 | "moduleResolution": "node",
15 | "resolveJsonModule": true,
16 | "isolatedModules": true,
17 | "jsx": "preserve"
18 | },
19 | "include": ["global.d.ts", "src/**/*.ts", "src/**/*.tsx"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.org/schema.json",
3 | "ui": "stream",
4 | "tasks": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "outputs": [".next/**", "./dist/**", "./build/**"]
8 | },
9 | "lint": {
10 | "outputs": []
11 | },
12 | "dev": {
13 | "cache": false
14 | },
15 | "start": {
16 | "cache": false
17 | },
18 | "clean": {
19 | "cache": false
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------