├── .github └── workflows │ └── release.yml ├── .gitignore ├── README.md ├── components.json ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── favicon512.png ├── icon.png └── manifest.json ├── src ├── App.tsx ├── background.tsx ├── components │ ├── DateRangePicker.tsx │ ├── FileFoundCard.tsx │ ├── Post.tsx │ ├── ThemeToggle.tsx │ ├── UploadedPost.tsx │ ├── pages │ │ ├── Home │ │ │ ├── home.tsx │ │ │ ├── render1 │ │ │ │ ├── fileUpload.tsx │ │ │ │ ├── render1.tsx │ │ │ │ └── sync.tsx │ │ │ ├── render2 │ │ │ │ └── render2.tsx │ │ │ ├── render3.tsx │ │ │ └── steps.tsx │ │ └── login │ │ │ ├── Login.tsx │ │ │ └── loginForm.tsx │ ├── ui │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ └── switch.tsx │ └── utils.tsx ├── hooks │ ├── LogInContext.tsx │ ├── useAnalysis.ts │ ├── useFileUpload.ts │ └── useUpload.ts ├── index.css ├── lib │ ├── auth │ │ ├── login.ts │ │ └── validateUser.ts │ ├── constant.ts │ ├── parse │ │ ├── __test__ │ │ │ ├── minify.test.ts │ │ │ └── parse.test.ts │ │ ├── analyze.tsx │ │ ├── minify.ts │ │ ├── parse.tsx │ │ └── processTweets.ts │ ├── rateLimit │ │ └── RateLimitedAgent.ts │ └── utils.ts ├── main.tsx ├── popup.css └── types │ ├── login.type.ts │ ├── render.d.ts │ └── tweets.d.ts ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.js /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Chrome Extension 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build-and-release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Extract Extension Version 26 | id: package-version 27 | run: | 28 | VERSION=$(grep '"version":' public/manifest.json | cut -d'"' -f4) 29 | echo "version=$VERSION" >> $GITHUB_OUTPUT 30 | 31 | - name: Build extension 32 | run: npm run build 33 | 34 | - name: Create ZIP package 35 | run: | 36 | cd dist 37 | zip -r porto-extension-v${{ steps.package-version.outputs.version }}.zip . 38 | 39 | - name: Create GitHub Release 40 | uses: softprops/action-gh-release@v2 41 | with: 42 | files: dist/porto-extension-v${{ steps.package-version.outputs.version }}.zip 43 | tag_name: v${{ steps.package-version.outputs.version }} 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | dist.zip 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Porto](https://chromewebstore.google.com/detail/porto-import-your-tweets/ckilhjdflnaakopknngigiggfpnjaaop) is an Open Source [donation](https://ko-fi.com/nesterdev) based tool that lets you import your Tweets (X posts) into Bluesky in few clicks through a Chrome Extension. It's build by [Ankit Bhandari](https://bsky.app/profile/anku.bsky.social), [Yogesh Aryal](https://bsky.app/profile/aryog.bsky.social) & [Adarsh Kunwar](https://bsky.app/profile/helloalex.bsky.social). 2 | 3 | --- 4 | 5 | How to Use Porto? 🤔

6 | Step 1: Download your archive data from Twitter/X.
7 | Step 2: Extract the zip file into one standalone folder of archived data.
8 | Step 3: Upload the extracted root folder into the extension.
9 | Step 4: Choose the date range & customize what you want to import.
10 | Step 5: Proceed, minimize the window, and let the magic happen!
11 | 12 | --- 13 | 14 | Porto has been featured on [Lifehacker.com](https://lifehacker.com/tech/use-porto-to-upload-all-your-old-tweets-to-bluesky), [Popular Science](https://www.popsci.com/diy/how-to-leave-twitter-for-bluesky/), [Mashable](https://mashable.com/article/bluesky-importing-tweets-x-posts) & more. Lifehacker has best walk through guide on how to use Porto, so we recommend reading their article. 15 | 16 | --- 17 | Feel free to create any PR & issues that needs to be fixed to this tool. Also, thank you to [Divyaswor Makai](https://bsky.app/profile/divyaswor.bsky.social) for valuable contribution to Porto! 18 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import reactHooks from "eslint-plugin-react-hooks"; 4 | import reactRefresh from "eslint-plugin-react-refresh"; 5 | import tseslint from "typescript-eslint"; 6 | 7 | export default tseslint.config( 8 | { ignores: ["dist"] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ["**/*.{ts,tsx}"], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | "react-hooks": reactHooks, 18 | "react-refresh": reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | "react-refresh/only-export-components": [ 23 | "warn", 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ); 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Porto - Import Tweets to Bluesky 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "porto", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "watch": "vite build --watch", 10 | "preview": "vite preview", 11 | "format": "prettier --write .", 12 | "lint": "eslint . --fix", 13 | "test": "vitest" 14 | }, 15 | "devDependencies": { 16 | "@eslint/js": "^9.15.0", 17 | "@types/chrome": "^0.0.278", 18 | "@types/follow-redirects": "^1.14.4", 19 | "@types/he": "^1.2.3", 20 | "@types/node": "^22.7.7", 21 | "@types/react": "^18.3.11", 22 | "@types/react-dom": "^18.3.1", 23 | "@types/urijs": "^1.19.25", 24 | "@vitejs/plugin-react": "^4.3.2", 25 | "autoprefixer": "^10.4.20", 26 | "eslint": "^9.15.0", 27 | "eslint-plugin-react": "^7.37.2", 28 | "eslint-plugin-react-hooks": "^5.0.0", 29 | "eslint-plugin-react-refresh": "^0.4.14", 30 | "globals": "^15.12.0", 31 | "postcss": "^8.4.47", 32 | "tailwindcss": "^3.4.14", 33 | "typescript": "^5.6.3", 34 | "typescript-eslint": "^8.15.0", 35 | "vite": "^5.4.9", 36 | "vite-plugin-chrome-extension": "^0.0.7", 37 | "vite-plugin-html": "^3.2.2", 38 | "vitest": "^2.1.5" 39 | }, 40 | "dependencies": { 41 | "@atproto/api": "^0.13.12", 42 | "@radix-ui/react-dialog": "^1.1.14", 43 | "@radix-ui/react-icons": "^1.3.0", 44 | "@radix-ui/react-label": "^2.1.0", 45 | "@radix-ui/react-popover": "^1.1.2", 46 | "@radix-ui/react-slot": "^1.2.3", 47 | "@radix-ui/react-switch": "^1.2.5", 48 | "browser-image-compression": "^2.0.2", 49 | "class-variance-authority": "^0.7.0", 50 | "clsx": "^2.1.1", 51 | "dotenv": "^16.4.5", 52 | "follow-redirects": "^1.15.9", 53 | "he": "^1.2.0", 54 | "lucide-react": "^0.453.0", 55 | "node-html-parser": "^6.1.13", 56 | "prettier": "^3.3.3", 57 | "process": "^0.11.10", 58 | "react": "^18.3.1", 59 | "react-datepicker": "^7.5.0", 60 | "react-day-picker": "^9.7.0", 61 | "react-dom": "^18.3.1", 62 | "react-icons": "^5.3.0", 63 | "react-router-dom": "^6.27.0", 64 | "tailwind-merge": "^2.5.4", 65 | "tailwindcss-animate": "^1.0.7", 66 | "urijs": "^1.19.11" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nester-xyz/Porto/4cf47b14c5f7fc77e5c6225cb6945c399df42823/public/favicon512.png -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nester-xyz/Porto/4cf47b14c5f7fc77e5c6225cb6945c399df42823/public/icon.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Porto - Import Tweets to Bluesky", 4 | "version": "1.2.7", 5 | "description": "Easily import Tweets to Bluesky with few clicks", 6 | "action": { 7 | "default_icon": "icon.png", 8 | "default_title": "Porto - Import Tweets to Bluesky" 9 | }, 10 | "background": { 11 | "service_worker": "background.js" 12 | }, 13 | "permissions": ["windows"], 14 | "icons": { 15 | "128": "icon.png" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Login from "./components/pages/login/Login"; 2 | import { LogInProvider } from "./hooks/LogInContext"; 3 | import ThemeToggle from "./components/ThemeToggle"; 4 | 5 | const App: React.FC = () => { 6 | return ( 7 | 8 |
9 | 10 |
11 | 12 |
13 | ); 14 | }; 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /src/background.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | let windowId: number | null = null; 4 | 5 | chrome.action.onClicked.addListener(async () => { 6 | if (windowId !== null) { 7 | const window = await chrome.windows.get(windowId); 8 | if (window) { 9 | chrome.windows.update(windowId, { focused: true }); 10 | return; 11 | } 12 | } 13 | 14 | const window = await chrome.windows.create({ 15 | url: "index.html", 16 | type: "popup", 17 | width: 500, 18 | height: 800, 19 | focused: true, 20 | }); 21 | 22 | // Store the window ID 23 | windowId = window.id || null; 24 | 25 | // Listen for window close 26 | chrome.windows.onRemoved.addListener((removedWindowId) => { 27 | if (removedWindowId === windowId) { 28 | windowId = null; 29 | } 30 | }); 31 | }); 32 | 33 | // Handle messages 34 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 35 | if (message.action === "sayHello") { 36 | sendResponse({ response: "Hello from background!" }); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/components/DateRangePicker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { CalendarIcon } from "lucide-react"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Calendar } from "@/components/ui/calendar"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Label } from "@/components/ui/label"; 10 | import { 11 | Popover, 12 | PopoverContent, 13 | PopoverTrigger, 14 | } from "@/components/ui/popover"; 15 | import { TDateRange } from "@/types/render"; 16 | 17 | function formatDate(date: Date | undefined) { 18 | if (!date) { 19 | return ""; 20 | } 21 | 22 | return date.toLocaleDateString("en-US", { 23 | day: "2-digit", 24 | month: "long", 25 | year: "numeric", 26 | }); 27 | } 28 | 29 | function isValidDate(date: Date | undefined) { 30 | if (!date) { 31 | return false; 32 | } 33 | return !isNaN(date.getTime()); 34 | } 35 | 36 | function SingleDatePicker({ 37 | label, 38 | date: initialDate, 39 | onDateChange, 40 | }: { 41 | label: string; 42 | date: Date; 43 | onDateChange: (date: Date) => void; 44 | }) { 45 | const [open, setOpen] = React.useState(false); 46 | const [date, setDate] = React.useState(initialDate); 47 | const [month, setMonth] = React.useState(initialDate); 48 | const [value, setValue] = React.useState(formatDate(initialDate)); 49 | 50 | React.useEffect(() => { 51 | setDate(initialDate); 52 | setMonth(initialDate); 53 | setValue(formatDate(initialDate)); 54 | }, [initialDate]); 55 | 56 | return ( 57 |
58 | 61 |
62 | { 68 | const date = new Date(e.target.value); 69 | setValue(e.target.value); 70 | if (isValidDate(date)) { 71 | setDate(date); 72 | setMonth(date); 73 | onDateChange(date); 74 | } 75 | }} 76 | onKeyDown={(e) => { 77 | if (e.key === "ArrowDown") { 78 | e.preventDefault(); 79 | setOpen(true); 80 | } 81 | }} 82 | /> 83 | 84 | 85 | 93 | 94 | 100 | { 107 | if (!date) return; 108 | setDate(date); 109 | setValue(formatDate(date)); 110 | onDateChange(date); 111 | setOpen(false); 112 | }} 113 | /> 114 | 115 | 116 |
117 |
118 | ); 119 | } 120 | 121 | export function Calendar28({ 122 | onDateChange, 123 | initialDate, 124 | }: { 125 | onDateChange: (range: TDateRange) => void; 126 | initialDate: TDateRange; 127 | }) { 128 | const handleStartDateChange = (date: Date) => { 129 | onDateChange({ ...initialDate, min_date: date }); 130 | }; 131 | 132 | const handleEndDateChange = (date: Date) => { 133 | onDateChange({ ...initialDate, max_date: date }); 134 | }; 135 | 136 | return ( 137 |
138 | 143 | 148 |
149 | ); 150 | } 151 | -------------------------------------------------------------------------------- /src/components/FileFoundCard.tsx: -------------------------------------------------------------------------------- 1 | const FileFoundCard = ({ 2 | cardName, 3 | found, 4 | }: { 5 | cardName: string; 6 | found: boolean; 7 | }) => { 8 | return ( 9 |
16 |
17 |

{cardName}

18 |

19 | {found ? "was successfully loaded" : "could not be found"} 20 |

21 |
22 |
25 | {found ? "✔" : "✖"} 26 |
27 |
28 | ); 29 | }; 30 | export default FileFoundCard; 31 | -------------------------------------------------------------------------------- /src/components/Post.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Upload } from "lucide-react"; 3 | 4 | export default function Post() { 5 | const [files, setFiles] = React.useState(null); 6 | useEffect(() => {}, [files]); 7 | return ( 8 |
9 |
10 |

11 | Port Twitter posts to Bluesky 12 |

13 |
14 | 34 |
35 |

36 | Choose the folder containing your Twitter posts to import them into 37 | Bluesky. 38 |

39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Switch } from "./ui/switch"; 3 | 4 | const ThemeToggle = () => { 5 | const [enabled, setEnabled] = useState(false); 6 | 7 | useEffect(() => { 8 | const stored = localStorage.getItem("theme"); 9 | const prefersDark = window.matchMedia( 10 | "(prefers-color-scheme: dark)" 11 | ).matches; 12 | const isDark = stored === "dark" || (!stored && prefersDark); 13 | setEnabled(isDark); 14 | if (isDark) { 15 | document.documentElement.classList.add("dark"); 16 | } else { 17 | document.documentElement.classList.remove("dark"); 18 | } 19 | }, []); 20 | 21 | const toggle = () => { 22 | const newTheme = enabled ? "light" : "dark"; 23 | setEnabled(!enabled); 24 | if (newTheme === "dark") { 25 | document.documentElement.classList.add("dark"); 26 | } else { 27 | document.documentElement.classList.remove("dark"); 28 | } 29 | localStorage.setItem("theme", newTheme); 30 | }; 31 | 32 | return ( 33 |
34 | Dark Mode 35 | 36 |
37 | ); 38 | }; 39 | 40 | export default ThemeToggle; 41 | -------------------------------------------------------------------------------- /src/components/UploadedPost.tsx: -------------------------------------------------------------------------------- 1 | const UploadedPost = ({ 2 | postName, 3 | link, 4 | }: { 5 | postName: string; 6 | link: string; 7 | }) => { 8 | return ( 9 |
10 |
11 |
12 |

{postName}

13 |
14 | 20 | View Post 21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default UploadedPost; 28 | -------------------------------------------------------------------------------- /src/components/pages/Home/home.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { initialShareableData } from "@/lib/constant"; 3 | import RenderStep1 from "./render1/render1"; 4 | import { shareableData } from "@/types/render"; 5 | import RenderStep2 from "./render2/render2"; 6 | import RenderStep3 from "./render3"; 7 | import Steps from "./steps"; 8 | 9 | const Home = () => { 10 | const [currentStep, setCurrentStep] = useState(1); 11 | const [shareableData, setShareableData] = 12 | useState(initialShareableData); 13 | 14 | const updateShareableData = (data: shareableData) => { 15 | setShareableData(data); 16 | }; 17 | 18 | return ( 19 |
20 |
21 |
22 | 23 |
24 | 25 | {currentStep === 1 ? ( 26 | 30 | ) : currentStep === 2 ? ( 31 | 36 | ) : ( 37 | 41 | )} 42 |
43 |
44 | ); 45 | }; 46 | 47 | export default Home; 48 | -------------------------------------------------------------------------------- /src/components/pages/Home/render1/fileUpload.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Upload, 3 | X, 4 | Folder as FolderIcon, 5 | CheckCircle, 6 | XCircle, 7 | } from "lucide-react"; 8 | import React, { useState, useCallback, useEffect } from "react"; 9 | 10 | type FileUploadProps = { 11 | onFilesChange: (files: File[]) => void; 12 | onTargetFileFound: (found: boolean) => void; 13 | targetFileName: string; 14 | }; 15 | 16 | const FileUpload: React.FC = ({ 17 | onFilesChange, 18 | onTargetFileFound, 19 | targetFileName, 20 | }) => { 21 | const [files, setFiles] = useState([]); 22 | const [isDragging, setIsDragging] = useState(false); 23 | const [folderName, setFolderName] = useState(""); 24 | const [targetFileFound, setTargetFileFound] = useState(null); 25 | 26 | useEffect(() => { 27 | if (files.length > 0) { 28 | const found = files.some((file) => file.name === targetFileName); 29 | setTargetFileFound(found); 30 | onTargetFileFound(found); 31 | } else { 32 | setTargetFileFound(null); 33 | onTargetFileFound(false); 34 | } 35 | }, [files, targetFileName, onTargetFileFound]); 36 | 37 | const handleFiles = useCallback( 38 | (newFiles: FileList | null) => { 39 | if (newFiles && newFiles.length > 0) { 40 | const newFilesArray = Array.from(newFiles); 41 | const firstFile = newFilesArray[0]; 42 | 43 | if ((firstFile as any).webkitRelativePath) { 44 | setFolderName((firstFile as any).webkitRelativePath.split("/")[0]); 45 | } else if (newFilesArray.length === 1) { 46 | setFolderName(newFilesArray[0].name); 47 | } else { 48 | setFolderName(`${newFilesArray.length} files selected`); 49 | } 50 | 51 | setFiles(newFilesArray); 52 | onFilesChange(newFilesArray); 53 | } 54 | }, 55 | [onFilesChange] 56 | ); 57 | 58 | const clearFiles = useCallback(() => { 59 | setFiles([]); 60 | setFolderName(""); 61 | onFilesChange([]); 62 | }, [onFilesChange]); 63 | 64 | const handleDragEnter = (e: React.DragEvent) => { 65 | e.preventDefault(); 66 | e.stopPropagation(); 67 | setIsDragging(true); 68 | }; 69 | 70 | const handleDragLeave = (e: React.DragEvent) => { 71 | e.preventDefault(); 72 | e.stopPropagation(); 73 | setIsDragging(false); 74 | }; 75 | 76 | const handleDragOver = (e: React.DragEvent) => { 77 | e.preventDefault(); 78 | e.stopPropagation(); 79 | }; 80 | 81 | const handleDrop = (e: React.DragEvent) => { 82 | e.preventDefault(); 83 | e.stopPropagation(); 84 | setIsDragging(false); 85 | handleFiles(e.dataTransfer.files); 86 | }; 87 | 88 | return ( 89 |
90 |
0 ? "py-6" : "py-10"} 97 | ${ 98 | isDragging 99 | ? "border-blue-500 bg-blue-50 dark:bg-gray-700" 100 | : files.length > 0 101 | ? "border-blue-500 bg-blue-50 dark:bg-gray-800" 102 | : "border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500" 103 | } 104 | `} 105 | > 106 | {files.length === 0 ? ( 107 |
108 | 133 |
134 | ) : ( 135 |
136 |
137 |
138 |
164 |
165 | 172 |
173 |
174 |
175 | )} 176 |
177 |
178 | ); 179 | }; 180 | 181 | export default FileUpload; 182 | -------------------------------------------------------------------------------- /src/components/pages/Home/render1/render1.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Calendar28 } from "@/components/DateRangePicker"; 4 | import { Render1Props, TDateRange } from "@/types/render"; 5 | import { initialFileState, intialDate, TWEETS_FILENAME } from "@/lib/constant"; 6 | import FileUpload from "./fileUpload"; 7 | import { useFileUpload } from "@/hooks/useFileUpload"; 8 | import { useAnalysis } from "@/hooks/useAnalysis"; 9 | 10 | const RenderStep1: React.FC = ({ 11 | onAnalysisComplete, 12 | setCurrentStep, 13 | }) => { 14 | const [dateRange, setDateRange] = useState(intialDate); 15 | 16 | const { fileState, onFilesChange, targetFileFound, setTargetFileFound } = 17 | useFileUpload(initialFileState); 18 | 19 | const { analysisProgress, tweets, validTweets } = useAnalysis( 20 | fileState, 21 | dateRange 22 | ); 23 | 24 | const analyzeTweets = async () => { 25 | try { 26 | const analysisResults = { 27 | fileMap: fileState.fileMap, 28 | dateRange: dateRange, 29 | totalTweets: tweets?.length ?? 0, 30 | validTweets: validTweets?.length ?? 0, 31 | tweetsLocation: fileState.tweetsLocation!, 32 | mediaLocation: fileState.mediaLocation!, 33 | validTweetsData: validTweets ?? [], 34 | selectedTweetIds: validTweets ? validTweets.map((t) => t.tweet.id) : [], 35 | }; 36 | 37 | setCurrentStep(2); 38 | onAnalysisComplete(analysisResults); 39 | } catch (error) { 40 | console.error("Error analyzing tweets:", error); 41 | } 42 | }; 43 | 44 | return ( 45 |
46 |
47 | 52 | 53 |
54 | setDateRange(newDateRange)} 57 | /> 58 |
59 | 60 | 67 |
68 |
69 | ); 70 | }; 71 | 72 | export default RenderStep1; 73 | -------------------------------------------------------------------------------- /src/components/pages/Home/render1/sync.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "@/components/ui/label"; 2 | import { Switch } from "@/components/ui/switch"; 3 | 4 | type SycnProps = { 5 | checked: boolean; 6 | updateChecked: (checked: boolean) => void; 7 | }; 8 | 9 | const Sync = ({ checked, updateChecked }: SycnProps) => { 10 | return ( 11 |
12 | 13 | 18 |
19 | ); 20 | }; 21 | 22 | export default Sync; 23 | -------------------------------------------------------------------------------- /src/components/pages/Home/render2/render2.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Card } from "@/components/ui/card"; 3 | import { Render2Props } from "@/types/render"; 4 | import { useEffect, useState } from "react"; 5 | import { useUpload } from "@/hooks/useUpload"; 6 | import { Tweet } from "@/types/tweets"; 7 | import { 8 | Dialog, 9 | DialogContent, 10 | DialogHeader, 11 | DialogTitle, 12 | DialogTrigger, 13 | } from "@/components/ui/dialog"; 14 | import { Eye } from "lucide-react"; 15 | 16 | const RenderStep2: React.FC = ({ 17 | setCurrentStep, 18 | shareableData, 19 | setShareableData, 20 | }) => { 21 | const { totalTweets, validTweets, validTweetsData, selectedTweetIds } = 22 | shareableData; 23 | const [selectedIds, setSelectedIds] = useState([]); 24 | const [query, setQuery] = useState(""); 25 | const [visibleCount, setVisibleCount] = useState(50); 26 | const [isEmailConfirmed, setIsEmailConfirmed] = useState(true); 27 | const [tweetsWithVideos, setTweetsWithVideos] = useState([]); 28 | 29 | const { isProcessing, progress, tweet_to_bsky, skippedVideos } = useUpload({ 30 | shareableData, 31 | }); 32 | 33 | useEffect(() => { 34 | const emailConfirmed = localStorage.getItem("emailConfirmed") === "true"; 35 | setIsEmailConfirmed(emailConfirmed); 36 | }, []); 37 | 38 | useEffect(() => { 39 | if (validTweetsData) { 40 | const videoTweets = validTweetsData.filter( 41 | (t) => t.tweet.extended_entities?.media?.[0]?.type === "video" 42 | ); 43 | setTweetsWithVideos(videoTweets); 44 | } 45 | }, [validTweetsData]); 46 | 47 | useEffect(() => { 48 | if (selectedTweetIds && selectedTweetIds.length > 0) { 49 | setSelectedIds(selectedTweetIds); 50 | } else if (validTweetsData) { 51 | setSelectedIds(validTweetsData.map((t) => t.tweet.id)); 52 | } 53 | }, [validTweetsData, selectedTweetIds]); 54 | 55 | useEffect(() => { 56 | if (!isProcessing && progress === 100 && selectedIds.length > 0) { 57 | setShareableData({ ...shareableData, selectedTweetIds: selectedIds }); 58 | setCurrentStep(3); 59 | } 60 | }, [isProcessing, progress]); 61 | 62 | const toggleId = (id: string) => { 63 | setSelectedIds((prev) => 64 | prev.includes(id) ? prev.filter((p) => p !== id) : [...prev, id] 65 | ); 66 | }; 67 | 68 | const filteredTweets = 69 | validTweetsData?.filter((t) => 70 | t.tweet.full_text.toLowerCase().includes(query.toLowerCase()) 71 | ) || []; 72 | 73 | const displayTweets = filteredTweets.slice(0, visibleCount); 74 | 75 | return ( 76 |
77 | 78 |

Tweet Analysis

79 |
80 |

81 | Total tweets found: {totalTweets} 82 |

83 |

84 | Valid tweets to import: {validTweets} 85 |

86 |

87 | Excluded: {totalTweets - validTweets} (quotes, retweets, replies, or 88 | outside date range) 89 |

90 | {!isEmailConfirmed && ( 91 |
92 |

93 | Your email isn't verified. Videos won't be uploaded. 94 |

95 | {tweetsWithVideos.length > 0 && ( 96 | 97 | 98 | 101 | 102 | 103 | 104 | Tweets with Videos 105 | 106 |
107 | {tweetsWithVideos.map((t) => ( 108 |
109 |

{t.tweet.full_text}

110 |
111 | ))} 112 |
113 |
114 |
115 | )} 116 |
117 | )} 118 |
119 |
120 | 121 |
122 | { 125 | setQuery(e.target.value); 126 | setVisibleCount(50); 127 | }} 128 | placeholder="Search tweets..." 129 | className="flex-1 dark:bg-gray-800 px-2 py-1 border rounded-md" 130 | /> 131 | 143 |
144 | 145 | {validTweetsData && ( 146 | 147 |

148 | Select Tweets ({selectedIds.length}) 149 |

150 |
151 | {displayTweets.map((t) => ( 152 | 161 | ))} 162 |
163 | {filteredTweets.length > visibleCount && ( 164 |
165 | 171 |
172 | )} 173 |
174 | )} 175 | 176 | {isProcessing && ( 177 |
178 |
182 |
183 | )} 184 | 185 |
186 | 194 | 208 |
209 |
210 | ); 211 | }; 212 | 213 | export default RenderStep2; 214 | -------------------------------------------------------------------------------- /src/components/pages/Home/render3.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Card } from "@/components/ui/card"; 3 | import { BLUESKY_USERNAME } from "@/lib/constant"; 4 | import { Render3Props } from "@/types/render"; 5 | import { CheckCircle } from "lucide-react"; 6 | import { Tweet } from "@/types/tweets"; 7 | 8 | const RenderStep3: React.FC = ({ 9 | shareableData, 10 | setCurrentStep, 11 | }) => { 12 | const { totalTweets, validTweetsData, selectedTweetIds, skippedVideos } = 13 | shareableData; 14 | const importedCount = selectedTweetIds.length 15 | ? selectedTweetIds.length 16 | : (validTweetsData?.length ?? 0); 17 | const skippedCount = totalTweets - importedCount; 18 | 19 | return ( 20 |
21 |
22 |
23 |
24 | 25 |
26 |

27 | Import Complete! 28 |

29 |

30 | Your tweets have been successfully imported to Bluesky 31 |

32 |
33 | 34 | 35 |

Import Summary

36 |
37 |

38 | Total tweets found: {totalTweets} 39 |

40 |

41 | Successfully imported: {importedCount} 42 |

43 |

44 | Skipped: {skippedCount} (unselected or invalid) 45 |

46 |
47 |
48 | 49 | {skippedVideos && skippedVideos.length > 0 && ( 50 | 51 |

Tweets with Skipped Videos

52 |

53 | The following tweets had videos that were not uploaded because 54 | your email is not confirmed on Bluesky. 55 |

56 |
57 | {skippedVideos.map((tweet: Tweet["tweet"]) => ( 58 |
59 |

{tweet.full_text}

60 |
61 | ))} 62 |
63 |
64 | )} 65 | 66 | 67 |
68 |

Support Our Work

69 |

70 | If you found this tool helpful, consider supporting us to keep it 71 | free and maintained 72 |

73 | 81 |
82 |
83 | 84 |
85 | 92 | 103 |
104 |
105 |
106 | ); 107 | }; 108 | 109 | export default RenderStep3; 110 | -------------------------------------------------------------------------------- /src/components/pages/Home/steps.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { UploadCloud, Check, Settings, PartyPopper } from "lucide-react"; 3 | 4 | type StepProps = { 5 | step: number; 6 | currentStep: number; 7 | title: string; 8 | icon: React.ReactNode; 9 | }; 10 | 11 | const Step = ({ step, currentStep, title, icon }: StepProps) => { 12 | const isActive = currentStep === step; 13 | const isCompleted = currentStep > step; 14 | 15 | return ( 16 |
17 |
26 | {isCompleted ? : icon} 27 |
28 |
29 |
36 | {title} 37 |
38 |
39 |
40 | ); 41 | }; 42 | 43 | const Steps = ({ currentStep }: { currentStep: number }) => { 44 | const steps = [ 45 | { 46 | step: 1, 47 | title: "Upload", 48 | icon: , 49 | }, 50 | { 51 | step: 2, 52 | title: "Customize", 53 | icon: , 54 | }, 55 | { step: 3, title: "Finish", icon: }, 56 | ]; 57 | 58 | return ( 59 |
60 | {steps.map((s, index) => ( 61 | 62 | 68 | {index < steps.length - 1 && ( 69 |
s.step 72 | ? "bg-green-500" 73 | : "bg-gray-200 dark:bg-gray-700" 74 | }`} 75 | /> 76 | )} 77 | 78 | ))} 79 |
80 | ); 81 | }; 82 | 83 | export default Steps; 84 | -------------------------------------------------------------------------------- /src/components/pages/login/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import LoginForm from "./loginForm"; 3 | import { useLogInContext } from "@/hooks/LogInContext"; 4 | import Home from "../Home/home"; 5 | 6 | const Login = () => { 7 | const [error, setError] = useState(""); 8 | const [isLoading, setIsLoading] = useState(false); 9 | const [showTwoFactor, setShowTwoFactor] = useState(false); 10 | const [verificationCode, setVerificationCode] = useState(""); 11 | const { agent, loggedIn, setLoggedIn } = useLogInContext(); 12 | 13 | const handleLogin = async (userName: string, password: string) => { 14 | if (!agent) throw new Error("No agent found"); 15 | setIsLoading(true); 16 | setError(""); 17 | 18 | try { 19 | const user = await agent.login({ 20 | identifier: userName, 21 | password: password, 22 | authFactorToken: verificationCode, 23 | }); 24 | 25 | if (user.success) { 26 | setLoggedIn(true); 27 | localStorage.setItem( 28 | "emailConfirmed", 29 | String(user.data.emailConfirmed) 30 | ); 31 | console.info("User logged in successfully"); 32 | } 33 | } catch (error: any) { 34 | console.error("Login error:", error); 35 | 36 | // Check for the specific error message from Bluesky API 37 | if ( 38 | error?.message?.includes("A sign in code has been sent") || 39 | error?.cause?.message?.includes("A sign in code has been sent") 40 | ) { 41 | setShowTwoFactor(true); 42 | setError( 43 | "A sign-in code has been sent to your email. Please enter it below." 44 | ); 45 | } else { 46 | setError("Invalid username or password"); 47 | } 48 | } finally { 49 | setIsLoading(false); 50 | } 51 | }; 52 | 53 | if (loggedIn) return ; 54 | 55 | return ( 56 | 64 | ); 65 | }; 66 | 67 | export default Login; 68 | -------------------------------------------------------------------------------- /src/components/pages/login/loginForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Label } from "@/components/ui/label"; 5 | import { CgDanger } from "react-icons/cg"; 6 | import { BsFillInfoCircleFill } from "react-icons/bs"; 7 | import { HiEye, HiEyeSlash } from "react-icons/hi2"; 8 | import { Button } from "@/components/ui/button"; 9 | 10 | interface LoginFormProps { 11 | onLogin: (userName: string, password: string) => void; 12 | error: string; 13 | isLoading: boolean; 14 | showTwoFactor: boolean; 15 | verificationCode: string; 16 | onVerificationCodeChange: (code: string) => void; 17 | } 18 | 19 | const LoginForm = ({ 20 | onLogin, 21 | error, 22 | isLoading, 23 | showTwoFactor, 24 | verificationCode, 25 | onVerificationCodeChange, 26 | }: LoginFormProps) => { 27 | const [userName, setUserName] = useState(""); 28 | const [password, setPassword] = useState(""); 29 | const [showToolTip, setShowToolTip] = useState(false); 30 | const [showPassword, setShowPassword] = useState(false); 31 | 32 | const handleSubmit = () => { 33 | if (userName === "" || password === "") { 34 | alert("Please fill in both the username and password."); 35 | } else { 36 | onLogin(userName, password); 37 | } 38 | }; 39 | 40 | const appPasswordRoute = () => { 41 | window.open( 42 | "https://github.com/bluesky-social/atproto-ecosystem/blob/main/app-passwords.md", 43 | "_blank" 44 | ); 45 | }; 46 | 47 | return ( 48 |
49 | 50 | 51 | Login 52 | 53 | 54 |
{ 57 | event.preventDefault(); 58 | handleSubmit(); 59 | }} 60 | > 61 | {/* Error */} 62 |
63 | {error && ( 64 |
72 | 73 | {error} 74 |
75 | )} 76 |
77 | {/* Username Input */} 78 |
79 | 80 | setUserName(e.target.value)} 87 | /> 88 |
89 | 90 | {/* Password Input */} 91 |
92 | {showToolTip && ( 93 |
94 | Temporary password for third party applications! 95 |
96 | )} 97 |
98 | 99 |
setShowToolTip(true)} 102 | onClick={appPasswordRoute} 103 | onMouseLeave={() => setShowToolTip(false)} 104 | > 105 | 106 |
107 |
108 |
109 | setPassword(e.target.value)} 116 | /> 117 |
setShowPassword((prev) => !prev)} 120 | > 121 | {showPassword ? ( 122 | 123 | ) : ( 124 | 125 | )} 126 |
127 |
128 |
129 | 130 | {showTwoFactor && ( 131 |
132 | 133 |
134 | onVerificationCodeChange(e.target.value)} 141 | /> 142 |
143 |
144 | )} 145 | 146 | {/* Login Button */} 147 | 154 | 155 | 156 |
157 |
158 |
159 |
160 | ); 161 | }; 162 | 163 | export default LoginForm; 164 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | } 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /src/components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { 3 | ChevronDownIcon, 4 | ChevronLeftIcon, 5 | ChevronRightIcon, 6 | } from "lucide-react" 7 | import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" 8 | 9 | import { cn } from "@/lib/utils" 10 | import { Button, buttonVariants } from "@/components/ui/button" 11 | 12 | function Calendar({ 13 | className, 14 | classNames, 15 | showOutsideDays = true, 16 | captionLayout = "label", 17 | buttonVariant = "ghost", 18 | formatters, 19 | components, 20 | ...props 21 | }: React.ComponentProps & { 22 | buttonVariant?: React.ComponentProps["variant"] 23 | }) { 24 | const defaultClassNames = getDefaultClassNames() 25 | 26 | return ( 27 | svg]:rotate-180`, 32 | String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, 33 | className 34 | )} 35 | captionLayout={captionLayout} 36 | formatters={{ 37 | formatMonthDropdown: (date) => 38 | date.toLocaleString("default", { month: "short" }), 39 | ...formatters, 40 | }} 41 | classNames={{ 42 | root: cn("w-fit", defaultClassNames.root), 43 | months: cn( 44 | "relative flex flex-col gap-4 md:flex-row", 45 | defaultClassNames.months 46 | ), 47 | month: cn("flex w-full flex-col gap-4", defaultClassNames.month), 48 | nav: cn( 49 | "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", 50 | defaultClassNames.nav 51 | ), 52 | button_previous: cn( 53 | buttonVariants({ variant: buttonVariant }), 54 | "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", 55 | defaultClassNames.button_previous 56 | ), 57 | button_next: cn( 58 | buttonVariants({ variant: buttonVariant }), 59 | "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", 60 | defaultClassNames.button_next 61 | ), 62 | month_caption: cn( 63 | "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", 64 | defaultClassNames.month_caption 65 | ), 66 | dropdowns: cn( 67 | "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", 68 | defaultClassNames.dropdowns 69 | ), 70 | dropdown_root: cn( 71 | "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", 72 | defaultClassNames.dropdown_root 73 | ), 74 | dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown), 75 | caption_label: cn( 76 | "select-none font-medium", 77 | captionLayout === "label" 78 | ? "text-sm" 79 | : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", 80 | defaultClassNames.caption_label 81 | ), 82 | table: "w-full border-collapse", 83 | weekdays: cn("flex", defaultClassNames.weekdays), 84 | weekday: cn( 85 | "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", 86 | defaultClassNames.weekday 87 | ), 88 | week: cn("mt-2 flex w-full", defaultClassNames.week), 89 | week_number_header: cn( 90 | "w-[--cell-size] select-none", 91 | defaultClassNames.week_number_header 92 | ), 93 | week_number: cn( 94 | "text-muted-foreground select-none text-[0.8rem]", 95 | defaultClassNames.week_number 96 | ), 97 | day: cn( 98 | "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", 99 | defaultClassNames.day 100 | ), 101 | range_start: cn( 102 | "bg-accent rounded-l-md", 103 | defaultClassNames.range_start 104 | ), 105 | range_middle: cn("rounded-none", defaultClassNames.range_middle), 106 | range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), 107 | today: cn( 108 | "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", 109 | defaultClassNames.today 110 | ), 111 | outside: cn( 112 | "text-muted-foreground aria-selected:text-muted-foreground", 113 | defaultClassNames.outside 114 | ), 115 | disabled: cn( 116 | "text-muted-foreground opacity-50", 117 | defaultClassNames.disabled 118 | ), 119 | hidden: cn("invisible", defaultClassNames.hidden), 120 | ...classNames, 121 | }} 122 | components={{ 123 | Root: ({ className, rootRef, ...props }) => { 124 | return ( 125 |
131 | ) 132 | }, 133 | Chevron: ({ className, orientation, ...props }) => { 134 | if (orientation === "left") { 135 | return ( 136 | 137 | ) 138 | } 139 | 140 | if (orientation === "right") { 141 | return ( 142 | 146 | ) 147 | } 148 | 149 | return ( 150 | 151 | ) 152 | }, 153 | DayButton: CalendarDayButton, 154 | WeekNumber: ({ children, ...props }) => { 155 | return ( 156 | 157 |
158 | {children} 159 |
160 | 161 | ) 162 | }, 163 | ...components, 164 | }} 165 | {...props} 166 | /> 167 | ) 168 | } 169 | 170 | function CalendarDayButton({ 171 | className, 172 | day, 173 | modifiers, 174 | ...props 175 | }: React.ComponentProps) { 176 | const defaultClassNames = getDefaultClassNames() 177 | 178 | const ref = React.useRef(null) 179 | React.useEffect(() => { 180 | if (modifiers.focused) ref.current?.focus() 181 | }, [modifiers.focused]) 182 | 183 | return ( 184 |