├── .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 | 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 | {props.alt} { 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 |
23 | Replying to{' '} 24 | 29 | @{tweet.in_reply_to_screen_name} 30 | 31 |
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 |
54 | 60 | 61 |

{nFormatter(tweet.favorite_count)}

62 |
63 | 69 | 70 |

{nFormatter(tweet.conversation_count)}

71 |
72 |
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 |
8 |
9 | 10 | 17 | 18 | 61 |
62 | 63 | Link to tweet 64 | 65 | 66 |
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 | 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 |
7 |
8 |
{children}
9 |
10 |

🤯 This tweet was statically generated.

11 |
12 |
13 |
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 |
13 |

🤯 This tweet was statically generated.

14 |
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 | 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 |
26 | 32 |
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 | 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 |
10 |
{children}
11 |
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 | 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 | 56 | 57 | {playButton && ( 58 | 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 | {media.ext_alt_text 71 | 72 | ) : ( 73 |
74 |
78 | 79 |
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 | --------------------------------------------------------------------------------