├── .changeset
├── README.md
└── config.json
├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .vscode
└── settings.json
├── apps
├── create-react-app
│ ├── .env
│ ├── package.json
│ ├── public
│ │ └── index.html
│ ├── readme.md
│ ├── src
│ │ ├── app.js
│ │ ├── app.module.css
│ │ ├── base.css
│ │ └── index.js
│ └── vercel.json
├── custom-tweet-dub
│ ├── .vscode
│ │ └── settings.json
│ ├── app
│ │ ├── layout.tsx
│ │ └── light
│ │ │ ├── [tweet]
│ │ │ └── page.tsx
│ │ │ ├── layout.module.css
│ │ │ ├── layout.tsx
│ │ │ └── mdx
│ │ │ ├── page.tsx
│ │ │ └── post.mdx
│ ├── base.css
│ ├── components
│ │ └── tweet
│ │ │ ├── blur-image.tsx
│ │ │ ├── dub-tweet.tsx
│ │ │ ├── icons
│ │ │ ├── heart.tsx
│ │ │ ├── index.ts
│ │ │ ├── message.tsx
│ │ │ ├── repeat.tsx
│ │ │ └── twitter.tsx
│ │ │ ├── index.ts
│ │ │ ├── tilt.tsx
│ │ │ ├── tweet-header.tsx
│ │ │ ├── tweet-media.tsx
│ │ │ ├── tweet-text.tsx
│ │ │ ├── tweet.tsx
│ │ │ └── utils.ts
│ ├── mdx-components.tsx
│ ├── mdx.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.js
│ ├── readme.md
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ └── vercel.json
├── next-app
│ ├── .vscode
│ │ └── settings.json
│ ├── app
│ │ ├── api
│ │ │ └── tweet
│ │ │ │ └── [id]
│ │ │ │ └── route.ts
│ │ ├── layout.tsx
│ │ └── light
│ │ │ ├── [tweet]
│ │ │ ├── page.tsx
│ │ │ └── tweet-components.tsx
│ │ │ ├── cache
│ │ │ └── [tweet]
│ │ │ │ ├── page.tsx
│ │ │ │ └── tweet-page.tsx
│ │ │ ├── layout.module.css
│ │ │ ├── layout.tsx
│ │ │ ├── mdx
│ │ │ ├── page.tsx
│ │ │ └── post.mdx
│ │ │ ├── suspense
│ │ │ └── [tweet]
│ │ │ │ ├── page.tsx
│ │ │ │ └── tweet-page.tsx
│ │ │ └── vercel-kv
│ │ │ └── [tweet]
│ │ │ ├── page.tsx
│ │ │ └── tweet-page.tsx
│ ├── base.css
│ ├── components
│ │ ├── tweet-page.module.css
│ │ └── tweet-page.tsx
│ ├── mdx-components.tsx
│ ├── mdx.d.ts
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── pages
│ │ ├── _app.js
│ │ └── dark
│ │ │ ├── [tweet].tsx
│ │ │ └── swr
│ │ │ └── [tweet].tsx
│ ├── readme.md
│ ├── tsconfig.json
│ └── vercel.json
├── site
│ ├── README.md
│ ├── app
│ │ └── api
│ │ │ └── tweet
│ │ │ └── [id]
│ │ │ └── route.ts
│ ├── components
│ │ ├── counters.module.css
│ │ └── counters.tsx
│ ├── next.config.js
│ ├── package.json
│ ├── pages
│ │ ├── _app.mdx
│ │ ├── _meta.json
│ │ ├── api-reference.mdx
│ │ ├── contributing.mdx
│ │ ├── create-react-app.mdx
│ │ ├── custom-theme.mdx
│ │ ├── index.mdx
│ │ ├── next.mdx
│ │ ├── twitter-theme.mdx
│ │ ├── twitter-theme
│ │ │ ├── _meta.json
│ │ │ ├── advanced.mdx
│ │ │ └── api-reference.mdx
│ │ └── vite.mdx
│ ├── postcss.config.js
│ ├── styles
│ │ └── base.css
│ ├── tailwind.config.js
│ ├── theme.config.tsx
│ ├── tsconfig.json
│ └── vercel.json
└── vite-app
│ ├── .gitignore
│ ├── api
│ └── tweet
│ │ └── [tweet].ts
│ ├── index.html
│ ├── package.json
│ ├── public
│ └── vite.svg
│ ├── readme.md
│ ├── src
│ ├── base.css
│ ├── layout.module.css
│ ├── layout.tsx
│ ├── main.tsx
│ ├── pages
│ │ ├── index.tsx
│ │ └── tweet.tsx
│ └── vite-env.d.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── vercel.json
│ └── vite.config.ts
├── license.md
├── package.json
├── packages
└── react-tweet
│ ├── .eslintignore
│ ├── .eslintrc.cjs
│ ├── .swcrc
│ ├── CHANGELOG.md
│ ├── global.d.ts
│ ├── license.md
│ ├── package.json
│ ├── readme.md
│ ├── src
│ ├── api
│ │ ├── fetch-tweet.ts
│ │ ├── get-oembed.ts
│ │ ├── get-tweet.ts
│ │ ├── index.ts
│ │ └── types
│ │ │ ├── edit.ts
│ │ │ ├── entities.ts
│ │ │ ├── index.ts
│ │ │ ├── media.ts
│ │ │ ├── photo.ts
│ │ │ ├── tweet.ts
│ │ │ ├── user.ts
│ │ │ └── video.ts
│ ├── date-utils.ts
│ ├── hooks.ts
│ ├── index.client.ts
│ ├── index.ts
│ ├── swr.tsx
│ ├── tweet.tsx
│ ├── twitter-theme
│ │ ├── avatar-img.tsx
│ │ ├── components.tsx
│ │ ├── embedded-tweet.tsx
│ │ ├── icons
│ │ │ ├── icons.module.css
│ │ │ ├── index.ts
│ │ │ ├── verified-business.tsx
│ │ │ ├── verified-government.tsx
│ │ │ └── verified.tsx
│ │ ├── media-img.tsx
│ │ ├── quoted-tweet
│ │ │ ├── index.ts
│ │ │ ├── quoted-tweet-body.module.css
│ │ │ ├── quoted-tweet-body.tsx
│ │ │ ├── quoted-tweet-container.module.css
│ │ │ ├── quoted-tweet-container.tsx
│ │ │ ├── quoted-tweet-header.module.css
│ │ │ ├── quoted-tweet-header.tsx
│ │ │ └── quoted-tweet.tsx
│ │ ├── skeleton.module.css
│ │ ├── skeleton.tsx
│ │ ├── theme.css
│ │ ├── tweet-actions-copy.tsx
│ │ ├── tweet-actions.module.css
│ │ ├── tweet-actions.tsx
│ │ ├── tweet-body.module.css
│ │ ├── tweet-body.tsx
│ │ ├── tweet-container.module.css
│ │ ├── tweet-container.tsx
│ │ ├── tweet-header.module.css
│ │ ├── tweet-header.tsx
│ │ ├── tweet-in-reply-to.module.css
│ │ ├── tweet-in-reply-to.tsx
│ │ ├── tweet-info-created-at.module.css
│ │ ├── tweet-info-created-at.tsx
│ │ ├── tweet-info.module.css
│ │ ├── tweet-info.tsx
│ │ ├── tweet-link.module.css
│ │ ├── tweet-link.tsx
│ │ ├── tweet-media-video.module.css
│ │ ├── tweet-media-video.tsx
│ │ ├── tweet-media.module.css
│ │ ├── tweet-media.tsx
│ │ ├── tweet-not-found.module.css
│ │ ├── tweet-not-found.tsx
│ │ ├── tweet-replies.module.css
│ │ ├── tweet-replies.tsx
│ │ ├── tweet-skeleton.module.css
│ │ ├── tweet-skeleton.tsx
│ │ ├── types.tsx
│ │ ├── verified-badge.module.css
│ │ └── verified-badge.tsx
│ └── utils.ts
│ └── tsconfig.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── readme.md
└── 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", "custom-tweet-dub"]
11 | }
12 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | permissions:
9 | contents: write
10 | pull-requests: write
11 |
12 | env:
13 | FORCE_COLOR: 3
14 |
15 | concurrency: ${{ github.workflow }}-${{ github.ref }}
16 |
17 | jobs:
18 | release:
19 | name: Release
20 | runs-on: ubuntu-latest
21 | defaults:
22 | run:
23 | shell: bash
24 | steps:
25 | - name: Checkout Repo
26 | uses: actions/checkout@v3
27 |
28 | - name: Set up pnpm
29 | uses: pnpm/action-setup@v2.2.4
30 | with:
31 | version: 8.14.3
32 |
33 | - name: Set up Node.js
34 | uses: actions/setup-node@v3
35 | with:
36 | node-version: 18.17.1
37 | cache: pnpm
38 |
39 | - name: Install Dependencies
40 | run: pnpm i --frozen-lockfile --ignore-scripts
41 |
42 | - name: Create Release PR or Publish Packages
43 | id: changesets
44 | uses: changesets/action@v1
45 | with:
46 | # Builds the package and executes `changeset publish`
47 | publish: pnpm release
48 | # Alias to `changeset version` script in package.json
49 | version: pnpm version-packages
50 | commit: 'chore: update package versions'
51 | title: 'chore: update package versions'
52 | env:
53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
54 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
55 |
56 | - name: Send a Slack notification if a publish happens
57 | if: steps.changesets.outputs.published == 'true'
58 | # You can do something when a publish happens.
59 | run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!"
60 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[mdx]": {
3 | "editor.wordWrap": "on",
4 | }
5 | }
--------------------------------------------------------------------------------
/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/vercel-labs/react-tweet.git",
6 | "author": "Luis Alvarez (https://twitter.com/luis_fades)",
7 | "main": "src/index.js",
8 | "scripts": {
9 | "dev": "react-scripts start",
10 | "build": "react-scripts build"
11 | },
12 | "dependencies": {
13 | "clsx": "^1.2.1",
14 | "react": "18.2.0",
15 | "react-dom": "18.2.0",
16 | "react-scripts": "5.0.1",
17 | "react-tweet": "workspace:*"
18 | },
19 | "devDependencies": {
20 | "@babel/runtime": "7.22.6",
21 | "postcss-flexbugs-fixes": "^5.0.2"
22 | },
23 | "browserslist": [
24 | ">0.2%",
25 | "not dead",
26 | "not ie <= 11",
27 | "not op_mini all"
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/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-tweet for Create React App
2 |
3 | Follow the instructions in the [official docs](https://react-tweet.vercel.app/create-react-app) to learn more about `react-tweet` for Create React App.
4 |
--------------------------------------------------------------------------------
/apps/create-react-app/src/app.js:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { Tweet } from 'react-tweet'
3 | import styles from './app.module.css'
4 | import './base.css'
5 |
6 | export default function App() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/apps/create-react-app/src/app.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-family: var(--tweet-font-family);
3 | color: var(--tweet-font-color);
4 | background: var(--tweet-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.js:
--------------------------------------------------------------------------------
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/custom-tweet-dub/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
3 | "typescript.enablePromptUseWorkspaceTsdk": true
4 | }
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/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/custom-tweet-dub/app/light/[tweet]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getTweet } from 'react-tweet/api'
2 | import { Tweet } from '@/components/tweet'
3 |
4 | type Props = {
5 | params: { tweet: string }
6 | }
7 |
8 | export const revalidate = 1800
9 |
10 | export async function generateMetadata({ params }: Props) {
11 | const tweet = await getTweet(params.tweet).catch(() => undefined)
12 |
13 | if (!tweet) return { title: 'Next Tweet' }
14 |
15 | const username = ` - @${tweet.user.screen_name}`
16 | const maxLength = 68 - username.length
17 | const text =
18 | tweet.text.length > maxLength
19 | ? `${tweet.text.slice(0, maxLength)}…`
20 | : tweet.text
21 |
22 | return { title: `${text}${username}` }
23 | }
24 |
25 | const Page = ({ params }: Props) =>
26 |
27 | export default Page
28 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/app/light/layout.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-family: var(--tweet-font-family);
3 | color: var(--tweet-font-color);
4 | background: var(--tweet-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 | margin-top: -0.5rem;
17 | }
18 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/app/light/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 | import s from './layout.module.css'
3 |
4 | const Layout = ({ children }: { children: ReactNode }) => (
5 |
6 |
7 |
8 | {children}
9 |
10 |
11 |
12 | )
13 |
14 | export default Layout
15 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/app/light/mdx/page.tsx:
--------------------------------------------------------------------------------
1 | import Post from './post.mdx'
2 |
3 | export default function Page() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/app/light/mdx/post.mdx:
--------------------------------------------------------------------------------
1 | import { Tweet } from '@/components/tweet'
2 |
3 |
4 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/base.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | *,
6 | *::before,
7 | *::after {
8 | box-sizing: inherit;
9 | }
10 | html {
11 | height: 100%;
12 | box-sizing: border-box;
13 | }
14 | body {
15 | position: relative;
16 | min-height: 100%;
17 | margin: 0;
18 | line-height: 1.65;
19 | font-family: -apple-system, BlinkMacSystemFont, sans-serif;
20 | font-weight: 400;
21 | text-rendering: optimizeLegibility;
22 | -webkit-font-smoothing: antialiased;
23 | -moz-osx-font-smoothing: grayscale;
24 | scroll-behavior: smooth;
25 | }
26 | html,
27 | body {
28 | background: #fff;
29 | }
30 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/blur-image.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import Image, { ImageProps } from 'next/image'
4 | import { useEffect, useState } from 'react'
5 |
6 | export default function BlurImage(props: ImageProps) {
7 | const [loading, setLoading] = useState(true)
8 | const [src, setSrc] = useState(props.src)
9 | useEffect(() => setSrc(props.src), [props.src]) // update the `src` value when the `prop.src` value changes
10 |
11 | return (
12 | {
18 | setLoading(false)
19 | }}
20 | onError={() => {
21 | setSrc(`https://avatar.vercel.sh/${props.alt}`) // if the image fails to load, use the default avatar
22 | }}
23 | />
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/dub-tweet.tsx:
--------------------------------------------------------------------------------
1 | import { type EnrichedTweet } from 'react-tweet'
2 | import { nFormatter } from './utils'
3 | import { Heart, Message } from './icons'
4 | import { Tilt } from './tilt'
5 | import { TweetHeader } from './tweet-header'
6 | import { TweetText } from './tweet-text'
7 | import { TweetMedia } from './tweet-media'
8 |
9 | export const DubTweet = ({
10 | tweet,
11 | noTilt,
12 | }: {
13 | tweet: EnrichedTweet
14 | noTilt?: boolean
15 | }) => {
16 | const TweetBody = (
17 |
18 | {/* User info, verified badge, twitter logo, text, etc. */}
19 |
20 |
21 | {tweet.in_reply_to_status_id_str && tweet.in_reply_to_screen_name && (
22 |
32 | )}
33 |
34 |
35 | {/* Images, Preview images, videos, polls, etc. */}
36 |
37 | {tweet.mediaDetails?.length ? (
38 |
45 | {tweet.mediaDetails?.map((media) => (
46 |
47 |
48 |
49 | ))}
50 |
51 | ) : null}
52 |
53 |
73 |
74 | )
75 |
76 | return noTilt ? (
77 | TweetBody
78 | ) : (
79 |
88 | {TweetBody}
89 |
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/icons/heart.tsx:
--------------------------------------------------------------------------------
1 | export const Heart = ({ className }: { className: string }) => (
2 |
14 |
15 |
16 | )
17 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/icons/index.ts:
--------------------------------------------------------------------------------
1 | export * from './heart'
2 | export * from './message'
3 | export * from './repeat'
4 | export * from './twitter'
5 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/icons/message.tsx:
--------------------------------------------------------------------------------
1 | export const Message = ({ className }: { className: string }) => (
2 |
14 | {' '}
15 |
16 | )
17 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/icons/repeat.tsx:
--------------------------------------------------------------------------------
1 | export const Repeat = ({ className }: { className: string }) => (
2 |
14 |
15 |
16 |
17 | {' '}
18 |
19 | )
20 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/icons/twitter.tsx:
--------------------------------------------------------------------------------
1 | export const Twitter = ({ className }: { className?: string }) => (
2 |
7 |
11 |
12 | )
13 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/index.ts:
--------------------------------------------------------------------------------
1 | export * from './tweet'
2 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/tilt.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * client component wrapper around react-parallax-tilt because it uses class components
3 | * that can't be used in React Server Components.
4 | */
5 |
6 | 'use client'
7 |
8 | import TiltComponent, { type ReactParallaxTiltProps } from 'react-parallax-tilt'
9 |
10 | export const Tilt = ({ children, ...props }: ReactParallaxTiltProps) => (
11 | {children}
12 | )
13 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/tweet-header.tsx:
--------------------------------------------------------------------------------
1 | import { type EnrichedTweet } from 'react-tweet'
2 | import { truncate } from './utils'
3 | import { Twitter } from './icons'
4 | import BlurImage from './blur-image'
5 |
6 | export const TweetHeader = ({ tweet }: { tweet: EnrichedTweet }) => (
7 |
67 | )
68 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/tweet-media.tsx:
--------------------------------------------------------------------------------
1 | import type { MediaDetails } from 'react-tweet/api'
2 | import { type EnrichedTweet, getMediaUrl, getMp4Video } from 'react-tweet'
3 | import BlurImage from './blur-image'
4 |
5 | export const TweetMedia = ({
6 | tweet,
7 | media,
8 | }: {
9 | tweet: EnrichedTweet
10 | media: MediaDetails
11 | }) => {
12 | if (media.type == 'video') {
13 | return (
14 |
23 |
24 | Your browser does not support the video tag.
25 |
26 | )
27 | }
28 |
29 | if (media.type == 'animated_gif') {
30 | return (
31 |
38 | )
39 | }
40 |
41 | return (
42 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/tweet-text.tsx:
--------------------------------------------------------------------------------
1 | import { type ReactNode } from 'react'
2 | import { type EnrichedTweet } from 'react-tweet'
3 |
4 | const Link = ({ href, children }: { href: string; children: ReactNode }) => (
5 |
11 | {children}
12 |
13 | )
14 |
15 | export const TweetText = ({ tweet }: { tweet: EnrichedTweet }) => (
16 |
17 | {tweet.entities.map((item, i) => {
18 | switch (item.type) {
19 | case 'hashtag':
20 | case 'mention':
21 | case 'url':
22 | case 'symbol':
23 | return (
24 |
25 | {item.text}
26 |
27 | )
28 | case 'media':
29 | return
30 | default:
31 | // We use `dangerouslySetInnerHTML` to preserve the text encoding.
32 | // https://github.com/vercel-labs/react-tweet/issues/29
33 | return (
34 |
35 | )
36 | }
37 | })}
38 |
39 | )
40 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/tweet.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react'
2 | import { type TweetCoreProps, enrichTweet } from 'react-tweet'
3 | import { getTweet } from 'react-tweet/api'
4 | import { DubTweet } from './dub-tweet'
5 |
6 | type Props = TweetCoreProps & {
7 | noTilt?: boolean
8 | }
9 |
10 | export const TweetContent = async ({ id, noTilt, onError }: Props) => {
11 | const tweet = id
12 | ? await getTweet(id).catch((err) => {
13 | if (onError) {
14 | onError(err)
15 | } else {
16 | console.error(err)
17 | }
18 | })
19 | : undefined
20 |
21 | if (!tweet) {
22 | return (
23 |
24 |
There was an error loading this tweet.
25 |
26 | )
27 | }
28 |
29 | return
30 | }
31 |
32 | export const Tweet = (props: Props) => (
33 |
34 |
35 |
36 | )
37 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/components/tweet/utils.ts:
--------------------------------------------------------------------------------
1 | export const truncate = (str: string | null, length: number) => {
2 | if (!str || str.length <= length) return str
3 | return `${str.slice(0, length - 3)}...`
4 | }
5 |
6 | export function nFormatter(num?: number, digits?: number) {
7 | if (!num) return '0'
8 | const lookup = [
9 | { value: 1, symbol: '' },
10 | { value: 1e3, symbol: 'K' },
11 | { value: 1e6, symbol: 'M' },
12 | { value: 1e9, symbol: 'G' },
13 | { value: 1e12, symbol: 'T' },
14 | { value: 1e15, symbol: 'P' },
15 | { value: 1e18, symbol: 'E' },
16 | ]
17 | const rx = /\.0+$|(\.[0-9]*[1-9])0+$/
18 | var item = lookup
19 | .slice()
20 | .reverse()
21 | .find(function (item) {
22 | return num >= item.value
23 | })
24 | return item
25 | ? (num / item.value).toFixed(digits || 1).replace(rx, '$1') + item.symbol
26 | : '0'
27 | }
28 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/mdx-components.tsx:
--------------------------------------------------------------------------------
1 | export const useMDXComponents = (components: any) => components
2 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/mdx.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.mdx' {
2 | let MDXComponent: (props) => JSX.Element
3 | export default MDXComponent
4 | }
5 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/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: [
10 | { protocol: 'https', hostname: 'pbs.twimg.com' },
11 | { protocol: 'https', hostname: 'abs.twimg.com' },
12 | ],
13 | },
14 | experimental: {
15 | mdxRs: true,
16 | },
17 | }
18 |
19 | export default withMDX(nextConfig)
20 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "custom-tweet-dub",
3 | "private": true,
4 | "license": "MIT",
5 | "repository": "https://github.com/vercel-labs/react-tweet.git",
6 | "author": "Luis Alvarez (https://twitter.com/luis_fades)",
7 | "scripts": {
8 | "dev": "next dev -p 3005",
9 | "build": "next build",
10 | "start": "next start -p 3005",
11 | "lint": "next lint",
12 | "clean": "rm -rf .next && rm -rf .turbo"
13 | },
14 | "dependencies": {
15 | "@next/mdx": "^14.0.4",
16 | "clsx": "^2.0.0",
17 | "next": "14.0.4",
18 | "react": "^18.2.0",
19 | "react-dom": "^18.2.0",
20 | "react-parallax-tilt": "^1.7.177",
21 | "react-tweet": "workspace:*"
22 | },
23 | "devDependencies": {
24 | "@tailwindcss/forms": "^0.5.7",
25 | "@tailwindcss/typography": "^0.5.10",
26 | "@types/node": "20.10.5",
27 | "@types/react": "^18.2.45",
28 | "autoprefixer": "^10.4.16",
29 | "eslint": "^8.56.0",
30 | "eslint-config-next": "^14.0.4",
31 | "postcss": "^8.4.32",
32 | "tailwind-scrollbar-hide": "^1.1.7",
33 | "tailwindcss": "^3.3.6",
34 | "tailwindcss-radix": "^2.8.0",
35 | "typescript": "^5.3.3"
36 | },
37 | "version": null
38 | }
39 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/postcss.config.js:
--------------------------------------------------------------------------------
1 | // If you want to use other PostCSS plugins, see the following:
2 | // https://tailwindcss.com/docs/using-with-preprocessors
3 | module.exports = {
4 | plugins: {
5 | tailwindcss: {},
6 | autoprefixer: {},
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/readme.md:
--------------------------------------------------------------------------------
1 | # Custom Tweet theme for react-tweet
2 |
3 | Inspired by the tweet in https://dub.sh/
4 |
5 | Demo URL: https://react-tweet-dub.vercel.app/light/1586745532386578433.
6 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: [
3 | './pages/**/*.{js,ts,jsx,tsx}',
4 | './app/**/*.{js,ts,jsx,tsx}',
5 | './components/**/*.{js,ts,jsx,tsx}',
6 | ],
7 | future: {
8 | hoverOnlyWhenSupported: true,
9 | },
10 | theme: {
11 | extend: {
12 | fontFamily: {
13 | display: ['var(--font-satoshi)', 'system-ui', 'sans-serif'],
14 | default: ['var(--font-inter)', 'system-ui', 'sans-serif'],
15 | },
16 | animation: {
17 | // Modal
18 | 'scale-in': 'scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
19 | // Input Select
20 | 'input-select-slide-up':
21 | 'input-select-slide-up 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
22 | 'input-select-slide-down':
23 | 'input-select-slide-down 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
24 | // Tooltip
25 | 'slide-up-fade': 'slide-up-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
26 | 'slide-right-fade':
27 | 'slide-right-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
28 | 'slide-down-fade': 'slide-down-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
29 | 'slide-left-fade': 'slide-left-fade 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
30 | // Navigation menu
31 | 'enter-from-right': 'enter-from-right 0.25s ease',
32 | 'enter-from-left': 'enter-from-left 0.25s ease',
33 | 'exit-to-right': 'exit-to-right 0.25s ease',
34 | 'exit-to-left': 'exit-to-left 0.25s ease',
35 | 'scale-in-content': 'scale-in-content 0.2s ease',
36 | 'scale-out-content': 'scale-out-content 0.2s ease',
37 | // Accordion
38 | 'accordion-down': 'accordion-down 300ms cubic-bezier(0.87, 0, 0.13, 1)',
39 | 'accordion-up': 'accordion-up 300ms cubic-bezier(0.87, 0, 0.13, 1)',
40 | // Custom wiggle animation
41 | wiggle: 'wiggle 0.75s infinite',
42 | },
43 | keyframes: {
44 | // Modal
45 | 'scale-in': {
46 | '0%': { transform: 'scale(0.95)' },
47 | '100%': { transform: 'scale(1)' },
48 | },
49 | // Input Select
50 | 'input-select-slide-up': {
51 | '0%': { transform: 'translateY(-342px)' },
52 | '100%': { transform: 'translateY(-350px)' },
53 | },
54 | 'input-select-slide-down': {
55 | '0%': { transform: 'translateY(0px)' },
56 | '100%': { transform: 'translateY(8px)' },
57 | },
58 | // Tooltip
59 | 'slide-up-fade': {
60 | '0%': { opacity: 0, transform: 'translateY(2px)' },
61 | '100%': { opacity: 1, transform: 'translateY(0)' },
62 | },
63 | 'slide-right-fade': {
64 | '0%': { opacity: 0, transform: 'translateX(-2px)' },
65 | '100%': { opacity: 1, transform: 'translateX(0)' },
66 | },
67 | 'slide-down-fade': {
68 | '0%': { opacity: 0, transform: 'translateY(-2px)' },
69 | '100%': { opacity: 1, transform: 'translateY(0)' },
70 | },
71 | 'slide-left-fade': {
72 | '0%': { opacity: 0, transform: 'translateX(2px)' },
73 | '100%': { opacity: 1, transform: 'translateX(0)' },
74 | },
75 | // Navigation menu
76 | 'enter-from-right': {
77 | '0%': { transform: 'translateX(200px)', opacity: 0 },
78 | '100%': { transform: 'translateX(0)', opacity: 1 },
79 | },
80 | 'enter-from-left': {
81 | '0%': { transform: 'translateX(-200px)', opacity: 0 },
82 | '100%': { transform: 'translateX(0)', opacity: 1 },
83 | },
84 | 'exit-to-right': {
85 | '0%': { transform: 'translateX(0)', opacity: 1 },
86 | '100%': { transform: 'translateX(200px)', opacity: 0 },
87 | },
88 | 'exit-to-left': {
89 | '0%': { transform: 'translateX(0)', opacity: 1 },
90 | '100%': { transform: 'translateX(-200px)', opacity: 0 },
91 | },
92 | 'scale-in-content': {
93 | '0%': { transform: 'rotateX(-30deg) scale(0.9)', opacity: 0 },
94 | '100%': { transform: 'rotateX(0deg) scale(1)', opacity: 1 },
95 | },
96 | 'scale-out-content': {
97 | '0%': { transform: 'rotateX(0deg) scale(1)', opacity: 1 },
98 | '100%': { transform: 'rotateX(-10deg) scale(0.95)', opacity: 0 },
99 | },
100 | // Accordion
101 | 'accordion-down': {
102 | from: { height: 0 },
103 | to: { height: 'var(--radix-accordion-content-height)' },
104 | },
105 | 'accordion-up': {
106 | from: { height: 'var(--radix-accordion-content-height)' },
107 | to: { height: 0 },
108 | },
109 | // Custom wiggle animation
110 | wiggle: {
111 | '0%, 100%': {
112 | transform: 'translateX(0%)',
113 | transformOrigin: '50% 50%',
114 | },
115 | '15%': { transform: 'translateX(-4px) rotate(-4deg)' },
116 | '30%': { transform: 'translateX(6px) rotate(4deg)' },
117 | '45%': { transform: 'translateX(-6px) rotate(-2.4deg)' },
118 | '60%': { transform: 'translateX(2px) rotate(1.6deg)' },
119 | '75%': { transform: 'translateX(-1px) rotate(-0.8deg)' },
120 | },
121 | },
122 | colors: {
123 | brown: {
124 | 50: '#fdf8f6',
125 | 100: '#f2e8e5',
126 | 200: '#eaddd7',
127 | 300: '#e0cec7',
128 | 400: '#d2bab0',
129 | 500: '#bfa094',
130 | 600: '#a18072',
131 | 700: '#977669',
132 | 800: '#846358',
133 | 900: '#43302b',
134 | },
135 | },
136 | },
137 | },
138 | plugins: [
139 | require('@tailwindcss/forms'),
140 | require('@tailwindcss/typography'),
141 | require('tailwind-scrollbar-hide'),
142 | require('tailwindcss-radix')(),
143 | ],
144 | }
145 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "ESNext",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
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 | "paths": {
24 | "@/*": ["./*"]
25 | }
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/apps/custom-tweet-dub/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "pnpm turbo build --filter=custom-tweet-dub...",
3 | "ignoreCommand": "pnpm dlx turbo-ignore"
4 | }
5 |
--------------------------------------------------------------------------------
/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/tweet/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 | import { getTweet } from 'react-tweet/api'
3 |
4 | type RouteSegment = { params: { id: string } }
5 |
6 | export const fetchCache = 'only-cache'
7 |
8 | export async function GET(_req: Request, { params }: RouteSegment) {
9 | try {
10 | const tweet = await getTweet(params.id)
11 | return NextResponse.json(
12 | { data: tweet ?? null },
13 | { status: tweet ? 200 : 404 }
14 | )
15 | } catch (error: any) {
16 | console.error(error)
17 | return NextResponse.json(
18 | { error: error.message ?? 'Bad request.' },
19 | { status: 400 }
20 | )
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/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/[tweet]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Tweet } from 'react-tweet'
2 | import { getTweet } from 'react-tweet/api'
3 | import { components } from './tweet-components'
4 |
5 | type Props = {
6 | params: { tweet: string }
7 | }
8 |
9 | export const revalidate = 1800
10 |
11 | export async function generateMetadata({ params }: Props) {
12 | const tweet = await getTweet(params.tweet).catch(() => undefined)
13 |
14 | if (!tweet) return { title: 'Next Tweet' }
15 |
16 | const username = ` - @${tweet.user.screen_name}`
17 | const maxLength = 68 - username.length
18 | const text =
19 | tweet.text.length > maxLength
20 | ? `${tweet.text.slice(0, maxLength)}…`
21 | : tweet.text
22 |
23 | return { title: `${text}${username}` }
24 | }
25 |
26 | export default function Page({ params }: Props) {
27 | return
28 | }
29 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/[tweet]/tweet-components.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/alt-text */
2 | import Image from 'next/image'
3 | import type { TwitterComponents } from 'react-tweet'
4 |
5 | export const components: TwitterComponents = {
6 | AvatarImg: (props) => ,
7 | MediaImg: (props) => ,
8 | }
9 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/cache/[tweet]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react'
2 | import { TweetSkeleton } from 'react-tweet'
3 | import TweetPage from './tweet-page'
4 |
5 | export const revalidate = 86400
6 |
7 | const Page = ({ params }: { params: { tweet: string } }) => (
8 | }>
9 |
10 |
11 | )
12 |
13 | export default Page
14 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/cache/[tweet]/tweet-page.tsx:
--------------------------------------------------------------------------------
1 | import { unstable_cache } from 'next/cache'
2 | import { getTweet as _getTweet } from 'react-tweet/api'
3 | import { EmbeddedTweet, TweetNotFound } from 'react-tweet'
4 |
5 | const getTweet = unstable_cache(
6 | async (id: string) => _getTweet(id),
7 | ['tweet'],
8 | { revalidate: 3600 * 24 }
9 | )
10 |
11 | const TweetPage = async ({ id }: { id: string }) => {
12 | try {
13 | const tweet = await getTweet(id)
14 | return tweet ? :
15 | } catch (error) {
16 | console.error(error)
17 | return
18 | }
19 | }
20 |
21 | export default TweetPage
22 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/layout.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-family: var(--tweet-font-family);
3 | color: var(--tweet-font-color);
4 | background: var(--tweet-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 | margin-top: -0.5rem;
17 | }
18 |
--------------------------------------------------------------------------------
/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 Post from './post.mdx'
2 |
3 | export default function Page() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/mdx/post.mdx:
--------------------------------------------------------------------------------
1 | import { Tweet } from 'react-tweet'
2 |
3 |
4 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/suspense/[tweet]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react'
2 | import { TweetSkeleton } from 'react-tweet'
3 | import TweetPage from './tweet-page'
4 |
5 | export const revalidate = 3600
6 |
7 | const Page = ({ params }: { params: { tweet: string } }) => (
8 | }>
9 |
10 |
11 | )
12 |
13 | export default Page
14 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/suspense/[tweet]/tweet-page.tsx:
--------------------------------------------------------------------------------
1 | import { getTweet } from 'react-tweet/api'
2 | import { EmbeddedTweet, TweetNotFound } from 'react-tweet'
3 |
4 | const TweetPage = async ({ id }: { id: string }) => {
5 | try {
6 | const tweet = await getTweet(id)
7 | return tweet ? :
8 | } catch (error) {
9 | console.error(error)
10 | return
11 | }
12 | }
13 |
14 | export default TweetPage
15 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/vercel-kv/[tweet]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react'
2 | import { TweetSkeleton } from 'react-tweet'
3 | import TweetPage from './tweet-page'
4 |
5 | export const revalidate = 86400
6 |
7 | const Page = ({ params }: { params: { tweet: string } }) => (
8 | }>
9 |
10 |
11 | )
12 |
13 | export default Page
14 |
--------------------------------------------------------------------------------
/apps/next-app/app/light/vercel-kv/[tweet]/tweet-page.tsx:
--------------------------------------------------------------------------------
1 | import { fetchTweet, Tweet } from 'react-tweet/api'
2 | import { EmbeddedTweet, TweetNotFound } from 'react-tweet'
3 | import { kv } from '@vercel/kv'
4 |
5 | async function getTweet(
6 | id: string,
7 | fetchOptions?: RequestInit
8 | ): Promise {
9 | try {
10 | const { data, tombstone, notFound } = await fetchTweet(id, fetchOptions)
11 |
12 | if (data) {
13 | await kv.set(`tweet:${id}`, data)
14 | return data
15 | } else if (tombstone || notFound) {
16 | // remove the tweet from the cache if it has been made private by the author (tombstone)
17 | // or if it no longer exists.
18 | await kv.del(`tweet:${id}`)
19 | }
20 | } catch (error) {
21 | console.error('fetching the tweet failed with:', error)
22 | }
23 |
24 | const cachedTweet = await kv.get(`tweet:${id}`)
25 | return cachedTweet ?? undefined
26 | }
27 |
28 | const TweetPage = async ({ id }: { id: string }) => {
29 | try {
30 | const tweet = await getTweet(id)
31 | return tweet ? :
32 | } catch (error) {
33 | console.error(error)
34 | return
35 | }
36 | }
37 |
38 | export default TweetPage
39 |
--------------------------------------------------------------------------------
/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/tweet-page.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-family: var(--tweet-font-family);
3 | color: var(--tweet-font-color);
4 | background: var(--tweet-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 | margin-top: -0.5rem;
17 | }
18 |
--------------------------------------------------------------------------------
/apps/next-app/components/tweet-page.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 | import clsx from 'clsx'
3 | import s from './tweet-page.module.css'
4 |
5 | type Props = { children?: ReactNode; footer?: boolean }
6 |
7 | export const TweetPage = ({ 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-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // NOTE: This file should not be edited
6 | // see https://nextjs.org/docs/basic-features/typescript for more information.
7 |
--------------------------------------------------------------------------------
/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: [
10 | { protocol: 'https', hostname: 'pbs.twimg.com' },
11 | { protocol: 'https', hostname: 'abs.twimg.com' },
12 | ],
13 | },
14 | experimental: {
15 | mdxRs: true,
16 | },
17 | }
18 |
19 | export default withMDX(nextConfig)
20 |
--------------------------------------------------------------------------------
/apps/next-app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-app",
3 | "private": true,
4 | "license": "MIT",
5 | "repository": "https://github.com/vercel-labs/react-tweet.git",
6 | "author": "Luis Alvarez (https://twitter.com/luis_fades)",
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.0.4",
16 | "@vercel/kv": "^1.0.1",
17 | "clsx": "^2.0.0",
18 | "next": "14.0.4",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "react-tweet": "workspace:*"
22 | },
23 | "devDependencies": {
24 | "@types/node": "20.10.4",
25 | "@types/react": "^18.2.45",
26 | "eslint": "^8.56.0",
27 | "eslint-config-next": "^14.0.4",
28 | "typescript": "^5.3.3"
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/[tweet].tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router'
2 | import { getTweet, type Tweet } from 'react-tweet/api'
3 | import { EmbeddedTweet, TweetSkeleton } from 'react-tweet'
4 | import { TweetPage } from '../../components/tweet-page'
5 |
6 | export async function getStaticProps({
7 | params,
8 | }: {
9 | params: { tweet: string }
10 | }) {
11 | try {
12 | const tweet = await getTweet(params.tweet)
13 | return tweet ? { props: { tweet } } : { notFound: true }
14 | } catch (error) {
15 | console.error(error)
16 | return { notFound: true }
17 | }
18 | }
19 |
20 | export async function getStaticPaths() {
21 | return { paths: [], fallback: true }
22 | }
23 |
24 | export default function Page({ tweet }: { tweet: Tweet }) {
25 | const { isFallback } = useRouter()
26 |
27 | return (
28 |
29 | {isFallback ? : }
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/next-app/pages/dark/swr/[tweet].tsx:
--------------------------------------------------------------------------------
1 | import { Tweet } from 'react-tweet'
2 | import { useRouter } from 'next/router'
3 | import { TweetPage } from '../../../components/tweet-page'
4 |
5 | export default function Page() {
6 | const { query } = useRouter()
7 | const id = query.tweet
8 |
9 | return (
10 |
11 |
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/apps/next-app/readme.md:
--------------------------------------------------------------------------------
1 | # react-tweet for Next.js
2 |
3 | Follow the instructions in the [official docs](https://react-tweet.vercel.app/next) to learn more about `react-tweet` 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": true,
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 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/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-tweet site
2 |
3 | This is documentation site app for `react-tweet`. 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 http://localhost:3000.
14 |
--------------------------------------------------------------------------------
/apps/site/app/api/tweet/[id]/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 | import { getTweet } from 'react-tweet/api'
3 | import cors from 'edge-cors'
4 |
5 | type RouteSegment = { params: { id: string } }
6 |
7 | export const fetchCache = 'only-cache'
8 |
9 | export async function GET(req: Request, { params }: RouteSegment) {
10 | try {
11 | const tweet = await getTweet(params.id)
12 | return cors(
13 | req,
14 | NextResponse.json({ data: tweet ?? null }, { status: tweet ? 200 : 404 })
15 | )
16 | } catch (error: any) {
17 | console.error(error)
18 | return cors(
19 | req,
20 | NextResponse.json(
21 | { error: error.message ?? 'Bad request.' },
22 | { status: 400 }
23 | )
24 | )
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/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.js:
--------------------------------------------------------------------------------
1 | const withNextra = require('nextra')({
2 | theme: 'nextra-theme-docs',
3 | themeConfig: './theme.config.tsx',
4 | })
5 |
6 | module.exports = withNextra()
7 |
--------------------------------------------------------------------------------
/apps/site/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "site",
3 | "private": true,
4 | "license": "MIT",
5 | "repository": "https://github.com/vercel-labs/react-tweet.git",
6 | "author": "Luis Alvarez (https://twitter.com/luis_fades)",
7 | "description": "Official site and documentation for react-tweet",
8 | "scripts": {
9 | "dev": "next dev",
10 | "build": "next build",
11 | "start": "next start"
12 | },
13 | "dependencies": {
14 | "edge-cors": "^0.2.1",
15 | "next": "^14.0.4",
16 | "nextra": "^2.13.2",
17 | "nextra-theme-docs": "^2.13.2",
18 | "react": "^18.2.0",
19 | "react-dom": "^18.2.0",
20 | "react-tweet": "workspace:*"
21 | },
22 | "devDependencies": {
23 | "@types/node": "20.10.5",
24 | "@types/react": "^18.2.45",
25 | "autoprefixer": "^10.4.16",
26 | "postcss": "^8.4.32",
27 | "tailwindcss": "^3.3.6",
28 | "typescript": "^5.3.3"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/apps/site/pages/_app.mdx:
--------------------------------------------------------------------------------
1 | import '../styles/base.css'
2 |
3 | export default function App({ Component, pageProps }) {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/apps/site/pages/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "index": "Introduction",
3 | "-- Usage": {
4 | "type": "separator",
5 | "title": "Usage"
6 | },
7 | "next": "Next.js",
8 | "vite": "Vite",
9 | "create-react-app": "CRA",
10 | "api-reference": "",
11 | "-- Themes": {
12 | "type": "separator",
13 | "title": "Themes"
14 | },
15 | "twitter-theme": "",
16 | "custom-theme": "",
17 | "-- More": {
18 | "type": "separator",
19 | "title": "More"
20 | },
21 | "contributing": "",
22 | "next.js-link": {
23 | "title": "Next.js Docs ↗",
24 | "href": "https://nextjs.org?utm_source=react-tweet.site&utm_medium=referral&utm_campaign=sidebar",
25 | "newWindow": true
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/apps/site/pages/api-reference.mdx:
--------------------------------------------------------------------------------
1 | # API Reference
2 |
3 | This is the reference for the utility functions that `react-tweet` provides for [building your own tweet components](/custom-theme) or simply fetching a tweet. Navigate to the docs for the [Twitter theme](/twitter-theme) if you want to render the existing Tweet components instead.
4 |
5 | ## `getTweet`
6 |
7 | ```tsx
8 | import { getTweet, type Tweet } from 'react-tweet/api'
9 |
10 | function getTweet(
11 | id: string,
12 | fetchOptions?: RequestInit
13 | ): Promise
14 | ```
15 |
16 | Fetches and returns a [`Tweet`](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/api/types/tweet.ts). It accepts the following params:
17 |
18 | - **id** - `string`: the tweet ID. For example in `https://twitter.com/chibicode/status/1629307668568633344` the tweet ID is `1629307668568633344`.
19 | - **fetchOptions** - `RequestInit` (Optional): options to pass to [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch).
20 |
21 | If a tweet is not found it returns `undefined`.
22 |
23 | ## `fetchTweet`
24 |
25 | ```tsx
26 | function fetchTweet(
27 | id: string,
28 | fetchOptions?: RequestInit
29 | ): Promise<{
30 | data?: Tweet | undefined
31 | tombstone?: true | undefined
32 | notFound?: true | undefined
33 | }>
34 | ```
35 |
36 | Fetches and returns a [`Tweet`](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/api/types/tweet.ts) just like [`getTweet`](#gettweet), but it also returns additional information about the tweet:
37 |
38 | - **data** - `Tweet` (Optional): The tweet data.
39 | - **tombstone** - `true` (Optional): Indicates if the tweet has been made private.
40 | - **notFound** - `true` (Optional): Indicates if the tweet was not found.
41 |
42 | ## `enrichTweet`
43 |
44 | ```tsx
45 | import { enrichTweet, type EnrichedTweet } from 'react-tweet'
46 |
47 | const enrichTweet: (tweet: Tweet) => EnrichedTweet
48 | ```
49 |
50 | Enriches a [`Tweet`](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/api/types/tweet.ts) as returned by [`getTweet`](#gettweet) with additional data. This is useful to more easily build custom tweet components.
51 |
52 | It returns an [`EnrichedTweet`](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/utils.ts).
53 |
54 | ## `useTweet`
55 |
56 | > If your app supports React Server Components, use [`getTweet`](#gettweet) instead.
57 |
58 | ```tsx
59 | import { useTweet } from 'react-tweet'
60 |
61 | const useTweet: (
62 | id?: string,
63 | apiUrl?: string,
64 | fetchOptions?: RequestInit
65 | ) => {
66 | isLoading: boolean
67 | data: Tweet | null | undefined
68 | error: any
69 | }
70 | ```
71 |
72 | SWR hook for fetching a tweet in the browser. It accepts the following parameters:
73 |
74 | - **id** - `string`: the tweet ID. For example in `https://twitter.com/chibicode/status/1629307668568633344` the tweet ID is `1629307668568633344`. This parameter is not used if `apiUrl` is provided.
75 | - **apiUrl** - `string`: the API URL to fetch the tweet from. Defaults to `https://react-tweet.vercel.app/api/tweet/:id`.
76 | - **fetchOptions** - `RequestInit` (Optional): options to pass to [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch). Try to pass down a reference to the same object to avoid unnecessary re-renders.
77 |
78 | We highly recommend adding your own API endpoint in `apiUrl` for production:
79 |
80 | ```ts copy
81 | const tweet = useTweet(null, id && `/api/tweet/${id}`)
82 | ```
83 |
84 | It's likely you'll never use this hook directly, and `apiUrl` is passed as a prop to a component instead:
85 |
86 | ```tsx copy
87 |
88 | ```
89 |
90 | Or if the tweet component already knows about the endpoint it needs to use, you can use `id` instead:
91 |
92 | ```tsx copy
93 |
94 | ```
95 |
--------------------------------------------------------------------------------
/apps/site/pages/contributing.mdx:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | To contribute, clone the [`react-tweet` repository](https://github.com/vercel-labs/react-tweet) and run the [Next.js test app](/next#running-the-test-app) to start an app locally that uses the [`react-tweet` package](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet). 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/vercel-labs/react-tweet/compare).
6 |
7 | It's recommended to [open an issue](https://github.com/vercel-labs/react-tweet/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/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 `Tweet` from `react-tweet` and use it like so:
10 |
11 | ```tsx copy
12 | import { Tweet } from 'react-tweet'
13 |
14 | export default function App() {
15 | return
16 | }
17 | ```
18 |
19 | You can learn more about `Tweet` in the [Twitter theme docs](/twitter-theme).
20 |
21 | ## Running the test app
22 |
23 | Clone the [`react-tweet`](https://github.com/vercel-labs/react-tweet) repository and then run the following command:
24 |
25 | ```bash copy
26 | pnpm install && pnpm dev --filter=create-react-app...
27 | ```
28 |
29 | The app will be up and running at (localhost:3002)[http://localhost:3002] for the [CRA example](https://github.com/vercel-labs/react-tweet/tree/main/apps/create-react-app).
30 |
31 | The source code for `react-tweet` is imported from [packages/react-tweet](https://github.com/vercel-labs/react-tweet/tree/main/packages/react-tweet) and any changes you make to it will be reflected in the app immediately.
32 |
--------------------------------------------------------------------------------
/apps/site/pages/custom-theme.mdx:
--------------------------------------------------------------------------------
1 | # Custom Theme
2 |
3 | `react-tweet` exports multiple [utility functions](/api-reference) to help you build your own theme if the default [Twitter theme](/twitter-theme) and its customization options don't work for you or if you simply want to build your own.
4 |
5 | To get started, we recommend using the [source for the Twitter theme](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/tweet.tsx) as the base and start customizing from there. Which more precisely is all of the components in the [`react-tweet` package](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet):
6 |
7 | - [`src/tweet.tsx`](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/tweet.tsx): Exports the async `Tweet` component that fetches the tweet data and renders the tweet. This is a [React Server Component](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components).
8 | - [`src/twitter-theme/*.tsx`](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/twitter-theme): All the components that make up the theme.
9 | - [`src/swr.tsx`](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/swr.tsx): Exports the `Tweet` component but it uses [SWR](https://swr.vercel.app/) to fetch the tweet client-side. This is useful if React Server Components are not supported by your React environment.
10 |
11 | You can see a custom theme in action by looking at our [custom-tweet-dub](https://github.com/vercel-labs/react-tweet/blob/main/apps/custom-tweet-dub) example.
12 |
13 | ## Publishing your theme
14 |
15 | We recommend you follow the same patterns of the Twitter theme before publishing your theme:
16 |
17 | - Use the props defined by the `TweetProps` type in your Tweet component.
18 | - Support the CSS theme features shown in [Toggling theme manually](/#toggling-theme-manually). You can use the [`base.css`](https://github.com/vercel-labs/react-tweet/blob/main/packages/react-tweet/src/twitter-theme/theme.css) file from the Twitter theme as reference.
19 | - Support both SWR and React Server Components as explained below.
20 |
21 | When you use `react-tweet` we tell the builder which `Tweet` component to use with `exports` in `package.json`:
22 |
23 | ```json filename="package.json" copy
24 | "exports": {
25 | ".": {
26 | "react-server": "./dist/index.js",
27 | "default": "./dist/index.client.js"
28 | }
29 | },
30 | ```
31 |
32 | > You can learn more about `react-server` in the [RFC for React Server Module Conventions V2](https://github.com/reactjs/rfcs/blob/main/text/0227-server-module-conventions.md#react-server-conditional-exports).
33 |
34 | If the builder supports React Server Components, it will use the `react-server` export. Otherwise, it will use the `default` export.
35 |
36 | Each export goes to a different file that exports the `Tweet` component. In this case `index.ts` exports a React Server Component and `index.client.ts` exports the `Tweet` component that uses SWR:
37 |
38 | ```tsx filename="index.ts" {2} copy
39 | export * from './twitter-theme/components.js'
40 | export * from './tweet.js'
41 | export * from './utils.js'
42 | export * from './hooks.js'
43 | ```
44 |
45 | ```tsx filename="index.client.ts" {2} copy
46 | export * from './twitter-theme/components.js'
47 | export * from './swr.js'
48 | export * from './utils.js'
49 | export * from './hooks.js'
50 | ```
51 |
--------------------------------------------------------------------------------
/apps/site/pages/index.mdx:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | `react-tweet` allows you to embed tweets in your React application when using Next.js, Create React App, Vite, and more. This library does not require using the Twitter API. Tweets can be rendered statically, preventing the need to include an iframe and additional client-side JavaScript.
4 |
5 | You can see how it in action in [react-tweet-next.vercel.app/light/1629307668568633344](https://react-tweet-next.vercel.app/light/1629307668568633344). Replace the tweet ID in the URL to see other tweets.
6 |
7 | This library is fully compatible with React Server Components. [Learn more](https://nextjs.org/docs/getting-started/react-essentials#server-components).
8 |
9 | ## Installation
10 |
11 | Install `react-tweet` using your package manager of choice:
12 |
13 | ```bash
14 | pnpm add react-tweet
15 | ```
16 |
17 | ```bash
18 | yarn add react-tweet
19 | ```
20 |
21 | ```bash
22 | npm install react-tweet
23 | ```
24 |
25 | Now follow the usage instructions for your framework or builder:
26 |
27 | - [Next.js](/next)
28 | - [Vite](/vite)
29 | - [Create React App](/create-react-app)
30 |
31 | > **Important**: Before going to production, we recommend [enabling cache for the Twitter API](#enabling-cache-for-the-twitter-api) as server IPs might get rate limited by Twitter.
32 |
33 | ## Choosing a theme
34 |
35 | The [`prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) CSS media feature is used to select the theme of the tweet.
36 |
37 | ### Toggling theme manually
38 |
39 | The closest `data-theme` attribute on a parent element can determine the theme of the tweet. You can set it to `light` or `dark`, like so:
40 |
41 | ```tsx
42 |
43 |
44 |
45 | ```
46 |
47 | Alternatively, a parent with the class `light` or `dark` will also work:
48 |
49 | ```tsx
50 |
51 |
52 |
53 | ```
54 |
55 | ### Updating the theme
56 |
57 | In CSS Modules, you can use the `:global` selector to update the CSS variables used by themes:
58 |
59 | ```css
60 | .my-class :global(.react-tweet-theme) {
61 | --tweet-body-font-size: 1rem;
62 | }
63 | ```
64 |
65 | For Global CSS the usage of `:global` is not necessary.
66 |
67 | ## Enabling cache for the Twitter API
68 |
69 | Rendering tweets requires making a call to Twitter's syndication 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-tweet.vercel.app/api/tweet/:id`) 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.
70 |
71 | To prevent this, you can use a db like Redis or [Vercel KV](https://vercel.com/docs/storage/vercel-kv) to cache the tweets. For example using [Vercel KV](https://vercel.com/docs/storage/vercel-kv):
72 |
73 | ```tsx
74 | import { Suspense } from 'react'
75 | import { TweetSkeleton, EmbeddedTweet, TweetNotFound } from 'react-tweet'
76 | import { fetchTweet, Tweet } from 'react-tweet/api'
77 | import { kv } from '@vercel/kv'
78 |
79 | async function getTweet(
80 | id: string,
81 | fetchOptions?: RequestInit
82 | ): Promise {
83 | try {
84 | const { data, tombstone, notFound } = await fetchTweet(id, fetchOptions)
85 |
86 | if (data) {
87 | await kv.set(`tweet:${id}`, data)
88 | return data
89 | } else if (tombstone || notFound) {
90 | // remove the tweet from the cache if it has been made private by the author (tombstone)
91 | // or if it no longer exists.
92 | await kv.del(`tweet:${id}`)
93 | }
94 | } catch (error) {
95 | console.error('fetching the tweet failed with:', error)
96 | }
97 |
98 | const cachedTweet = await kv.get(`tweet:${id}`)
99 | return cachedTweet ?? undefined
100 | }
101 |
102 | const TweetPage = async ({ id }: { id: string }) => {
103 | try {
104 | const tweet = await getTweet(id)
105 | return tweet ? :
106 | } catch (error) {
107 | console.error(error)
108 | return
109 | }
110 | }
111 |
112 | const Page = ({ params }: { params: { tweet: string } }) => (
113 | }>
114 |
115 |
116 | )
117 |
118 | export default Page
119 | ```
120 |
121 | You can see it working at [react-tweet-next.vercel.app/light/vercel-kv/1629307668568633344](https://react-tweet-next.vercel.app/light/vercel-kv/1629307668568633344) ([source](https://github.com/vercel/react-tweet/blob/main/apps/next-app/app/light/vercel-kv/%5Btweet%5D/page.tsx)).
122 |
123 | If you're using Next.js then using [`unstable_cache`](/next#enabling-cache) works too.
124 |
--------------------------------------------------------------------------------
/apps/site/pages/next.mdx:
--------------------------------------------------------------------------------
1 | # Next.js
2 |
3 | ## Installation
4 |
5 | > Next.js 13.2.1 or higher is required in order to use `react-tweet`.
6 |
7 | Follow the [installation docs in the Introduction](/#installation).
8 |
9 | ## Usage
10 |
11 | In any component, import `Tweet` from `react-tweet` and use it like so:
12 |
13 | ```tsx copy
14 | import { Tweet } from 'react-tweet'
15 |
16 | export default function Page() {
17 | return
18 | }
19 | ```
20 |
21 | `Tweet` works differently depending on where it's used. If it's used in the App Router it will fetch the tweet in the server. If it's used in the pages directory it will fetch the tweet in the client with [SWR](https://swr.vercel.app/).
22 |
23 | You can learn more about `Tweet` in the [Twitter theme docs](/twitter-theme). And you can learn more about the usage in [Running the test app](#running-the-test-app).
24 |
25 | ### Troubleshooting
26 |
27 | 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`:
28 |
29 | ```js copy
30 | transpilePackages: ['react-tweet']
31 | ```
32 |
33 | 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).
34 |
35 | ### Enabling cache
36 |
37 | It's recommended to enable cache for the Twitter 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):
38 |
39 | ```tsx
40 | import { Suspense } from 'react'
41 | import { unstable_cache } from 'next/cache'
42 | import { TweetSkeleton, EmbeddedTweet, TweetNotFound } from 'react-tweet'
43 | import { getTweet as _getTweet } from 'react-tweet/api'
44 |
45 | const getTweet = unstable_cache(
46 | async (id: string) => _getTweet(id),
47 | ['tweet'],
48 | { revalidate: 3600 * 24 }
49 | )
50 |
51 | const TweetPage = async ({ id }: { id: string }) => {
52 | try {
53 | const tweet = await getTweet(id)
54 | return tweet ? :
55 | } catch (error) {
56 | console.error(error)
57 | return
58 | }
59 | }
60 |
61 | const Page = ({ params }: { params: { tweet: string } }) => (
62 | }>
63 |
64 |
65 | )
66 |
67 | export default Page
68 | ```
69 |
70 | This can prevent getting your server IPs rate limited if they are making too many requests to the Twitter API.
71 |
72 | ## Advanced usage
73 |
74 | ### Manual data fetching
75 |
76 | You can use the [`getTweet`](/api-reference#gettweet) function from `react-tweet/api` to fetch the tweet 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.
77 |
78 | For example, using `getStaticProps` in `pages/[tweet].tsx` to fetch the tweet and send it as props to the page component:
79 |
80 | ```tsx copy
81 | import { useRouter } from 'next/router'
82 | import { getTweet, type Tweet } from 'react-tweet/api'
83 | import { EmbeddedTweet, TweetSkeleton } from 'react-tweet'
84 |
85 | export async function getStaticProps({
86 | params,
87 | }: {
88 | params: { tweet: string }
89 | }) {
90 | const tweetId = params.tweet
91 |
92 | try {
93 | const tweet = await getTweet(tweetId)
94 | return tweet ? { props: { tweet } } : { notFound: true }
95 | } catch (error) {
96 | return { notFound: true }
97 | }
98 | }
99 |
100 | export async function getStaticPaths() {
101 | return { paths: [], fallback: true }
102 | }
103 |
104 | export default function Page({ tweet }: { tweet: Tweet }) {
105 | const { isFallback } = useRouter()
106 | return isFallback ? :
107 | }
108 | ```
109 |
110 | ### Adding `next/image`
111 |
112 | Add the domain URLs from Twitter to [`images.remotePatterns`](https://nextjs.org/docs/api-reference/next/image#remote-patterns) in `next.config.js`:
113 |
114 | ```js copy
115 | /** @type {import('next').NextConfig} */
116 | const nextConfig = {
117 | images: {
118 | remotePatterns: [
119 | { protocol: 'https', hostname: 'pbs.twimg.com' },
120 | { protocol: 'https', hostname: 'abs.twimg.com' },
121 | ],
122 | },
123 | }
124 | ```
125 |
126 | In `tweet-components.tsx` or elsewhere, import the `Image` component from `next/image` and use it to define custom image components for the tweet:
127 |
128 | ```tsx copy
129 | import Image from 'next/image'
130 | import type { TwitterComponents } from 'react-tweet'
131 |
132 | export const components: TwitterComponents = {
133 | AvatarImg: (props) => ,
134 | MediaImg: (props) => ,
135 | }
136 | ```
137 |
138 | Then pass the `components` prop to `Tweet`:
139 |
140 | ```tsx copy
141 | import { Tweet } from 'react-tweet'
142 | import { components } from './tweet-components'
143 |
144 | export default function Page() {
145 | return
146 | }
147 | ```
148 |
149 | ## Running the test app
150 |
151 | Clone the [`react-tweet`](https://github.com/vercel-labs/react-tweet) repository and then run the following command:
152 |
153 | ```bash copy
154 | pnpm install && pnpm dev --filter=next-app...
155 | ```
156 |
157 | The app will be up and running at http://localhost:3001 for the [Next.js app example](https://github.com/vercel-labs/react-tweet/tree/main/apps/next-app).
158 |
159 | The app shows the usage of `react-tweet` in different scenarios:
160 |
161 | - [localhost:3001/light/1629307668568633344](http://localhost:3001/light/1629307668568633344) renders the tweet in the app router.
162 | - [localhost:3001/dark/1629307668568633344](http://localhost:3001/dark/1629307668568633344) renders the tweet using SSG in the pages directory.
163 | - [localhost:3001/light/mdx](http://localhost:3001/light/mdx) rendes the tweet in MDX (with the experimental `mdxRs` config enabled).
164 | - [localhost:3001/light/suspense/1629307668568633344](http://localhost:3001/light/suspense/1629307668568633344) renders the tweet with a custom `Suspense` wrapper.
165 | - [localhost:3001/dark/swr/1629307668568633344](http://localhost:3001/dark/swr/1629307668568633344) uses `apiUrl` to change the API endpoint from which the tweet is fetched in SWR mode.
166 | - [localhost:3001/light/cache/1629307668568633344](http://localhost:3001/light/suspense/1629307668568633344) renders the tweet while caching the tweet data with [`unstable_cache`](https://nextjs.org/docs/app/api-reference/functions/unstable_cache).
167 | - [localhost:3001/light/vercel-kv/1629307668568633344](http://localhost:3001/light/suspense/1629307668568633344) renders the tweet while caching the tweet data with [Vercel KV](https://vercel.com/docs/storage/vercel-kv).
168 |
169 | The source code for `react-tweet` is imported from [packages/react-tweet](https://github.com/vercel-labs/react-tweet/tree/main/packages/react-tweet) and any changes you make to it will be reflected in the app immediately.
170 |
--------------------------------------------------------------------------------
/apps/site/pages/twitter-theme.mdx:
--------------------------------------------------------------------------------
1 | # Twitter Theme
2 |
3 | This is the theme you'll see in [publish.twitter.com](https://publish.twitter.com/?query=https%3A%2F%2Ftwitter.com%2FInterior%2Fstatus%2F463440424141459456&widget=Tweet) and the default theme included in `react-tweet`.
4 |
5 | ## Usage
6 |
7 | In any component, import `Tweet` from `react-tweet` and use it like so:
8 |
9 | ```tsx copy
10 | import { Tweet } from 'react-tweet'
11 |
12 | export default function Page() {
13 | return
14 | }
15 | ```
16 |
17 | ## Troubleshooting
18 |
19 | Currently, `react-tweet` uses CSS Modules to scope the CSS of each component, so the bundler where it's used needs to support CSS Modules. If you get issues about your bundler not recognizing CSS Modules, please open an issue as we would like to know how well supported this is.
20 |
--------------------------------------------------------------------------------
/apps/site/pages/twitter-theme/_meta.json:
--------------------------------------------------------------------------------
1 | {
2 | "api-reference": "",
3 | "advanced": ""
4 | }
5 |
--------------------------------------------------------------------------------
/apps/site/pages/twitter-theme/advanced.mdx:
--------------------------------------------------------------------------------
1 | # Advanced
2 |
3 | ## Customizing the theme components
4 |
5 | The components used by the Twitter theme allow some simple [customization options](/twitter-theme/api-reference#custom-tweet-components) for common use cases. However you can also have full control over the tweet by building your own `Tweet` component with the components and features of the theme that you would like to use.
6 |
7 | For example, you can build your own tweet component but without the reply button like so:
8 |
9 | ```tsx filename="my-tweet.tsx" copy
10 | import type { Tweet } from 'react-tweet/api'
11 | import {
12 | type TwitterComponents,
13 | TweetContainer,
14 | TweetHeader,
15 | TweetInReplyTo,
16 | TweetBody,
17 | TweetMedia,
18 | TweetInfo,
19 | TweetActions,
20 | QuotedTweet,
21 | enrichTweet,
22 | } from 'react-tweet'
23 |
24 | type Props = {
25 | tweet: Tweet
26 | components?: TwitterComponents
27 | }
28 |
29 | export const MyTweet = ({ tweet: t, components }: Props) => {
30 | const tweet = enrichTweet(t)
31 | return (
32 |
33 |
34 | {tweet.in_reply_to_status_id_str && }
35 |
36 | {tweet.mediaDetails?.length ? (
37 |
38 | ) : null}
39 | {tweet.quoted_tweet && }
40 |
41 |
42 | {/* We're not including the `TweetReplies` component that adds the reply button */}
43 |
44 | )
45 | }
46 | ```
47 |
48 | Then, you can build your own `Tweet` component that uses the `MyTweet` component:
49 |
50 | ```tsx filename="tweet.tsx" copy
51 | import { Suspense } from 'react'
52 | import { getTweet } from 'react-tweet/api'
53 | import { type TweetProps, TweetNotFound, TweetSkeleton } from 'react-tweet'
54 | import { MyTweet } from './my-tweet'
55 |
56 | const TweetContent = async ({ id, components, onError }: TweetProps) => {
57 | const tweet = id
58 | ? await getTweet(id).catch((err) => {
59 | if (onError) {
60 | onError(err)
61 | } else {
62 | console.error(err)
63 | }
64 | })
65 | : undefined
66 |
67 | if (!tweet) {
68 | const NotFound = components?.TweetNotFound || TweetNotFound
69 | return
70 | }
71 |
72 | return
73 | }
74 |
75 | export const Tweet = ({
76 | fallback = ,
77 | ...props
78 | }: TweetProps) => (
79 |
80 | {/* @ts-ignore: Async components are valid in the app directory */}
81 |
82 |
83 | )
84 | ```
85 |
86 | The `Tweet` component uses `Suspense` to progressively load the tweet (non-blocking rendering) and to opt-in into streaming if your framework supports it, like Next.js.
87 |
88 | `TweetContent` is an async component that fetches the tweet and passes it to `MyTweet`. `async` only works for [React Server Components (RSC)](https://react.dev/blog/2023/03/22/react-labs-what-we-have-been-working-on-march-2023#react-server-components) so if your framework does not support RSC you can use [SWR](https://swr.vercel.app/) instead:
89 |
90 | ```tsx filename="tweet.tsx" copy
91 | 'use client'
92 |
93 | import {
94 | type TweetProps,
95 | EmbeddedTweet,
96 | TweetNotFound,
97 | TweetSkeleton,
98 | useTweet,
99 | } from 'react-tweet'
100 |
101 | export const Tweet = ({
102 | id,
103 | apiUrl,
104 | fallback = ,
105 | components,
106 | onError,
107 | }: TweetProps) => {
108 | const { data, error, isLoading } = useTweet(id, apiUrl)
109 |
110 | if (isLoading) return fallback
111 | if (error || !data) {
112 | const NotFound = components?.TweetNotFound || TweetNotFound
113 | return
114 | }
115 |
116 | return
117 | }
118 | ```
119 |
--------------------------------------------------------------------------------
/apps/site/pages/twitter-theme/api-reference.mdx:
--------------------------------------------------------------------------------
1 | ## API Reference
2 |
3 | ### `Tweet`
4 |
5 | ```tsx copy
6 | import { Tweet } from 'react-tweet'
7 | ```
8 |
9 | ```tsx copy
10 |
11 | ```
12 |
13 | Fetches and renders the tweet. It accepts the following props:
14 |
15 | - **id** - `string`: the tweet ID. For example in `https://twitter.com/chibicode/status/1629307668568633344` the tweet ID is `1629307668568633344`. This is the only required prop.
16 | - **apiUrl** - `string`: the API URL to fetch the tweet from when using the tweet client-side with SWR. Defaults to `https://react-tweet.vercel.app/api/tweet/:id`.
17 | - **fallback** - `ReactNode`: The fallback component to render while the tweet is loading. Defaults to `TweetSkeleton`.
18 | - **onError** - `(error?: any) => any`: The returned error will be sent to the `TweetNotFound` component.
19 | - **components** - `TwitterComponents`: Components to replace the default tweet components. See the [custom tweet components](#custom-tweet-components) section for more details.
20 | - **fetchOptions** - `RequestInit`: options to pass to [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch).
21 |
22 | If the environment where `Tweet` is used does not support React Server Components then it will work with [SWR](https://swr.vercel.app/) instead and the tweet will be fetched from `https://react-tweet.vercel.app/api/tweet/:id`, which is CORS friendly.
23 |
24 | We highly recommend adding your own API route to fetch the tweet in production (as we cannot guarantee our IP will not get limited). You can do it by using the `apiUrl` prop:
25 |
26 | ```tsx copy
27 |
28 | ```
29 |
30 | > Note: `apiUrl` does nothing if the Tweet is rendered in a server component because it can fetch directly from Twitter's CDN.
31 |
32 | Here's a good example of how to setup your own API route:
33 |
34 | ```ts filename="api/tweet/[tweet].ts" copy
35 | import type { VercelRequest, VercelResponse } from '@vercel/node'
36 | import { getTweet } from 'react-tweet/api'
37 |
38 | const handler = async (req: VercelRequest, res: VercelResponse) => {
39 | const tweetId = req.query.tweet
40 |
41 | if (req.method !== 'GET' || typeof tweetId !== 'string') {
42 | res.status(400).json({ error: 'Bad Request.' })
43 | return
44 | }
45 |
46 | try {
47 | const tweet = await getTweet(tweetId)
48 | res.status(tweet ? 200 : 404).json({ data: tweet ?? null })
49 | } catch (error) {
50 | console.error(error)
51 | res.status(400).json({ error: error.message ?? 'Bad request.' })
52 | }
53 | }
54 |
55 | export default handler
56 | ```
57 |
58 | Something similar can be done with Next.js API Routes or Route Handlers.
59 |
60 | ### `EmbeddedTweet`
61 |
62 | ```tsx copy
63 | import { EmbeddedTweet } from 'react-tweet'
64 | ```
65 |
66 | Renders a tweet. It accepts the following props:
67 |
68 | - **tweet** - `Tweet`: the tweet data, as returned by `getTweet`. Required.
69 | - **components** - `TwitterComponents`: Components to replace the default tweet components. See the [custom tweet components](#custom-tweet-components) section for more details.
70 |
71 | ### `TweetSkeleton`
72 |
73 | ```tsx copy
74 | import { TweetSkeleton } from 'react-tweet'
75 | ```
76 |
77 | A tweet skeleton useful for loading states.
78 |
79 | ### `TweetNotFound`
80 |
81 | ```tsx copy
82 | import { TweetNotFound } from 'react-tweet'
83 | ```
84 |
85 | A tweet not found component. It accepts the following props:
86 |
87 | - **error** - `any`: the error that was thrown when fetching the tweet. Not required.
88 |
89 | ## Custom tweet components
90 |
91 | Default components used by [`Tweet`](#tweet) and [`EmbeddedTweet`](#embeddedtweet) can be replaced by passing a `components` prop. It extends the `TwitterComponents` type exported from `react-tweet`:
92 |
93 | ```ts copy
94 | type TwitterComponents = {
95 | TweetNotFound?: (props: Props) => JSX.Element
96 | AvatarImg?: (props: AvatarImgProps) => JSX.Element
97 | MediaImg?: (props: MediaImgProps) => JSX.Element
98 | }
99 | ```
100 |
101 | For example, to replace the default `img` tag used for the avatar and media with `next/image` you can do the following:
102 |
103 | ```tsx copy
104 | // tweet-components.tsx
105 | import Image from 'next/image'
106 | import type { TwitterComponents } from 'react-tweet'
107 |
108 | export const components: TwitterComponents = {
109 | AvatarImg: (props) => ,
110 | MediaImg: (props) => ,
111 | }
112 | ```
113 |
114 | And then pass the components to `Tweet` or `EmbeddedTweet`:
115 |
116 | ```tsx copy
117 | import { components } from './tweet-components'
118 |
119 | const MyTweet = ({ id }: { id: string }) => (
120 |
121 | )
122 | ```
123 |
--------------------------------------------------------------------------------
/apps/site/pages/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 `Tweet` from `react-tweet` and use it like so:
10 |
11 | ```tsx copy
12 | import { Tweet } from 'react-tweet'
13 |
14 | export const IndexPage = () =>
15 | ```
16 |
17 | You can learn more about `Tweet` in the [Twitter theme docs](/twitter-theme).
18 |
19 | ## Running the test app
20 |
21 | Clone the [`react-tweet`](https://github.com/vercel-labs/react-tweet) repository and then run the following command:
22 |
23 | ```bash copy
24 | pnpm install && pnpm dev --filter=vite-app...
25 | ```
26 |
27 | The app will be up and running at http://localhost:5173 for the [Vite app example](https://github.com/vercel-labs/react-tweet/tree/main/apps/vite-app).
28 |
29 | The app shows the usage of `react-tweet` in different scenarios:
30 |
31 | - [localhost:5173/](http://localhost:5173) renders a single tweet.
32 | - [localhost:5173/tweet/1629307668568633344](http://localhost:5173/tweet/1629307668568633344) renders dynamic tweets with SWR. `Tweet` already uses SWR and this page shows how to implement it manually.
33 |
34 | The source code for `react-tweet` is imported from [packages/react-tweet](https://github.com/vercel-labs/react-tweet/tree/main/packages/react-tweet) and any changes you make to it will be reflected in the app immediately.
35 |
--------------------------------------------------------------------------------
/apps/site/postcss.config.js:
--------------------------------------------------------------------------------
1 | // If you want to use other PostCSS plugins, see the following:
2 | // https://tailwindcss.com/docs/using-with-preprocessors
3 | module.exports = {
4 | plugins: {
5 | tailwindcss: {},
6 | autoprefixer: {},
7 | },
8 | }
9 |
--------------------------------------------------------------------------------
/apps/site/styles/base.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/apps/site/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
5 | './components/**/*.{js,ts,jsx,tsx}',
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, useConfig } from 'nextra-theme-docs'
3 |
4 | const config: DocsThemeConfig = {
5 | logo: react-tweet ,
6 | useNextSeoProps() {
7 | return {
8 | titleTemplate: '%s – react-tweet',
9 | }
10 | },
11 | head: function useHead() {
12 | const { title } = useConfig()
13 |
14 | return (
15 | <>
16 |
17 |
18 |
19 |
20 |
24 |
28 |
29 |
30 |
31 |
35 |
36 | >
37 | )
38 | },
39 | project: {
40 | link: 'https://github.com/vercel-labs/react-tweet',
41 | },
42 | docsRepositoryBase: 'https://github.com/vercel-labs/react-tweet',
43 | editLink: {
44 | text: 'Edit this page on GitHub →',
45 | },
46 | footer: {
47 | text: (
48 |
49 |
67 |
68 | © {new Date().getFullYear()} Vercel, Inc. All rights reserved.
69 |
70 |
71 | ),
72 | },
73 | }
74 |
75 | export default config
76 |
--------------------------------------------------------------------------------
/apps/site/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "incremental": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve",
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ]
26 | },
27 | "include": [
28 | "next-env.d.ts",
29 | "**/*.ts",
30 | "**/*.tsx",
31 | ".next/types/**/*.ts"
32 | ],
33 | "exclude": [
34 | "node_modules"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/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/tweet/[tweet].ts:
--------------------------------------------------------------------------------
1 | import type { VercelRequest, VercelResponse } from '@vercel/node'
2 | import { getTweet } from 'react-tweet/api'
3 |
4 | const handler = async (req: VercelRequest, res: VercelResponse) => {
5 | const tweetId = req.query.tweet
6 |
7 | if (req.method !== 'GET' || typeof tweetId !== 'string') {
8 | res.status(400).json({ error: 'Bad Request.' })
9 | return
10 | }
11 |
12 | try {
13 | const tweet = await getTweet(tweetId)
14 | res.status(tweet ? 200 : 404).json({ data: tweet ?? 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/vercel-labs/react-tweet.git",
6 | "author": "Luis Alvarez (https://twitter.com/luis_fades)",
7 | "type": "module",
8 | "scripts": {
9 | "dev": "vite",
10 | "build": "vite build",
11 | "start": "vite preview"
12 | },
13 | "dependencies": {
14 | "clsx": "^1.2.1",
15 | "react-tweet": "workspace:*",
16 | "react": "^18.2.0",
17 | "react-dom": "^18.2.0",
18 | "react-router-dom": "^6.9.0",
19 | "swr": "^2.1.0"
20 | },
21 | "devDependencies": {
22 | "@types/react": "^18.0.27",
23 | "@types/react-dom": "^18.0.10",
24 | "@vercel/node": "^2.9.12",
25 | "@vitejs/plugin-react-swc": "^3.0.0",
26 | "typescript": "^4.9.3",
27 | "vite": "^4.1.0"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/apps/vite-app/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/vite-app/readme.md:
--------------------------------------------------------------------------------
1 | # react-tweet for Vite
2 |
3 | Follow the instructions in the [official docs](https://react-tweet.vercel.app/vite) to learn more about `react-tweet` for Vite.
4 |
--------------------------------------------------------------------------------
/apps/vite-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/vite-app/src/layout.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-family: var(--tweet-font-family);
3 | color: var(--tweet-font-color);
4 | background: var(--tweet-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/vite-app/src/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router-dom'
2 | import clsx from 'clsx'
3 | import styles from './layout.module.css'
4 | import './base.css'
5 |
6 | export const Layout = () => (
7 |
8 |
9 |
10 |
11 |
12 | )
13 |
--------------------------------------------------------------------------------
/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 { TweetPage } from './pages/tweet'
7 |
8 | const router = createBrowserRouter([
9 | {
10 | path: '/',
11 | element: ,
12 | children: [
13 | { index: true, element: },
14 | { path: '/tweet/:id', element: },
15 | ],
16 | },
17 | ])
18 |
19 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
20 |
21 |
22 |
23 | )
24 |
--------------------------------------------------------------------------------
/apps/vite-app/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { Tweet } from 'react-tweet'
2 |
3 | export const IndexPage = () =>
4 |
--------------------------------------------------------------------------------
/apps/vite-app/src/pages/tweet.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from 'react-router-dom'
2 | import { EmbeddedTweet, TweetNotFound, TweetSkeleton } from 'react-tweet'
3 | import { type Tweet } from 'react-tweet/api'
4 | import useSWR from 'swr'
5 |
6 | async function fetcher(url: string) {
7 | const res = await fetch(url)
8 | const json = await res.json()
9 | return json.data
10 | }
11 |
12 | export const TweetPage = () => {
13 | const params = useParams()
14 | const { data, error, isLoading } = useSWR(
15 | // `/api/tweet` does not run locally with the vite server but it will work on Vercel.
16 | import.meta.env.PROD
17 | ? `/api/tweet/${params.id}`
18 | : `https://react-tweet.vercel.app/api/tweet/${params.id}`,
19 | fetcher
20 | )
21 |
22 | if (isLoading) return
23 | if (error || !data) return
24 |
25 | return
26 | }
27 |
--------------------------------------------------------------------------------
/apps/vite-app/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/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) 2023 Luis Alvarez.
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 | "repository": "https://github.com/vercel-labs/react-tweet.git",
3 | "license": "MIT",
4 | "private": true,
5 | "author": "Luis Alvarez (https://twitter.com/luis_fades)",
6 | "scripts": {
7 | "build": "turbo run build",
8 | "dev": "turbo run dev",
9 | "start": "turbo run start",
10 | "clean": "turbo run clean",
11 | "lint": "turbo run lint",
12 | "format": "prettier --write .",
13 | "changeset": "changeset",
14 | "version-packages": "changeset version",
15 | "release": "turbo run build && pnpm publish -r"
16 | },
17 | "prettier": {
18 | "semi": false,
19 | "singleQuote": true
20 | },
21 | "devDependencies": {
22 | "@changesets/cli": "^2.26.0",
23 | "prettier": "^2.8.4",
24 | "turbo": "^1.8.3"
25 | },
26 | "packageManager": "pnpm@9.5.0"
27 | }
28 |
--------------------------------------------------------------------------------
/packages/react-tweet/.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-tweet/.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-tweet/.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": false
24 | }
25 |
--------------------------------------------------------------------------------
/packages/react-tweet/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # react-tweet
2 |
3 | ## 3.2.2
4 |
5 | ### Patch Changes
6 |
7 | - b917cff: Fix for videos in mobile and included react 19 in peer dependencies.
8 |
9 | ## 3.2.1
10 |
11 | ### Patch Changes
12 |
13 | - 323e026: Added multiple improvements from community PRs:
14 |
15 | - https://github.com/vercel/react-tweet/pull/163: Removed the date-fns dependency and made the time component a server component.
16 | - https://github.com/vercel/react-tweet/pull/161: Updated media buttons to use X instead of Twitter.
17 | - https://github.com/vercel/react-tweet/pull/142: Added `Hexagon` to the API types.
18 | - https://github.com/vercel/react-tweet/pull/138: Add `nofollow` to tweet links.
19 |
20 | ## 3.2.0
21 |
22 | ### Minor Changes
23 |
24 | - 261e72d: Updated docs on caching tweets and added fetchTweet function.
25 |
26 | ## 3.1.1
27 |
28 | ### Patch Changes
29 |
30 | - dc5cadf: Added missing token to API requests to Twitter's CDN
31 |
32 | ## 3.1.0
33 |
34 | ### Minor Changes
35 |
36 | - 27d98ab: Added quoted tweet support and updated logo
37 |
38 | ## 3.0.4
39 |
40 | ### Patch Changes
41 |
42 | - 96e539a: - Fix position twitter icon for dir=rtl (#102)
43 | - Fix: bump author padding (#106)
44 | - Fix regression in media layout with multiple images (#103)
45 |
46 | ## 3.0.3
47 |
48 | ### Patch Changes
49 |
50 | - 42d317f: Updated Twitter theme
51 |
52 | ## 3.0.2
53 |
54 | ### Patch Changes
55 |
56 | - af50d09: Better CSS defaults to avoid external CSS conflicts
57 |
58 | ## 3.0.1
59 |
60 | ### Patch Changes
61 |
62 | - cc13ec8: Allow fetch options to be customized
63 |
64 | ## 3.0.0
65 |
66 | ### Major Changes
67 |
68 | - 7a92646: Theme support
69 |
70 | ## 2.0.2
71 |
72 | ### Patch Changes
73 |
74 | - 938522d: Check for default export in swr import
75 |
76 | ## 2.0.1
77 |
78 | ### Patch Changes
79 |
80 | - da92443: - Use `text-overflow: ellipsis` to truncate the user name when the width is small
81 | - Updated components type for `EmbeddedTweet` to exclude not found.
82 | - Added docs for CSS imports fix when importing the components in Next.js `pages`.
83 | - Use custom `MediaImg` component if provided.
84 |
85 | ## 2.0.0
86 |
87 | ### Major Changes
88 |
89 | - 54525e6: - Renamed `next-tweet` to `react-tweet`.
90 | - The whole API has changed to be more flexible and work outside of Next.js.
91 |
92 | ## 0.8.1
93 |
94 | ### Patch Changes
95 |
96 | - 2c38f71: Fix default theme not loading styles
97 |
98 | ## 0.8.0
99 |
100 | ### Minor Changes
101 |
102 | - f5a987e: Improved accessibility, added support for symbols, improved decoding and improved theming.
103 |
104 | ## 0.7.2
105 |
106 | ### Patch Changes
107 |
108 | - af479ea: Fixed theme in skeleton
109 |
110 | ## 0.7.1
111 |
112 | ### Patch Changes
113 |
114 | - 531bb40: Updated docs
115 |
116 | ## 0.7.0
117 |
118 | ### Minor Changes
119 |
120 | - b77efa2: Added video support and theme switching with class
121 |
122 | ## 0.6.1
123 |
124 | ### Patch Changes
125 |
126 | - 82c9f9d: Added support for prefers-color-scheme
127 |
128 | ## 0.6.0
129 |
130 | ### Minor Changes
131 |
132 | - 677d907: Added docs and improved theme support
133 |
134 | ## 0.5.0
135 |
136 | ### Minor Changes
137 |
138 | - 3918e7c: Updated Next.js minimum version
139 |
140 | ## 0.4.0
141 |
142 | ### Minor Changes
143 |
144 | - 91cf084: Use SWC for compilation, transpilePackages is no longer required for Next.js apps now
145 |
146 | ## 0.3.0
147 |
148 | ### Minor Changes
149 |
150 | - 07bce2b: Improved theme support and error handling
151 |
152 | ## 0.2.1
153 |
154 | ### Patch Changes
155 |
156 | - ec2e654: Moved use-mounted to typescript to avoid missing declaration error
157 |
158 | ## 0.2.0
159 |
160 | ### Minor Changes
161 |
162 | - 2702828: Added NextTweet component, and priority prop for images
163 |
164 | ## 0.1.0
165 |
166 | ### Minor Changes
167 |
168 | - b69303b: First version of next-tweet, with app directory support
169 |
--------------------------------------------------------------------------------
/packages/react-tweet/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.module.css' {
2 | const classes: { readonly [key: string]: string }
3 | export default classes
4 | }
5 |
--------------------------------------------------------------------------------
/packages/react-tweet/license.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2023 Luis Alvarez.
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-tweet/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tweet",
3 | "version": "3.2.2",
4 | "repository": "https://github.com/vercel-labs/react-tweet.git",
5 | "author": "Luis Alvarez (https://twitter.com/luis_fades)",
6 | "scripts": {
7 | "build": "pnpm build:swc && pnpm types",
8 | "build:swc": "swc src -d dist --copy-files",
9 | "dev": "pnpm build:swc -w",
10 | "types": "tsc --emitDeclarationOnly",
11 | "lint": "TIMING=1 eslint \"src/**/*.{ts,tsx,js,jsx}\" --fix",
12 | "clean": "rm -rf dist && rm -rf .turbo"
13 | },
14 | "license": "MIT",
15 | "bugs": {
16 | "url": "https://github.com/vercel-labs/react-tweet/issues"
17 | },
18 | "sideEffects": [
19 | "./dist/twitter-theme/tweet-container.js"
20 | ],
21 | "type": "module",
22 | "exports": {
23 | ".": {
24 | "react-server": "./dist/index.js",
25 | "default": "./dist/index.client.js"
26 | },
27 | "./api": "./dist/api/index.js",
28 | "./theme.css": "./dist/twitter-theme/theme.css"
29 | },
30 | "files": [
31 | "dist/**/*.{js,d.ts,css}"
32 | ],
33 | "typesVersions": {
34 | "*": {
35 | "index": [
36 | "src/index"
37 | ],
38 | "api": [
39 | "src/api/index"
40 | ],
41 | "*": []
42 | }
43 | },
44 | "publishConfig": {
45 | "access": "public",
46 | "typesVersions": {
47 | "*": {
48 | "index": [
49 | "dist/index.d.ts"
50 | ],
51 | "api": [
52 | "dist/api/index.d.ts"
53 | ],
54 | "*": []
55 | }
56 | }
57 | },
58 | "peerDependencies": {
59 | "react": "^18.0.0 || ^19.0.0",
60 | "react-dom": "^18.0.0 || ^19.0.0"
61 | },
62 | "dependencies": {
63 | "@swc/helpers": "^0.5.3",
64 | "clsx": "^2.0.0",
65 | "swr": "^2.2.4"
66 | },
67 | "devDependencies": {
68 | "@swc/cli": "^0.1.63",
69 | "@swc/core": "^1.3.100",
70 | "@types/node": "20.10.5",
71 | "@types/react": "^18.2.45",
72 | "chokidar": "^3.5.3",
73 | "eslint": "^8.56.0",
74 | "eslint-config-next": "^14.0.4",
75 | "typescript": "^5.3.3"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/packages/react-tweet/readme.md:
--------------------------------------------------------------------------------
1 | # react-tweet
2 |
3 | react-tweet allows you to embed tweets in your React application when using Next.js, Create React App, Vite, and more.
4 |
5 | For documentation visit [react-tweet.vercel.app](https://react-tweet.vercel.app).
6 |
7 | ## Contributing
8 |
9 | Visit our [contributing docs](https://react-tweet.vercel.app/contributing).
10 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/fetch-tweet.ts:
--------------------------------------------------------------------------------
1 | import type { Tweet } from './types/index.js'
2 |
3 | const SYNDICATION_URL = 'https://cdn.syndication.twimg.com'
4 |
5 | export class TwitterApiError extends Error {
6 | status: number
7 | data: any
8 |
9 | constructor({
10 | message,
11 | status,
12 | data,
13 | }: {
14 | message: string
15 | status: number
16 | data: any
17 | }) {
18 | super(message)
19 | this.name = 'TwitterApiError'
20 | this.status = status
21 | this.data = data
22 | }
23 | }
24 |
25 | const TWEET_ID = /^[0-9]+$/
26 |
27 | function getToken(id: string) {
28 | return ((Number(id) / 1e15) * Math.PI)
29 | .toString(6 ** 2)
30 | .replace(/(0+|\.)/g, '')
31 | }
32 |
33 | /**
34 | * Fetches a tweet from the Twitter syndication API.
35 | */
36 | export async function fetchTweet(
37 | id: string,
38 | fetchOptions?: RequestInit
39 | ): Promise<{ data?: Tweet; tombstone?: true; notFound?: true }> {
40 | if (id.length > 40 || !TWEET_ID.test(id)) {
41 | throw new Error(`Invalid tweet id: ${id}`)
42 | }
43 |
44 | const url = new URL(`${SYNDICATION_URL}/tweet-result`)
45 |
46 | url.searchParams.set('id', id)
47 | url.searchParams.set('lang', 'en')
48 | url.searchParams.set(
49 | 'features',
50 | [
51 | 'tfw_timeline_list:',
52 | 'tfw_follower_count_sunset:true',
53 | 'tfw_tweet_edit_backend:on',
54 | 'tfw_refsrc_session:on',
55 | 'tfw_fosnr_soft_interventions_enabled:on',
56 | 'tfw_show_birdwatch_pivots_enabled:on',
57 | 'tfw_show_business_verified_badge:on',
58 | 'tfw_duplicate_scribes_to_settings:on',
59 | 'tfw_use_profile_image_shape_enabled:on',
60 | 'tfw_show_blue_verified_badge:on',
61 | 'tfw_legacy_timeline_sunset:true',
62 | 'tfw_show_gov_verified_badge:on',
63 | 'tfw_show_business_affiliate_badge:on',
64 | 'tfw_tweet_edit_frontend:on',
65 | ].join(';')
66 | )
67 | url.searchParams.set('token', getToken(id))
68 |
69 | const res = await fetch(url.toString(), fetchOptions)
70 | const isJson = res.headers.get('content-type')?.includes('application/json')
71 | const data = isJson ? await res.json() : undefined
72 |
73 | if (res.ok) {
74 | if (data?.__typename === 'TweetTombstone') {
75 | return { tombstone: true }
76 | }
77 | return { data }
78 | }
79 | if (res.status === 404) {
80 | return { notFound: true }
81 | }
82 |
83 | throw new TwitterApiError({
84 | message:
85 | typeof data.error === 'string'
86 | ? data.error
87 | : `Failed to fetch tweet at "${url}" with "${res.status}".`,
88 | status: res.status,
89 | data,
90 | })
91 | }
92 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/get-oembed.ts:
--------------------------------------------------------------------------------
1 | export async function getOEmbed(url: string): Promise {
2 | const res = await fetch(`https://publish.twitter.com/oembed?url=${url}`)
3 |
4 | if (res.ok) return res.json()
5 | if (res.status === 404) return
6 |
7 | throw new Error(`Fetch for embedded tweet failed with code: ${res.status}`)
8 | }
9 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/get-tweet.ts:
--------------------------------------------------------------------------------
1 | import { fetchTweet } from './fetch-tweet.js'
2 | import type { Tweet } from './types/index.js'
3 |
4 | /**
5 | * Returns a tweet from the Twitter syndication API.
6 | */
7 | export async function getTweet(
8 | id: string,
9 | fetchOptions?: RequestInit
10 | ): Promise {
11 | const { data, tombstone, notFound } = await fetchTweet(id, fetchOptions)
12 |
13 | if (notFound) {
14 | console.error(
15 | `The tweet ${id} does not exist or has been deleted by the account owner. Update your code to remove this tweet when possible.`
16 | )
17 | } else if (tombstone) {
18 | console.error(
19 | `The tweet ${id} has been made private by the account owner. Update your code to remove this tweet when possible.`
20 | )
21 | }
22 |
23 | return data
24 | }
25 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types/index.js'
2 | export * from './fetch-tweet.js'
3 | export * from './get-tweet.js'
4 | export * from './get-oembed.js'
5 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/types/edit.ts:
--------------------------------------------------------------------------------
1 | export interface TweetEditControl {
2 | edit_tweet_ids: string[]
3 | editable_until_msecs: string
4 | is_edit_eligible: boolean
5 | edits_remaining: string
6 | }
7 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/types/entities.ts:
--------------------------------------------------------------------------------
1 | export type Indices = [number, number]
2 |
3 | export interface HashtagEntity {
4 | indices: Indices
5 | text: string
6 | }
7 |
8 | export interface UserMentionEntity {
9 | id_str: string
10 | indices: Indices
11 | name: string
12 | screen_name: string
13 | }
14 |
15 | export interface MediaEntity {
16 | display_url: string
17 | expanded_url: string
18 | indices: Indices
19 | url: string
20 | }
21 |
22 | export interface UrlEntity {
23 | display_url: string
24 | expanded_url: string
25 | indices: Indices
26 | url: string
27 | }
28 |
29 | export interface SymbolEntity {
30 | indices: Indices
31 | text: string
32 | }
33 |
34 | export interface TweetEntities {
35 | hashtags: HashtagEntity[]
36 | urls: UrlEntity[]
37 | user_mentions: UserMentionEntity[]
38 | symbols: SymbolEntity[]
39 | media?: MediaEntity[]
40 | }
41 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/types/index.ts:
--------------------------------------------------------------------------------
1 | export * from './edit.js'
2 | export * from './entities.js'
3 | export * from './media.js'
4 | export * from './photo.js'
5 | export * from './tweet.js'
6 | export * from './user.js'
7 | export * from './video.js'
8 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/types/media.ts:
--------------------------------------------------------------------------------
1 | import type { Indices } from './entities.js'
2 |
3 | export type RGB = {
4 | red: number
5 | green: number
6 | blue: number
7 | }
8 |
9 | export type Rect = {
10 | x: number
11 | y: number
12 | w: number
13 | h: number
14 | }
15 |
16 | export type Size = {
17 | h: number
18 | w: number
19 | resize: string
20 | }
21 |
22 | export interface VideoInfo {
23 | aspect_ratio: [number, number]
24 | variants: {
25 | bitrate?: number
26 | content_type: 'video/mp4' | 'application/x-mpegURL'
27 | url: string
28 | }[]
29 | }
30 |
31 | interface MediaBase {
32 | display_url: string
33 | expanded_url: string
34 | ext_media_availability: {
35 | status: string
36 | }
37 | ext_media_color: {
38 | palette: {
39 | percentage: number
40 | rgb: RGB
41 | }[]
42 | }
43 | indices: Indices
44 | media_url_https: string
45 | original_info: {
46 | height: number
47 | width: number
48 | focus_rects: Rect[]
49 | }
50 | sizes: {
51 | large: Size
52 | medium: Size
53 | small: Size
54 | thumb: Size
55 | }
56 | url: string
57 | }
58 |
59 | export interface MediaPhoto extends MediaBase {
60 | type: 'photo'
61 | ext_alt_text?: string
62 | }
63 |
64 | export interface MediaAnimatedGif extends MediaBase {
65 | type: 'animated_gif'
66 | video_info: VideoInfo
67 | }
68 |
69 | export interface MediaVideo extends MediaBase {
70 | type: 'video'
71 | video_info: VideoInfo
72 | }
73 |
74 | export type MediaDetails = MediaPhoto | MediaAnimatedGif | MediaVideo
75 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/types/photo.ts:
--------------------------------------------------------------------------------
1 | import type { Rect, RGB } from './media.js'
2 |
3 | export interface TweetPhoto {
4 | backgroundColor: RGB
5 | cropCandidates: Rect[]
6 | expandedUrl: string
7 | url: string
8 | width: number
9 | height: number
10 | }
11 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/types/tweet.ts:
--------------------------------------------------------------------------------
1 | import type { TweetEditControl } from './edit.js'
2 | import type { Indices, TweetEntities } from './entities.js'
3 | import type { MediaDetails } from './media.js'
4 | import type { TweetPhoto } from './photo.js'
5 | import type { TweetUser } from './user.js'
6 | import type { TweetVideo } from './video.js'
7 |
8 | /**
9 | * Base tweet information shared by a tweet, a parent tweet and a quoted tweet.
10 | */
11 | export interface TweetBase {
12 | /**
13 | * Language code of the tweet. E.g "en", "es".
14 | */
15 | lang: string
16 | /**
17 | * Creation date of the tweet in the format ISO 8601.
18 | */
19 | created_at: string
20 | /**
21 | * Text range of the tweet text.
22 | */
23 | display_text_range: Indices
24 | /**
25 | * All the entities that are part of the tweet. Like hashtags, mentions, urls, etc.
26 | */
27 | entities: TweetEntities
28 | /**
29 | * The unique identifier of the tweet.
30 | */
31 | id_str: string
32 | /**
33 | * The tweet text, including the raw text from the entities.
34 | */
35 | text: string
36 | /**
37 | * Information about the user who posted the tweet.
38 | */
39 | user: TweetUser
40 | /**
41 | * Edit information about the tweet.
42 | */
43 | edit_control: TweetEditControl
44 | isEdited: boolean
45 | isStaleEdit: boolean
46 | }
47 |
48 | /**
49 | * A tweet as returned by the the Twitter syndication API.
50 | */
51 | export interface Tweet extends TweetBase {
52 | __typename: 'Tweet'
53 | favorite_count: number
54 | mediaDetails?: MediaDetails[]
55 | photos?: TweetPhoto[]
56 | video?: TweetVideo
57 | conversation_count: number
58 | news_action_type: 'conversation'
59 | quoted_tweet?: QuotedTweet
60 | in_reply_to_screen_name?: string
61 | in_reply_to_status_id_str?: string
62 | in_reply_to_user_id_str?: string
63 | parent?: TweetParent
64 | possibly_sensitive?: boolean
65 | }
66 |
67 | /**
68 | * The parent tweet of a tweet reply.
69 | */
70 | export interface TweetParent extends TweetBase {
71 | reply_count: number
72 | retweet_count: number
73 | favorite_count: number
74 | }
75 |
76 | /**
77 | * A tweet quoted by another tweet.
78 | */
79 | export interface QuotedTweet extends TweetBase {
80 | reply_count: number
81 | retweet_count: number
82 | favorite_count: number
83 | mediaDetails?: MediaDetails[]
84 | self_thread: {
85 | id_str: string
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/types/user.ts:
--------------------------------------------------------------------------------
1 | export interface TweetUser {
2 | id_str: string
3 | name: string
4 | profile_image_url_https: string
5 | profile_image_shape: 'Circle' | 'Square' | 'Hexagon'
6 | screen_name: string
7 | verified: boolean
8 | verified_type?: 'Business' | 'Government'
9 | is_blue_verified: boolean
10 | }
11 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/api/types/video.ts:
--------------------------------------------------------------------------------
1 | export interface TweetVideo {
2 | aspectRatio: [number, number]
3 | contentType: string
4 | durationMs: number
5 | mediaAvailability: {
6 | status: string
7 | }
8 | poster: string
9 | variants: {
10 | type: string
11 | src: string
12 | }[]
13 | videoId: {
14 | type: string
15 | id: string
16 | }
17 | viewCount: number
18 | }
19 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/date-utils.ts:
--------------------------------------------------------------------------------
1 | type PartsObject = Record
2 |
3 | const options: Intl.DateTimeFormatOptions = {
4 | hour: 'numeric',
5 | minute: '2-digit',
6 | hour12: true,
7 | weekday: 'short',
8 | month: 'short',
9 | day: 'numeric',
10 | year: 'numeric',
11 | }
12 |
13 | const formatter = new Intl.DateTimeFormat('en-US', options)
14 |
15 | const partsArrayToObject = (
16 | parts: ReturnType
17 | ): PartsObject => {
18 | const result = {} as PartsObject
19 |
20 | for (const part of parts) {
21 | result[part.type] = part.value
22 | }
23 |
24 | return result
25 | }
26 |
27 | export const formatDate = (date: Date) => {
28 | const parts = partsArrayToObject(formatter.formatToParts(date))
29 | const formattedTime = `${parts.hour}:${parts.minute} ${parts.dayPeriod}`
30 | const formattedDate = `${parts.month} ${parts.day}, ${parts.year}`
31 | return `${formattedTime} · ${formattedDate}`
32 | }
33 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/hooks.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState } from 'react'
4 | import swr from 'swr'
5 | import { type Tweet, TwitterApiError } from './api/index.js'
6 |
7 | // Avoids an error when used in the pages directory where useSWR might be in `default`.
8 | const useSWR = ((swr as any).default as typeof swr) || swr
9 | const host = 'https://react-tweet.vercel.app'
10 |
11 | async function fetcher([url, fetchOptions]: [
12 | string,
13 | RequestInit
14 | ]): Promise {
15 | const res = await fetch(url, fetchOptions)
16 | const json = await res.json()
17 |
18 | // We return null in case `json.data` is undefined, that way we can check for "loading" by
19 | // checking if data is `undefined`. `null` means it was fetched.
20 | if (res.ok) return json.data || null
21 |
22 | throw new TwitterApiError({
23 | message: `Failed to fetch tweet at "${url}" with "${res.status}".`,
24 | data: json,
25 | status: res.status,
26 | })
27 | }
28 |
29 | /**
30 | * SWR hook for fetching a tweet in the browser.
31 | */
32 | export const useTweet = (
33 | id?: string,
34 | apiUrl?: string,
35 | fetchOptions?: RequestInit
36 | ) => {
37 | const { isLoading, data, error } = useSWR(
38 | () =>
39 | apiUrl || id
40 | ? [apiUrl || (id && `${host}/api/tweet/${id}`), fetchOptions]
41 | : null,
42 | fetcher,
43 | {
44 | revalidateIfStale: false,
45 | revalidateOnFocus: false,
46 | shouldRetryOnError: false,
47 | }
48 | )
49 |
50 | return {
51 | // If data is `undefined` then it might be the first render where SWR hasn't started doing
52 | // any work, so we set `isLoading` to `true`.
53 | isLoading: Boolean(isLoading || (data === undefined && !error)),
54 | data,
55 | error,
56 | }
57 | }
58 |
59 | export const useMounted = () => {
60 | const [mounted, setMounted] = useState(false)
61 |
62 | useEffect(() => setMounted(true), [])
63 |
64 | return mounted
65 | }
66 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/index.client.ts:
--------------------------------------------------------------------------------
1 | export * from './twitter-theme/components.js'
2 | export * from './swr.js'
3 | export * from './utils.js'
4 | export * from './hooks.js'
5 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/index.ts:
--------------------------------------------------------------------------------
1 | // Export every other component that's part of our default theme (the Twitter theme) as that
2 | // can be useful for anyone that wans to do more deep edits in the default theme.
3 | export * from './twitter-theme/components.js'
4 | export * from './tweet.js'
5 | export * from './utils.js'
6 | export * from './hooks.js'
7 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/swr.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { type ReactNode } from 'react'
4 | import {
5 | EmbeddedTweet,
6 | TweetNotFound,
7 | TweetSkeleton,
8 | type TwitterComponents,
9 | } from './twitter-theme/components.js'
10 | import { type TweetCoreProps } from './utils.js'
11 | import { useTweet } from './hooks.js'
12 |
13 | export type TweetProps = Omit & {
14 | fallback?: ReactNode
15 | components?: TwitterComponents
16 | fetchOptions?: RequestInit
17 | } & (
18 | | {
19 | id: string
20 | apiUrl?: string
21 | }
22 | | {
23 | id?: string
24 | apiUrl: string | undefined
25 | }
26 | )
27 |
28 | export const Tweet = ({
29 | id,
30 | apiUrl,
31 | fallback = ,
32 | components,
33 | fetchOptions,
34 | onError,
35 | }: TweetProps) => {
36 | const { data, error, isLoading } = useTweet(id, apiUrl, fetchOptions)
37 |
38 | if (isLoading) return fallback
39 | if (error || !data) {
40 | const NotFound = components?.TweetNotFound || TweetNotFound
41 | return
42 | }
43 |
44 | return
45 | }
46 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/tweet.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react'
2 | import { getTweet } from './api/index.js'
3 | import {
4 | EmbeddedTweet,
5 | TweetNotFound,
6 | TweetSkeleton,
7 | } from './twitter-theme/components.js'
8 | import type { TweetProps } from './swr.js'
9 |
10 | // This is not ideal because we don't use the `apiUrl` prop here and `id` is required. But as the
11 | // type is shared with the SWR version when the Tweet component is imported, we need to have a type
12 | // that supports both versions of the component.
13 | export type { TweetProps }
14 |
15 | type TweetContentProps = Omit
16 |
17 | const TweetContent = async ({
18 | id,
19 | components,
20 | fetchOptions,
21 | onError,
22 | }: TweetContentProps) => {
23 | let error
24 | const tweet = id
25 | ? await getTweet(id, fetchOptions).catch((err) => {
26 | if (onError) {
27 | error = onError(err)
28 | } else {
29 | console.error(err)
30 | error = err
31 | }
32 | })
33 | : undefined
34 |
35 | if (!tweet) {
36 | const NotFound = components?.TweetNotFound || TweetNotFound
37 | return
38 | }
39 |
40 | return
41 | }
42 |
43 | export const Tweet = ({
44 | fallback = ,
45 | ...props
46 | }: TweetProps) => (
47 |
48 | {/* @ts-ignore: Async components are valid in the app directory */}
49 |
50 |
51 | )
52 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/avatar-img.tsx:
--------------------------------------------------------------------------------
1 | type AvatarImgProps = {
2 | src: string
3 | alt: string
4 | width: number
5 | height: number
6 | }
7 |
8 | // eslint-disable-next-line jsx-a11y/alt-text -- The alt text is part of `...props`
9 | export const AvatarImg = (props: AvatarImgProps) =>
10 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/components.tsx:
--------------------------------------------------------------------------------
1 | export * from './types.js'
2 | export * from './icons/index.js'
3 | export * from './embedded-tweet.js'
4 | export * from './tweet-actions-copy.js'
5 | export * from './tweet-actions.js'
6 | export * from './tweet-body.js'
7 | export * from './tweet-container.js'
8 | export * from './tweet-header.js'
9 | export * from './tweet-in-reply-to.js'
10 | export * from './tweet-info-created-at.js'
11 | export * from './tweet-info.js'
12 | export * from './tweet-link.js'
13 | export * from './tweet-media-video.js'
14 | export * from './tweet-media.js'
15 | export * from './tweet-not-found.js'
16 | export * from './tweet-replies.js'
17 | export * from './tweet-skeleton.js'
18 | export * from './quoted-tweet/index.js'
19 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/embedded-tweet.tsx:
--------------------------------------------------------------------------------
1 | import type { Tweet } from '../api/index.js'
2 | import type { TwitterComponents } from './types.js'
3 | import { TweetContainer } from './tweet-container.js'
4 | import { TweetHeader } from './tweet-header.js'
5 | import { TweetInReplyTo } from './tweet-in-reply-to.js'
6 | import { TweetBody } from './tweet-body.js'
7 | import { TweetMedia } from './tweet-media.js'
8 | import { TweetInfo } from './tweet-info.js'
9 | import { TweetActions } from './tweet-actions.js'
10 | import { TweetReplies } from './tweet-replies.js'
11 | import { QuotedTweet } from './quoted-tweet/index.js'
12 | import { enrichTweet } from '../utils.js'
13 | import { useMemo } from 'react'
14 |
15 | type Props = {
16 | tweet: Tweet
17 | components?: Omit
18 | }
19 |
20 | export const EmbeddedTweet = ({ tweet: t, components }: Props) => {
21 | // useMemo does nothing for RSC but it helps when the component is used in the client (e.g by SWR)
22 | const tweet = useMemo(() => enrichTweet(t), [t])
23 | return (
24 |
25 |
26 | {tweet.in_reply_to_status_id_str && }
27 |
28 | {tweet.mediaDetails?.length ? (
29 |
30 | ) : null}
31 | {tweet.quoted_tweet && }
32 |
33 |
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/icons/icons.module.css:
--------------------------------------------------------------------------------
1 | .verified {
2 | margin-left: 0.125rem;
3 | max-width: 20px;
4 | max-height: 20px;
5 | height: 1.25em;
6 | fill: currentColor;
7 | user-select: none;
8 | vertical-align: text-bottom;
9 | }
10 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/icons/index.ts:
--------------------------------------------------------------------------------
1 | export * from './verified.js'
2 | export * from './verified-business.js'
3 | export * from './verified-government.js'
4 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/icons/verified-business.tsx:
--------------------------------------------------------------------------------
1 | import s from './icons.module.css'
2 |
3 | export const VerifiedBusiness = () => (
4 |
10 |
11 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
33 |
34 |
35 |
36 |
37 |
38 |
42 |
46 |
50 |
51 |
52 |
53 | )
54 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/icons/verified-government.tsx:
--------------------------------------------------------------------------------
1 | import s from './icons.module.css'
2 |
3 | export const VerifiedGovernment = () => (
4 |
10 |
11 |
16 |
17 |
18 | )
19 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/icons/verified.tsx:
--------------------------------------------------------------------------------
1 | import s from './icons.module.css'
2 |
3 | export const Verified = () => (
4 |
10 |
11 |
12 |
13 |
14 | )
15 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/media-img.tsx:
--------------------------------------------------------------------------------
1 | type MediaImgProps = {
2 | src: string
3 | alt: string
4 | className?: string
5 | draggable?: boolean
6 | }
7 |
8 | // eslint-disable-next-line jsx-a11y/alt-text -- The alt text is part of `...props`
9 | export const MediaImg = (props: MediaImgProps) =>
10 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/quoted-tweet/index.ts:
--------------------------------------------------------------------------------
1 | export * from './quoted-tweet.js'
2 | export * from './quoted-tweet-container.js'
3 | export * from './quoted-tweet-header.js'
4 | export * from './quoted-tweet-body.js'
5 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/quoted-tweet/quoted-tweet-body.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-size: var(--tweet-quoted-body-font-size);
3 | font-weight: var(--tweet-quoted-body-font-weight);
4 | line-height: var(--tweet-quoted-body-line-height);
5 | margin: var(--tweet-quoted-body-margin);
6 | overflow-wrap: break-word;
7 | white-space: pre-wrap;
8 | padding: 0 0.75rem;
9 | }
10 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/quoted-tweet/quoted-tweet-body.tsx:
--------------------------------------------------------------------------------
1 | import type { EnrichedQuotedTweet } from '../../utils.js'
2 | import s from './quoted-tweet-body.module.css'
3 |
4 | type Props = { tweet: EnrichedQuotedTweet }
5 |
6 | export const QuotedTweetBody = ({ tweet }: Props) => (
7 |
8 | {tweet.entities.map((item, i) => (
9 |
10 | ))}
11 |
12 | )
13 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/quoted-tweet/quoted-tweet-container.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | overflow: hidden;
4 | border: var(--tweet-border);
5 | border-radius: 12px;
6 | margin: var(--tweet-quoted-container-margin);
7 | transition-property: background-color, box-shadow;
8 | transition-duration: 0.2s;
9 | cursor: pointer;
10 | }
11 |
12 | .root:hover {
13 | background-color: var(--tweet-quoted-bg-color-hover);
14 | }
15 |
16 | .article {
17 | position: relative;
18 | box-sizing: inherit;
19 | }
20 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/quoted-tweet/quoted-tweet-container.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import type { ReactNode } from 'react'
4 | import type { EnrichedQuotedTweet } from '../../utils.js'
5 | import s from './quoted-tweet-container.module.css'
6 |
7 | type Props = { tweet: EnrichedQuotedTweet; children: ReactNode }
8 |
9 | export const QuotedTweetContainer = ({ tweet, children }: Props) => (
10 | {
13 | e.preventDefault()
14 | window.open(tweet.url, '_blank')
15 | }}
16 | >
17 |
{children}
18 |
19 | )
20 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/quoted-tweet/quoted-tweet-header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | display: flex;
3 | padding: 0.75rem 0.75rem 0 0.75rem;
4 | line-height: var(--tweet-header-line-height);
5 | font-size: var(--tweet-header-font-size);
6 | white-space: nowrap;
7 | overflow-wrap: break-word;
8 | overflow: hidden;
9 | }
10 |
11 | .avatar {
12 | position: relative;
13 | height: 20px;
14 | width: 20px;
15 | }
16 |
17 | .avatarSquare {
18 | border-radius: 4px;
19 | }
20 |
21 | .author {
22 | display: flex;
23 | margin: 0 0.5rem;
24 | }
25 |
26 | .authorText {
27 | font-weight: 700;
28 | text-overflow: ellipsis;
29 | overflow: hidden;
30 | white-space: nowrap;
31 | }
32 |
33 | .username {
34 | color: var(--tweet-font-color-secondary);
35 | text-decoration: none;
36 | text-overflow: ellipsis;
37 | margin-left: 0.125rem;
38 | }
39 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/quoted-tweet/quoted-tweet-header.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import { AvatarImg } from '../avatar-img.js'
3 | import s from './quoted-tweet-header.module.css'
4 | import type { EnrichedQuotedTweet } from '../../utils.js'
5 | import { VerifiedBadge } from '../verified-badge.js'
6 |
7 | type Props = { tweet: EnrichedQuotedTweet }
8 |
9 | export const QuotedTweetHeader = ({ tweet }: Props) => {
10 | const { user } = tweet
11 |
12 | return (
13 |
14 |
20 |
33 |
34 |
35 |
36 | {user.name}
37 |
38 |
39 |
40 | @{user.screen_name}
41 |
42 |
43 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/quoted-tweet/quoted-tweet.tsx:
--------------------------------------------------------------------------------
1 | import type { EnrichedQuotedTweet } from '../../utils.js'
2 | import { QuotedTweetContainer } from './quoted-tweet-container.js'
3 | import { QuotedTweetHeader } from './quoted-tweet-header.js'
4 | import { QuotedTweetBody } from './quoted-tweet-body.js'
5 | import { TweetMedia } from '../tweet-media.js'
6 |
7 | type Props = { tweet: EnrichedQuotedTweet }
8 |
9 | export const QuotedTweet = ({ tweet }: Props) => (
10 |
11 |
12 |
13 | {tweet.mediaDetails?.length ? : null}
14 |
15 | )
16 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/skeleton.module.css:
--------------------------------------------------------------------------------
1 | .skeleton {
2 | display: block;
3 | width: 100%;
4 | border-radius: 5px;
5 | background-image: var(--tweet-skeleton-gradient);
6 | background-size: 400% 100%;
7 | animation: loading 8s ease-in-out infinite;
8 | }
9 |
10 | @media (prefers-reduced-motion: reduce) {
11 | .skeleton {
12 | animation: none;
13 | background-position: 200% 0;
14 | }
15 | }
16 |
17 | @keyframes loading {
18 | 0% {
19 | background-position: 200% 0;
20 | }
21 | 100% {
22 | background-position: -200% 0;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import type { HTMLAttributes } from 'react'
2 | import styles from './skeleton.module.css'
3 |
4 | export const Skeleton = ({ style }: HTMLAttributes) => (
5 |
6 | )
7 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/theme.css:
--------------------------------------------------------------------------------
1 | .react-tweet-theme {
2 | --tweet-container-margin: 1.5rem 0;
3 |
4 | /* Header */
5 | --tweet-header-font-size: 0.9375rem;
6 | --tweet-header-line-height: 1.25rem;
7 |
8 | /* Text */
9 | --tweet-body-font-size: 1.25rem;
10 | --tweet-body-font-weight: 400;
11 | --tweet-body-line-height: 1.5rem;
12 | --tweet-body-margin: 0;
13 |
14 | /* Quoted Tweet */
15 | --tweet-quoted-container-margin: 0.75rem 0;
16 | --tweet-quoted-body-font-size: 0.938rem;
17 | --tweet-quoted-body-font-weight: 400;
18 | --tweet-quoted-body-line-height: 1.25rem;
19 | --tweet-quoted-body-margin: 0.25rem 0 0.75rem 0;
20 |
21 | /* Info */
22 | --tweet-info-font-size: 0.9375rem;
23 | --tweet-info-line-height: 1.25rem;
24 |
25 | /* Actions like the like, reply and copy buttons */
26 | --tweet-actions-font-size: 0.875rem;
27 | --tweet-actions-line-height: 1rem;
28 | --tweet-actions-font-weight: 700;
29 | --tweet-actions-icon-size: 1.25em;
30 | --tweet-actions-icon-wrapper-size: calc(
31 | var(--tweet-actions-icon-size) + 0.75em
32 | );
33 |
34 | /* Reply button */
35 | --tweet-replies-font-size: 0.875rem;
36 | --tweet-replies-line-height: 1rem;
37 | --tweet-replies-font-weight: 700;
38 | }
39 |
40 | :where(.react-tweet-theme) * {
41 | margin: 0;
42 | padding: 0;
43 | box-sizing: border-box;
44 | }
45 |
46 | :is([data-theme='light'], .light) :where(.react-tweet-theme),
47 | :where(.react-tweet-theme) {
48 | --tweet-skeleton-gradient: linear-gradient(
49 | 270deg,
50 | #fafafa,
51 | #eaeaea,
52 | #eaeaea,
53 | #fafafa
54 | );
55 | --tweet-border: 1px solid rgb(207, 217, 222);
56 | --tweet-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
57 | Helvetica, Arial, sans-serif;
58 | --tweet-font-color: rgb(15, 20, 25);
59 | --tweet-font-color-secondary: rgb(83, 100, 113);
60 | --tweet-bg-color: #fff;
61 | --tweet-bg-color-hover: rgb(247, 249, 249);
62 | --tweet-quoted-bg-color-hover: rgba(0, 0, 0, 0.03);
63 | --tweet-color-blue-primary: rgb(29, 155, 240);
64 | --tweet-color-blue-primary-hover: rgb(26, 140, 216);
65 | --tweet-color-blue-secondary: rgb(0, 111, 214);
66 | --tweet-color-blue-secondary-hover: rgba(0, 111, 214, 0.1);
67 | --tweet-color-red-primary: rgb(249, 24, 128);
68 | --tweet-color-red-primary-hover: rgba(249, 24, 128, 0.1);
69 | --tweet-color-green-primary: rgb(0, 186, 124);
70 | --tweet-color-green-primary-hover: rgba(0, 186, 124, 0.1);
71 | --tweet-twitter-icon-color: var(--tweet-font-color);
72 | --tweet-verified-old-color: rgb(130, 154, 171);
73 | --tweet-verified-blue-color: var(--tweet-color-blue-primary);
74 | }
75 |
76 | :is([data-theme='dark'], .dark) :where(.react-tweet-theme) {
77 | --tweet-skeleton-gradient: linear-gradient(
78 | 270deg,
79 | #15202b,
80 | rgb(30, 39, 50),
81 | rgb(30, 39, 50),
82 | rgb(21, 32, 43)
83 | );
84 | --tweet-border: 1px solid rgb(66, 83, 100);
85 | --tweet-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
86 | Helvetica, Arial, sans-serif;
87 | --tweet-font-color: rgb(247, 249, 249);
88 | --tweet-font-color-secondary: rgb(139, 152, 165);
89 | --tweet-bg-color: rgb(21, 32, 43);
90 | --tweet-bg-color-hover: rgb(30, 39, 50);
91 | --tweet-quoted-bg-color-hover: rgba(255, 255, 255, 0.03);
92 | --tweet-color-blue-primary: rgb(29, 155, 240);
93 | --tweet-color-blue-primary-hover: rgb(26, 140, 216);
94 | --tweet-color-blue-secondary: rgb(107, 201, 251);
95 | --tweet-color-blue-secondary-hover: rgba(107, 201, 251, 0.1);
96 | --tweet-color-red-primary: rgb(249, 24, 128);
97 | --tweet-color-red-primary-hover: rgba(249, 24, 128, 0.1);
98 | --tweet-color-green-primary: rgb(0, 186, 124);
99 | --tweet-color-green-primary-hover: rgba(0, 186, 124, 0.1);
100 | --tweet-twitter-icon-color: var(--tweet-font-color);
101 | --tweet-verified-old-color: rgb(130, 154, 171);
102 | --tweet-verified-blue-color: #fff;
103 | }
104 |
105 | @media (prefers-color-scheme: dark) {
106 | :where(.react-tweet-theme) {
107 | --tweet-skeleton-gradient: linear-gradient(
108 | 270deg,
109 | #15202b,
110 | rgb(30, 39, 50),
111 | rgb(30, 39, 50),
112 | rgb(21, 32, 43)
113 | );
114 | --tweet-border: 1px solid rgb(66, 83, 100);
115 | --tweet-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
116 | Helvetica, Arial, sans-serif;
117 | --tweet-font-color: rgb(247, 249, 249);
118 | --tweet-font-color-secondary: rgb(139, 152, 165);
119 | --tweet-bg-color: rgb(21, 32, 43);
120 | --tweet-bg-color-hover: rgb(30, 39, 50);
121 | --tweet-color-blue-primary: rgb(29, 155, 240);
122 | --tweet-color-blue-primary-hover: rgb(26, 140, 216);
123 | --tweet-color-blue-secondary: rgb(107, 201, 251);
124 | --tweet-color-blue-secondary-hover: rgba(107, 201, 251, 0.1);
125 | --tweet-color-red-primary: rgb(249, 24, 128);
126 | --tweet-color-red-primary-hover: rgba(249, 24, 128, 0.1);
127 | --tweet-color-green-primary: rgb(0, 186, 124);
128 | --tweet-color-green-primary-hover: rgba(0, 186, 124, 0.1);
129 | --tweet-twitter-icon-color: var(--tweet-font-color);
130 | --tweet-verified-old-color: rgb(130, 154, 171);
131 | --tweet-verified-blue-color: #fff;
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-actions-copy.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect } from 'react'
4 | import type { EnrichedTweet } from '../utils.js'
5 | import s from './tweet-actions.module.css'
6 |
7 | export const TweetActionsCopy = ({ tweet }: { tweet: EnrichedTweet }) => {
8 | const [copied, setCopied] = useState(false)
9 | const handleCopy = () => {
10 | navigator.clipboard.writeText(tweet.url)
11 | setCopied(true)
12 | }
13 |
14 | useEffect(() => {
15 | if (copied) {
16 | const timeout = setTimeout(() => {
17 | setCopied(false)
18 | }, 6000)
19 |
20 | return () => clearTimeout(timeout)
21 | }
22 | }, [copied])
23 |
24 | return (
25 |
31 |
32 | {copied ? (
33 |
34 |
35 |
36 |
37 |
38 | ) : (
39 |
40 |
41 |
42 |
43 |
44 | )}
45 |
46 | {copied ? 'Copied!' : 'Copy link'}
47 |
48 | )
49 | }
50 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-actions.module.css:
--------------------------------------------------------------------------------
1 | .actions {
2 | display: flex;
3 | align-items: center;
4 | color: var(--tweet-font-color-secondary);
5 | padding-top: 0.25rem;
6 | margin-top: 0.25rem;
7 | border-top: var(--tweet-border);
8 | overflow-wrap: break-word;
9 | white-space: nowrap;
10 | text-overflow: ellipsis;
11 | }
12 |
13 | .like,
14 | .reply,
15 | .copy {
16 | text-decoration: none;
17 | color: inherit;
18 | display: flex;
19 | align-items: center;
20 | margin-right: 1.25rem;
21 | }
22 | .like:hover,
23 | .reply:hover,
24 | .copy:hover {
25 | background-color: rgba(0, 0, 0, 0);
26 | }
27 | .like:hover > .likeIconWrapper {
28 | background-color: var(--tweet-color-red-primary-hover);
29 | }
30 | .like:hover > .likeCount {
31 | color: var(--tweet-color-red-primary);
32 | text-decoration-line: underline;
33 | }
34 | .likeIconWrapper,
35 | .replyIconWrapper,
36 | .copyIconWrapper {
37 | width: var(--tweet-actions-icon-wrapper-size);
38 | height: var(--tweet-actions-icon-wrapper-size);
39 | display: flex;
40 | justify-content: center;
41 | align-items: center;
42 | margin-left: -0.25rem;
43 | border-radius: 9999px;
44 | }
45 | .likeIcon,
46 | .replyIcon,
47 | .copyIcon {
48 | height: var(--tweet-actions-icon-size);
49 | fill: currentColor;
50 | user-select: none;
51 | }
52 | .likeIcon {
53 | color: var(--tweet-color-red-primary);
54 | }
55 | .likeCount,
56 | .replyText,
57 | .copyText {
58 | font-size: var(--tweet-actions-font-size);
59 | font-weight: var(--tweet-actions-font-weight);
60 | line-height: var(--tweet-actions-line-height);
61 | margin-left: 0.25rem;
62 | }
63 |
64 | .reply:hover > .replyIconWrapper {
65 | background-color: var(--tweet-color-blue-secondary-hover);
66 | }
67 | .reply:hover > .replyText {
68 | color: var(--tweet-color-blue-secondary);
69 | text-decoration-line: underline;
70 | }
71 | .replyIcon {
72 | color: var(--tweet-color-blue-primary);
73 | }
74 |
75 | .copy {
76 | font: inherit;
77 | background: none;
78 | border: none;
79 | cursor: pointer;
80 | }
81 | .copy:hover > .copyIconWrapper {
82 | background-color: var(--tweet-color-green-primary-hover);
83 | }
84 | .copy:hover .copyIcon {
85 | color: var(--tweet-color-green-primary);
86 | }
87 | .copy:hover > .copyText {
88 | color: var(--tweet-color-green-primary);
89 | text-decoration-line: underline;
90 | }
91 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-actions.tsx:
--------------------------------------------------------------------------------
1 | import { type EnrichedTweet, formatNumber } from '../utils.js'
2 | import { TweetActionsCopy } from './tweet-actions-copy.js'
3 | import s from './tweet-actions.module.css'
4 |
5 | export const TweetActions = ({ tweet }: { tweet: EnrichedTweet }) => {
6 | const favoriteCount = formatNumber(tweet.favorite_count)
7 |
8 | return (
9 |
44 | )
45 | }
46 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-body.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-size: var(--tweet-body-font-size);
3 | font-weight: var(--tweet-body-font-weight);
4 | line-height: var(--tweet-body-line-height);
5 | margin: var(--tweet-body-margin);
6 | overflow-wrap: break-word;
7 | white-space: pre-wrap;
8 | }
9 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-body.tsx:
--------------------------------------------------------------------------------
1 | import type { EnrichedTweet } from '../utils.js'
2 | import { TweetLink } from './tweet-link.js'
3 | import s from './tweet-body.module.css'
4 |
5 | export const TweetBody = ({ tweet }: { tweet: EnrichedTweet }) => (
6 |
7 | {tweet.entities.map((item, i) => {
8 | switch (item.type) {
9 | case 'hashtag':
10 | case 'mention':
11 | case 'url':
12 | case 'symbol':
13 | return (
14 |
15 | {item.text}
16 |
17 | )
18 | case 'media':
19 | // Media text is currently never displayed, some tweets however might have indices
20 | // that do match `display_text_range` so for those cases we ignore the content.
21 | return
22 | default:
23 | // We use `dangerouslySetInnerHTML` to preserve the text encoding.
24 | // https://github.com/vercel-labs/react-tweet/issues/29
25 | return (
26 |
27 | )
28 | }
29 | })}
30 |
31 | )
32 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-container.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | width: 100%;
3 | min-width: 250px;
4 | max-width: 550px;
5 | overflow: hidden;
6 | /* Base font styles */
7 | color: var(--tweet-font-color);
8 | font-family: var(--tweet-font-family);
9 | font-weight: 400;
10 | box-sizing: border-box;
11 | border: var(--tweet-border);
12 | border-radius: 12px;
13 | margin: var(--tweet-container-margin);
14 | background-color: var(--tweet-bg-color);
15 | transition-property: background-color, box-shadow;
16 | transition-duration: 0.2s;
17 | }
18 | .root:hover {
19 | background-color: var(--tweet-bg-color-hover);
20 | }
21 | .article {
22 | position: relative;
23 | box-sizing: inherit;
24 | padding: 0.75rem 1rem;
25 | }
26 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-container.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 | import clsx from 'clsx'
3 | import s from './tweet-container.module.css'
4 | import './theme.css'
5 |
6 | type Props = { className?: string; children: ReactNode }
7 |
8 | export const TweetContainer = ({ className, children }: Props) => (
9 |
12 | )
13 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | display: flex;
3 | padding-bottom: 0.75rem;
4 | line-height: var(--tweet-header-line-height);
5 | font-size: var(--tweet-header-font-size);
6 | white-space: nowrap;
7 | overflow-wrap: break-word;
8 | overflow: hidden;
9 | }
10 |
11 | .avatar {
12 | position: relative;
13 | height: 48px;
14 | width: 48px;
15 | }
16 | .avatarOverflow {
17 | height: 100%;
18 | width: 100%;
19 | position: absolute;
20 | overflow: hidden;
21 | border-radius: 9999px;
22 | }
23 | .avatarSquare {
24 | border-radius: 4px;
25 | }
26 | .avatarShadow {
27 | height: 100%;
28 | width: 100%;
29 | transition-property: background-color;
30 | transition-duration: 0.2s;
31 | box-shadow: rgb(0 0 0 / 3%) 0px 0px 2px inset;
32 | }
33 | .avatarShadow:hover {
34 | background-color: rgba(26, 26, 26, 0.15);
35 | }
36 |
37 | .author {
38 | max-width: calc(100% - 84px);
39 | display: flex;
40 | flex-direction: column;
41 | justify-content: center;
42 | margin: 0 0.5rem;
43 | }
44 | .authorLink {
45 | text-decoration: none;
46 | color: inherit;
47 | display: flex;
48 | align-items: center;
49 | }
50 | .authorLink:hover {
51 | text-decoration-line: underline;
52 | }
53 | .authorVerified {
54 | display: inline-flex;
55 | }
56 | .authorLinkText {
57 | font-weight: 700;
58 | text-overflow: ellipsis;
59 | overflow: hidden;
60 | white-space: nowrap;
61 | }
62 |
63 | .authorMeta {
64 | display: flex;
65 | }
66 | .authorFollow {
67 | display: flex;
68 | }
69 | .username {
70 | color: var(--tweet-font-color-secondary);
71 | text-decoration: none;
72 | text-overflow: ellipsis;
73 | }
74 | .follow {
75 | color: var(--tweet-color-blue-secondary);
76 | text-decoration: none;
77 | font-weight: 700;
78 | }
79 | .follow:hover {
80 | text-decoration-line: underline;
81 | }
82 | .separator {
83 | padding: 0 0.25rem;
84 | }
85 |
86 | .brand {
87 | margin-inline-start: auto;
88 | }
89 |
90 | .twitterIcon {
91 | width: 23.75px;
92 | height: 23.75px;
93 | color: var(--tweet-twitter-icon-color);
94 | fill: currentColor;
95 | user-select: none;
96 | }
97 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-header.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import type { EnrichedTweet } from '../utils.js'
3 | import type { TwitterComponents } from './types.js'
4 | import { AvatarImg } from './avatar-img.js'
5 | import s from './tweet-header.module.css'
6 | import { VerifiedBadge } from './verified-badge.js'
7 |
8 | type Props = {
9 | tweet: EnrichedTweet
10 | components?: TwitterComponents
11 | }
12 |
13 | export const TweetHeader = ({ tweet, components }: Props) => {
14 | const Img = components?.AvatarImg ?? AvatarImg
15 | const { user } = tweet
16 |
17 | return (
18 |
90 | )
91 | }
92 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-in-reply-to.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | text-decoration: none;
3 | color: var(--tweet-font-color-secondary);
4 | font-size: 0.9375rem;
5 | line-height: 1.25rem;
6 | margin-bottom: 0.25rem;
7 | overflow-wrap: break-word;
8 | white-space: pre-wrap;
9 | }
10 | .root:hover {
11 | text-decoration-thickness: 1px;
12 | text-decoration-line: underline;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-in-reply-to.tsx:
--------------------------------------------------------------------------------
1 | import type { EnrichedTweet } from '../utils.js'
2 | import s from './tweet-in-reply-to.module.css'
3 |
4 | export const TweetInReplyTo = ({ tweet }: { tweet: EnrichedTweet }) => (
5 |
11 | Replying to @{tweet.in_reply_to_screen_name}
12 |
13 | )
14 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-info-created-at.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | color: inherit;
3 | text-decoration: none;
4 | font-size: var(--tweet-info-font-size);
5 | line-height: var(--tweet-info-line-height);
6 | }
7 | .root:hover {
8 | text-decoration-thickness: 1px;
9 | text-decoration-line: underline;
10 | }
11 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-info-created-at.tsx:
--------------------------------------------------------------------------------
1 | import type { EnrichedTweet } from '../utils.js'
2 | import { formatDate } from '../date-utils.js'
3 | import s from './tweet-info-created-at.module.css'
4 |
5 | export const TweetInfoCreatedAt = ({ tweet }: { tweet: EnrichedTweet }) => {
6 | const createdAt = new Date(tweet.created_at)
7 | const formattedCreatedAtDate = formatDate(createdAt)
8 |
9 | return (
10 |
17 | {formattedCreatedAtDate}
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-info.module.css:
--------------------------------------------------------------------------------
1 | .info {
2 | display: flex;
3 | align-items: center;
4 | color: var(--tweet-font-color-secondary);
5 | margin-top: 0.125rem;
6 | overflow-wrap: break-word;
7 | white-space: nowrap;
8 | text-overflow: ellipsis;
9 | }
10 | .infoLink {
11 | color: inherit;
12 | text-decoration: none;
13 | }
14 | .infoLink {
15 | height: var(--tweet-actions-icon-wrapper-size);
16 | width: var(--tweet-actions-icon-wrapper-size);
17 | font: inherit;
18 | margin-left: auto;
19 | display: flex;
20 | justify-content: center;
21 | align-items: center;
22 | margin-right: -4px;
23 | border-radius: 9999px;
24 | transition-property: background-color;
25 | transition-duration: 0.2s;
26 | }
27 | .infoLink:hover {
28 | background-color: var(--tweet-color-blue-secondary-hover);
29 | }
30 | .infoIcon {
31 | color: inherit;
32 | fill: currentColor;
33 | height: var(--tweet-actions-icon-size);
34 | user-select: none;
35 | }
36 | .infoLink:hover > .infoIcon {
37 | color: var(--tweet-color-blue-secondary);
38 | }
39 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-info.tsx:
--------------------------------------------------------------------------------
1 | import type { EnrichedTweet } from '../utils.js'
2 | import { TweetInfoCreatedAt } from './tweet-info-created-at.js'
3 | import s from './tweet-info.module.css'
4 |
5 | export const TweetInfo = ({ tweet }: { tweet: EnrichedTweet }) => (
6 |
22 | )
23 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-link.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | font-weight: inherit;
3 | color: var(--tweet-color-blue-secondary);
4 | text-decoration: none;
5 | cursor: pointer;
6 | }
7 | .root:hover {
8 | text-decoration-thickness: 1px;
9 | text-decoration-line: underline;
10 | }
11 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-link.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react'
2 | import s from './tweet-link.module.css'
3 |
4 | type Props = {
5 | children: ReactNode
6 | href: string
7 | }
8 |
9 | export const TweetLink = ({ href, children }: Props) => (
10 |
11 | {children}
12 |
13 | )
14 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-media-video.module.css:
--------------------------------------------------------------------------------
1 | .anchor {
2 | display: flex;
3 | align-items: center;
4 | color: white;
5 | padding: 0 1rem;
6 | border: 1px solid transparent;
7 | border-radius: 9999px;
8 | font-weight: 700;
9 | transition: background-color 0.2s;
10 | cursor: pointer;
11 | user-select: none;
12 | outline-style: none;
13 | text-decoration: none;
14 | text-overflow: ellipsis;
15 | white-space: nowrap;
16 | }
17 | .videoButton {
18 | position: relative;
19 | height: 67px;
20 | width: 67px;
21 | display: flex;
22 | align-items: center;
23 | justify-content: center;
24 | background-color: var(--tweet-color-blue-primary);
25 | transition-property: background-color;
26 | transition-duration: 0.2s;
27 | border: 4px solid #fff;
28 | border-radius: 9999px;
29 | cursor: pointer;
30 | }
31 | .videoButton:hover,
32 | .videoButton:focus-visible {
33 | background-color: var(--tweet-color-blue-primary-hover);
34 | }
35 | .videoButtonIcon {
36 | margin-left: 3px;
37 | width: calc(50% + 4px);
38 | height: calc(50% + 4px);
39 | max-width: 100%;
40 | color: #fff;
41 | fill: currentColor;
42 | user-select: none;
43 | }
44 | .watchOnTwitter {
45 | position: absolute;
46 | top: 12px;
47 | right: 8px;
48 | }
49 | .watchOnTwitter > a {
50 | min-width: 2rem;
51 | min-height: 2rem;
52 | font-size: 0.875rem;
53 | line-height: 1rem;
54 | backdrop-filter: blur(4px);
55 | background-color: rgba(15, 20, 25, 0.75);
56 | }
57 | .watchOnTwitter > a:hover {
58 | background-color: rgba(39, 44, 48, 0.75);
59 | }
60 | .viewReplies {
61 | position: relative;
62 | min-height: 2rem;
63 | background-color: var(--tweet-color-blue-primary);
64 | border-color: var(--tweet-color-blue-primary);
65 | font-size: 0.9375rem;
66 | line-height: 1.25rem;
67 | }
68 | .viewReplies:hover {
69 | background-color: var(--tweet-color-blue-primary-hover);
70 | }
71 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-media-video.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import clsx from 'clsx'
5 | import type { MediaAnimatedGif, MediaVideo } from '../api/index.js'
6 | import {
7 | EnrichedQuotedTweet,
8 | type EnrichedTweet,
9 | getMediaUrl,
10 | getMp4Video,
11 | } from '../utils.js'
12 | import mediaStyles from './tweet-media.module.css'
13 | import s from './tweet-media-video.module.css'
14 |
15 | type Props = {
16 | tweet: EnrichedTweet | EnrichedQuotedTweet
17 | media: MediaAnimatedGif | MediaVideo
18 | }
19 |
20 | export const TweetMediaVideo = ({ tweet, media }: Props) => {
21 | const [playButton, setPlayButton] = useState(true)
22 | const [isPlaying, setIsPlaying] = useState(false)
23 | const [ended, setEnded] = useState(false)
24 | const mp4Video = getMp4Video(media)
25 | let timeout = 0
26 |
27 | return (
28 | <>
29 | {
37 | if (timeout) window.clearTimeout(timeout)
38 | if (!isPlaying) setIsPlaying(true)
39 | if (ended) setEnded(false)
40 | }}
41 | onPause={() => {
42 | // When the video is seeked (moved to a different timestamp), it will pause for a moment
43 | // before resuming. We don't want to show the message in that case so we wait a bit.
44 | if (timeout) window.clearTimeout(timeout)
45 | timeout = window.setTimeout(() => {
46 | if (isPlaying) setIsPlaying(false)
47 | timeout = 0
48 | }, 100)
49 | }}
50 | onEnded={() => {
51 | setEnded(true)
52 | }}
53 | >
54 |
55 |
56 |
57 | {playButton && (
58 | {
63 | const video = e.currentTarget.previousSibling as HTMLMediaElement
64 |
65 | e.preventDefault()
66 | setPlayButton(false)
67 | video.load()
68 | video
69 | .play()
70 | .then(() => {
71 | setIsPlaying(true)
72 | video.focus()
73 | })
74 | .catch((error) => {
75 | console.error('Error playing video:', error)
76 | setPlayButton(true)
77 | setIsPlaying(false)
78 | })
79 | }}
80 | >
81 |
86 |
87 |
88 |
89 |
90 |
91 | )}
92 |
93 | {!isPlaying && !ended && (
94 |
104 | )}
105 |
106 | {ended && (
107 |
113 | View replies
114 |
115 | )}
116 | >
117 | )
118 | }
119 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-media.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | margin-top: 0.75rem;
3 | overflow: hidden;
4 | position: relative;
5 | }
6 | .rounded {
7 | border: var(--tweet-border);
8 | border-radius: 12px;
9 | }
10 | .mediaWrapper {
11 | display: grid;
12 | grid-auto-rows: 1fr;
13 | gap: 2px;
14 | height: 100%;
15 | width: 100%;
16 | }
17 | .grid2Columns {
18 | grid-template-columns: repeat(2, 1fr);
19 | }
20 | .grid3 > a:first-child {
21 | grid-row: span 2;
22 | }
23 | .grid2x2 {
24 | grid-template-rows: repeat(2, 1fr);
25 | }
26 | .mediaContainer {
27 | position: relative;
28 | height: 100%;
29 | width: 100%;
30 | display: flex;
31 | align-items: center;
32 | justify-content: center;
33 | }
34 | .mediaLink {
35 | text-decoration: none;
36 | outline-style: none;
37 | }
38 | .skeleton {
39 | padding-bottom: 56.25%;
40 | width: 100%;
41 | display: block;
42 | }
43 | .image {
44 | position: absolute;
45 | top: 0px;
46 | left: 0px;
47 | bottom: 0px;
48 | height: 100%;
49 | width: 100%;
50 | margin: 0;
51 | object-fit: cover;
52 | object-position: center;
53 | }
54 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-media.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from 'react'
2 | import clsx from 'clsx'
3 | import {
4 | type EnrichedTweet,
5 | type EnrichedQuotedTweet,
6 | getMediaUrl,
7 | } from '../utils.js'
8 | import { MediaDetails } from '../api/index.js'
9 | import type { TwitterComponents } from './types.js'
10 | import { TweetMediaVideo } from './tweet-media-video.js'
11 | import { MediaImg } from './media-img.js'
12 | import s from './tweet-media.module.css'
13 |
14 | const getSkeletonStyle = (media: MediaDetails, itemCount: number) => {
15 | let paddingBottom = 56.25 // default of 16x9
16 |
17 | // if we only have 1 item, show at original ratio
18 | if (itemCount === 1)
19 | paddingBottom =
20 | (100 / media.original_info.width) * media.original_info.height
21 |
22 | // if we have 2 items, double the default to be 16x9 total
23 | if (itemCount === 2) paddingBottom = paddingBottom * 2
24 |
25 | return {
26 | width: media.type === 'photo' ? undefined : 'unset',
27 | paddingBottom: `${paddingBottom}%`,
28 | }
29 | }
30 |
31 | type Props = {
32 | tweet: EnrichedTweet | EnrichedQuotedTweet
33 | components?: TwitterComponents
34 | quoted?: boolean
35 | }
36 |
37 | export const TweetMedia = ({ tweet, components, quoted }: Props) => {
38 | const length = tweet.mediaDetails?.length ?? 0
39 | const Img = components?.MediaImg ?? MediaImg
40 |
41 | return (
42 |
43 |
1 && s.grid2Columns,
47 | length === 3 && s.grid3,
48 | length > 4 && s.grid2x2
49 | )}
50 | >
51 | {tweet.mediaDetails?.map((media) => (
52 |
53 | {media.type === 'photo' ? (
54 |
61 |
65 |
71 |
72 | ) : (
73 |
80 | )}
81 |
82 | ))}
83 |
84 |
85 | )
86 | }
87 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-not-found.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | padding-bottom: 0.75rem;
6 | }
7 | .root > h3 {
8 | font-size: 1.25rem;
9 | margin-bottom: 0.5rem;
10 | }
11 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-not-found.tsx:
--------------------------------------------------------------------------------
1 | import { TweetContainer } from './tweet-container.js'
2 | import styles from './tweet-not-found.module.css'
3 |
4 | type Props = {
5 | error?: any
6 | }
7 |
8 | export const TweetNotFound = (_props: Props) => (
9 |
10 |
11 |
Tweet not found
12 |
The embedded tweet could not be found…
13 |
14 |
15 | )
16 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-replies.module.css:
--------------------------------------------------------------------------------
1 | .replies {
2 | padding: 0.25rem 0;
3 | }
4 | .link {
5 | text-decoration: none;
6 | color: var(--tweet-color-blue-secondary);
7 | display: flex;
8 | align-items: center;
9 | justify-content: center;
10 | min-width: 32px;
11 | min-height: 32px;
12 | user-select: none;
13 | outline-style: none;
14 | transition-property: background-color;
15 | transition-duration: 0.2s;
16 | padding: 0 1rem;
17 | border: var(--tweet-border);
18 | border-radius: 9999px;
19 | }
20 | .link:hover {
21 | background-color: var(--tweet-color-blue-secondary-hover);
22 | }
23 | .text {
24 | font-weight: var(--tweet-replies-font-weight);
25 | font-size: var(--tweet-replies-font-size);
26 | line-height: var(--tweet-replies-line-height);
27 | overflow-wrap: break-word;
28 | white-space: nowrap;
29 | text-overflow: ellipsis;
30 | overflow: hidden;
31 | }
32 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-replies.tsx:
--------------------------------------------------------------------------------
1 | import { type EnrichedTweet, formatNumber } from '../utils.js'
2 | import s from './tweet-replies.module.css'
3 |
4 | export const TweetReplies = ({ tweet }: { tweet: EnrichedTweet }) => (
5 |
21 | )
22 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-skeleton.module.css:
--------------------------------------------------------------------------------
1 | .root {
2 | pointer-events: none;
3 | padding-bottom: 0.25rem;
4 | }
5 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/tweet-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { TweetContainer } from './tweet-container.js'
2 | import { Skeleton } from './skeleton.js'
3 | import styles from './tweet-skeleton.module.css'
4 |
5 | export const TweetSkeleton = () => (
6 |
7 |
8 |
9 |
10 |
15 |
22 |
23 | )
24 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/types.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Custom components that the default Twitter theme allows.
3 | *
4 | * Note: We only use these components in Server Components, because the root `Tweet`
5 | * component that uses them is a Server Component and you can't pass down functions to a
6 | * client component unless they're Server Actions.
7 | */
8 | export type TwitterComponents = {
9 | TweetNotFound?: typeof import('./tweet-not-found.js').TweetNotFound
10 | AvatarImg?: typeof import('./avatar-img.js').AvatarImg
11 | MediaImg?: typeof import('./media-img.js').MediaImg
12 | }
13 |
14 | /**
15 | * @deprecated Use `TwitterComponents` instead.
16 | */
17 | export type TweetComponents = TwitterComponents
18 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/verified-badge.module.css:
--------------------------------------------------------------------------------
1 | .verifiedOld {
2 | color: var(--tweet-verified-old-color);
3 | }
4 | .verifiedBlue {
5 | color: var(--tweet-verified-blue-color);
6 | }
7 | .verifiedGovernment {
8 | /* color: var(--tweet-verified-government-color); */
9 | color: rgb(130, 154, 171);
10 | }
11 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/twitter-theme/verified-badge.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx'
2 | import type { TweetUser } from '../api/index.js'
3 | import {
4 | Verified,
5 | VerifiedBusiness,
6 | VerifiedGovernment,
7 | } from './icons/index.js'
8 | import s from './verified-badge.module.css'
9 |
10 | type Props = {
11 | user: TweetUser
12 | className?: string
13 | }
14 |
15 | export const VerifiedBadge = ({ user, className }: Props) => {
16 | const verified = user.verified || user.is_blue_verified || user.verified_type
17 | let icon =
18 | let iconClassName: string | null = s.verifiedBlue
19 |
20 | if (verified) {
21 | if (!user.is_blue_verified) {
22 | iconClassName = s.verifiedOld
23 | }
24 | switch (user.verified_type) {
25 | case 'Government':
26 | icon =
27 | iconClassName = s.verifiedGovernment
28 | break
29 | case 'Business':
30 | icon =
31 | iconClassName = null
32 | break
33 | }
34 | }
35 |
36 | return verified ? (
37 | {icon}
38 | ) : null
39 | }
40 |
--------------------------------------------------------------------------------
/packages/react-tweet/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | TweetBase,
3 | Tweet,
4 | QuotedTweet,
5 | MediaDetails,
6 | HashtagEntity,
7 | SymbolEntity,
8 | Indices,
9 | UserMentionEntity,
10 | UrlEntity,
11 | MediaEntity,
12 | MediaAnimatedGif,
13 | MediaVideo,
14 | } from './api/index.js'
15 |
16 | export type TweetCoreProps = {
17 | id: string
18 | onError?(error: any): any
19 | }
20 |
21 | const getTweetUrl = (tweet: TweetBase) =>
22 | `https://x.com/${tweet.user.screen_name}/status/${tweet.id_str}`
23 |
24 | const getUserUrl = (usernameOrTweet: string | TweetBase) =>
25 | `https://x.com/${
26 | typeof usernameOrTweet === 'string'
27 | ? usernameOrTweet
28 | : usernameOrTweet.user.screen_name
29 | }`
30 |
31 | const getLikeUrl = (tweet: TweetBase) =>
32 | `https://x.com/intent/like?tweet_id=${tweet.id_str}`
33 |
34 | const getReplyUrl = (tweet: TweetBase) =>
35 | `https://x.com/intent/tweet?in_reply_to=${tweet.id_str}`
36 |
37 | const getFollowUrl = (tweet: TweetBase) =>
38 | `https://x.com/intent/follow?screen_name=${tweet.user.screen_name}`
39 |
40 | const getHashtagUrl = (hashtag: HashtagEntity) =>
41 | `https://x.com/hashtag/${hashtag.text}`
42 |
43 | const getSymbolUrl = (symbol: SymbolEntity) =>
44 | `https://x.com/search?q=%24${symbol.text}`
45 |
46 | const getInReplyToUrl = (tweet: Tweet) =>
47 | `https://x.com/${tweet.in_reply_to_screen_name}/status/${tweet.in_reply_to_status_id_str}`
48 |
49 | export const getMediaUrl = (
50 | media: MediaDetails,
51 | size: 'small' | 'medium' | 'large'
52 | ): string => {
53 | const url = new URL(media.media_url_https)
54 | const extension = url.pathname.split('.').pop()
55 |
56 | if (!extension) return media.media_url_https
57 |
58 | url.pathname = url.pathname.replace(`.${extension}`, '')
59 | url.searchParams.set('format', extension)
60 | url.searchParams.set('name', size)
61 |
62 | return url.toString()
63 | }
64 |
65 | export const getMp4Videos = (media: MediaAnimatedGif | MediaVideo) => {
66 | const { variants } = media.video_info
67 | const sortedMp4Videos = variants
68 | .filter((vid) => vid.content_type === 'video/mp4')
69 | .sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))
70 |
71 | return sortedMp4Videos
72 | }
73 |
74 | export const getMp4Video = (media: MediaAnimatedGif | MediaVideo) => {
75 | const mp4Videos = getMp4Videos(media)
76 | // Skip the highest quality video and use the next quality
77 | return mp4Videos.length > 1 ? mp4Videos[1] : mp4Videos[0]
78 | }
79 |
80 | export const formatNumber = (n: number): string => {
81 | if (n > 999999) return `${(n / 1000000).toFixed(1)}M`
82 | if (n > 999) return `${(n / 1000).toFixed(1)}K`
83 | return n.toString()
84 | }
85 |
86 | type TextEntity = {
87 | indices: Indices
88 | type: 'text'
89 | }
90 |
91 | type TweetEntity =
92 | | HashtagEntity
93 | | UserMentionEntity
94 | | UrlEntity
95 | | MediaEntity
96 | | SymbolEntity
97 |
98 | type EntityWithType =
99 | | TextEntity
100 | | (HashtagEntity & { type: 'hashtag' })
101 | | (UserMentionEntity & { type: 'mention' })
102 | | (UrlEntity & { type: 'url' })
103 | | (MediaEntity & { type: 'media' })
104 | | (SymbolEntity & { type: 'symbol' })
105 |
106 | type Entity = {
107 | text: string
108 | } & (
109 | | TextEntity
110 | | (HashtagEntity & { type: 'hashtag'; href: string })
111 | | (UserMentionEntity & { type: 'mention'; href: string })
112 | | (UrlEntity & { type: 'url'; href: string })
113 | | (MediaEntity & { type: 'media'; href: string })
114 | | (SymbolEntity & { type: 'symbol'; href: string })
115 | )
116 |
117 | function getEntities(tweet: TweetBase): Entity[] {
118 | const textMap = Array.from(tweet.text)
119 | const result: EntityWithType[] = [
120 | { indices: tweet.display_text_range, type: 'text' },
121 | ]
122 |
123 | addEntities(result, 'hashtag', tweet.entities.hashtags)
124 | addEntities(result, 'mention', tweet.entities.user_mentions)
125 | addEntities(result, 'url', tweet.entities.urls)
126 | addEntities(result, 'symbol', tweet.entities.symbols)
127 | if (tweet.entities.media) {
128 | addEntities(result, 'media', tweet.entities.media)
129 | }
130 | fixRange(tweet, result)
131 |
132 | return result.map((entity) => {
133 | const text = textMap.slice(entity.indices[0], entity.indices[1]).join('')
134 | switch (entity.type) {
135 | case 'hashtag':
136 | return Object.assign(entity, { href: getHashtagUrl(entity), text })
137 | case 'mention':
138 | return Object.assign(entity, {
139 | href: getUserUrl(entity.screen_name),
140 | text,
141 | })
142 | case 'url':
143 | case 'media':
144 | return Object.assign(entity, {
145 | href: entity.expanded_url,
146 | text: entity.display_url,
147 | })
148 | case 'symbol':
149 | return Object.assign(entity, { href: getSymbolUrl(entity), text })
150 | default:
151 | return Object.assign(entity, { text })
152 | }
153 | })
154 | }
155 |
156 | function addEntities(
157 | result: EntityWithType[],
158 | type: EntityWithType['type'],
159 | entities: TweetEntity[]
160 | ) {
161 | for (const entity of entities) {
162 | for (const [i, item] of result.entries()) {
163 | if (
164 | item.indices[0] > entity.indices[0] ||
165 | item.indices[1] < entity.indices[1]
166 | ) {
167 | continue
168 | }
169 |
170 | const items = [{ ...entity, type }] as EntityWithType[]
171 |
172 | if (item.indices[0] < entity.indices[0]) {
173 | items.unshift({
174 | indices: [item.indices[0], entity.indices[0]],
175 | type: 'text',
176 | })
177 | }
178 | if (item.indices[1] > entity.indices[1]) {
179 | items.push({
180 | indices: [entity.indices[1], item.indices[1]],
181 | type: 'text',
182 | })
183 | }
184 |
185 | result.splice(i, 1, ...items)
186 | break // Break out of the loop to avoid iterating over the new items
187 | }
188 | }
189 | }
190 |
191 | /**
192 | * Update display_text_range to work w/ Array.from
193 | * Array.from is unicode aware, unlike string.slice()
194 | */
195 | function fixRange(tweet: TweetBase, entities: EntityWithType[]) {
196 | if (
197 | tweet.entities.media &&
198 | tweet.entities.media[0].indices[0] < tweet.display_text_range[1]
199 | ) {
200 | tweet.display_text_range[1] = tweet.entities.media[0].indices[0]
201 | }
202 | const lastEntity = entities.at(-1)
203 | if (lastEntity && lastEntity.indices[1] > tweet.display_text_range[1]) {
204 | lastEntity.indices[1] = tweet.display_text_range[1]
205 | }
206 | }
207 |
208 | export type EnrichedTweet = Omit & {
209 | url: string
210 | user: {
211 | url: string
212 | follow_url: string
213 | }
214 | like_url: string
215 | reply_url: string
216 | in_reply_to_url?: string
217 | entities: Entity[]
218 | quoted_tweet?: EnrichedQuotedTweet
219 | }
220 |
221 | export type EnrichedQuotedTweet = Omit & {
222 | url: string
223 | entities: Entity[]
224 | }
225 |
226 | /**
227 | * Enriches a tweet with additional data used to more easily use the tweet in a UI.
228 | */
229 | export const enrichTweet = (tweet: Tweet): EnrichedTweet => ({
230 | ...tweet,
231 | url: getTweetUrl(tweet),
232 | user: {
233 | ...tweet.user,
234 | url: getUserUrl(tweet),
235 | follow_url: getFollowUrl(tweet),
236 | },
237 | like_url: getLikeUrl(tweet),
238 | reply_url: getReplyUrl(tweet),
239 | in_reply_to_url: tweet.in_reply_to_screen_name
240 | ? getInReplyToUrl(tweet)
241 | : undefined,
242 | entities: getEntities(tweet),
243 | quoted_tweet: tweet.quoted_tweet
244 | ? {
245 | ...tweet.quoted_tweet,
246 | url: getTweetUrl(tweet.quoted_tweet),
247 | entities: getEntities(tweet.quoted_tweet),
248 | }
249 | : undefined,
250 | })
251 |
--------------------------------------------------------------------------------
/packages/react-tweet/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 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | ./packages/react-tweet/readme.md
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.org/schema.json",
3 | "pipeline": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "outputs": [".next/**", "./dist/**", "./build/**"]
7 | },
8 | "lint": {
9 | "outputs": []
10 | },
11 | "dev": {
12 | "cache": false
13 | },
14 | "start": {
15 | "cache": false
16 | },
17 | "clean": {
18 | "cache": false
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------