diff options
Diffstat (limited to 'packages/tweetdeck')
39 files changed, 6549 insertions, 0 deletions
diff --git a/packages/tweetdeck/.gitignore b/packages/tweetdeck/.gitignore new file mode 100644 index 0000000..0aa738a --- /dev/null +++ b/packages/tweetdeck/.gitignore @@ -0,0 +1,35 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +XClientTransaction diff --git a/packages/tweetdeck/AGENTS.md b/packages/tweetdeck/AGENTS.md new file mode 100644 index 0000000..b2aa31f --- /dev/null +++ b/packages/tweetdeck/AGENTS.md @@ -0,0 +1,109 @@ + +Default to using Bun instead of Node.js. + +- Use `bun <file>` instead of `node <file>` or `ts-node <file>` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` +- Bun automatically loads .env, so don't use dotenv. + +## APIs + +- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. +- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. +- `Bun.redis` for Redis. Don't use `ioredis`. +- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. +- `WebSocket` is built-in. Don't use `ws`. +- Prefer `Bun.file` over `node:fs`'s readFile/writeFile +- Bun.$`ls` instead of execa. + +## Testing + +Use `bun test` to run tests. + +```ts#index.test.ts +import { test, expect } from "bun:test"; + +test("hello world", () => { + expect(1).toBe(1); +}); +``` + +## Frontend + +Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. + +Server: + +```ts#index.ts +import index from "./index.html" + +Bun.serve({ + routes: { + "/": index, + "/api/users/:id": { + GET: (req) => { + return new Response(JSON.stringify({ id: req.params.id })); + }, + }, + }, + // optional websocket support + websocket: { + open: (ws) => { + ws.send("Hello, world!"); + }, + message: (ws, message) => { + ws.send(message); + }, + close: (ws) => { + // handle close + } + }, + development: { + hmr: true, + console: true, + } +}) +``` + +HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. + +```html#index.html +<html> + <body> + <h1>Hello, world!</h1> + <script type="module" src="./frontend.tsx"></script> + </body> +</html> +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; + +// import .css files directly and it works +import './index.css'; + +import { createRoot } from "react-dom/client"; + +const root = createRoot(document.body); + +export default function Frontend() { + return <h1>Hello, world!</h1>; +} + +root.render(<Frontend />); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. + + +codex resume 019a215f-7256-74a0-aaee-554a11867cd5 diff --git a/packages/tweetdeck/GEMINI.md b/packages/tweetdeck/GEMINI.md new file mode 100644 index 0000000..c75a387 --- /dev/null +++ b/packages/tweetdeck/GEMINI.md @@ -0,0 +1,71 @@ +# Project Context: Bun React TweetDeck + +## Overview +This project is a **TweetDeck-like Twitter client** built with **Bun** and **React 19**. It utilizes Bun's native server and bundler capabilities, bypassing the need for tools like Webpack or Vite. It features a column-based layout for viewing different Twitter feeds (For You, Following, Lists, etc.). + +## Tech Stack +- **Runtime & Bundler:** [Bun](https://bun.com) (v1.3.1+) +- **Frontend:** React 19, `prosody-ui` (local lib), Lucide React (icons). +- **Backend:** `Bun.serve()` (Native Bun HTTP server). +- **Data Fetching:** Custom Twitter API wrapper (`src/lib/fetching/twitter-api.ts`) using cookie-based authentication. +- **State Persistence:** Custom `usePersistentState` hook. + +## Architecture +The application is a **Hybrid SSR/SPA**: +1. **Server (`src/index.ts`):** + * Serves the static `index.html`. + * Provides API endpoints at `/api/twitter/*`. + * Handles Twitter API requests by proxying them with a user-provided cookie. +2. **Client (`src/frontend.tsx` -> `src/App.tsx`):** + * Bootstrapped via `src/index.html`. + * Manages the UI state (columns, decks). + * Communicates with the local `/api/twitter` endpoints. + +## Key Directories +- `src/index.ts`: **Server Entry Point**. Defines API routes and serves static files. +- `src/frontend.tsx`: **Client Entry Point**. Hydrates the React app. +- `src/App.tsx`: Main application component. +- `src/components/`: UI components. + * `ColumnBoard.tsx`: Manages the grid/layout of columns. + * `ChatColumn.tsx` / `TimelineColumn.tsx`: Individual feed columns. + * `TweetCard.tsx`: Displays a single tweet. +- `src/lib/fetching/`: Backend logic for data fetching. + * `twitter-api.ts`: The core service communicating with Twitter. +- `twatter-cookies.ts`: (Likely) Contains logic or types related to the auth cookie structure. + +## Development & Usage + +### Prerequisites +- **Bun** must be installed. + +### Commands +- **Install Dependencies:** + ```bash + bun install + ``` +- **Start Development Server (Hot Reload):** + ```bash + bun dev + ``` + *Access at `http://localhost:3010`* +- **Build for Production:** + ```bash + bun build + ``` + *Outputs to `dist/`* +- **Start Production Server:** + ```bash + bun start + ``` + +### Conventions +- **Bun-First:** Always use `bun` commands (`bun install`, `bun test`, `bun run`). Do not use `npm` or `yarn`. +- **Styles:** CSS is imported directly into TSX files (e.g., `import './index.css'`). +- **Local Libs:** The project relies on local libraries (`sortug`, `prosody-ui`) linked via `file:` paths in `package.json`. +- **Auth:** Authentication is handled via a raw Twitter cookie string passed in API requests. + +## Notes for AI Agents +- When adding new API routes, define them in `src/index.ts`. +- When modifying the UI, look for components in `src/components/` first. +- The project uses **React 19**, so use modern React patterns (Hooks, Functional Components). +- Ensure `bun-python` usage is checked if modifying Python integration files (`tests/python.ts`, `src/lib/fetching/python.ts`). diff --git a/packages/tweetdeck/NOTES.md b/packages/tweetdeck/NOTES.md new file mode 100644 index 0000000..63a2ff6 --- /dev/null +++ b/packages/tweetdeck/NOTES.md @@ -0,0 +1,3 @@ +createBookmark and createRT return a freaking 404. + +which happens if the tweet in question is already bookmarked/RT'ed but it's not the case here!! diff --git a/packages/tweetdeck/README.md b/packages/tweetdeck/README.md new file mode 100644 index 0000000..0e71df9 --- /dev/null +++ b/packages/tweetdeck/README.md @@ -0,0 +1,21 @@ +# bun-react-template + +To install dependencies: + +```bash +bun install +``` + +To start a development server: + +```bash +bun dev +``` + +To run for production: + +```bash +bun start +``` + +This project was created using `bun init` in bun v1.3.1. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/packages/tweetdeck/bun-env.d.ts b/packages/tweetdeck/bun-env.d.ts new file mode 100644 index 0000000..72f1c26 --- /dev/null +++ b/packages/tweetdeck/bun-env.d.ts @@ -0,0 +1,17 @@ +// Generated by `bun init` + +declare module "*.svg" { + /** + * A path to the SVG file + */ + const path: `${string}.svg`; + export = path; +} + +declare module "*.module.css" { + /** + * A record of class names to their corresponding CSS module classes + */ + const classes: { readonly [key: string]: string }; + export = classes; +} diff --git a/packages/tweetdeck/bun.lock b/packages/tweetdeck/bun.lock new file mode 100644 index 0000000..a51402d --- /dev/null +++ b/packages/tweetdeck/bun.lock @@ -0,0 +1,410 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "bun-react-tweetdeck", + "dependencies": { + "bun_python": "^0.1.10", + "lucide-react": "latest", + "node-html-parser": "^7.0.1", + "prosody-ui": "file:../../libs/prosody-ui", + "react": "^19", + "react-dom": "^19", + "sortug": "file:../../libs/sortug", + "sortug-ai": "file:../../libs/models", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19", + "@types/react-dom": "^19", + }, + }, + }, + "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.70.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-AGEhifuvE22VxfQ5ROxViTgM8NuVQzEvqcN8bttR4AP24ythmNE/cL/SrOz79xiv7/osrsmCyErjsistJi7Z8A=="], + + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "@elevenlabs/elevenlabs-js": ["@elevenlabs/elevenlabs-js@2.24.1", "", { "dependencies": { "command-exists": "^1.2.9", "node-fetch": "^2.7.0", "ws": "^8.18.3" } }, "sha512-i6bDExgK9lYne1vLhy85JJ3O8bNi5vPTfcgq8kT3HG4+3rgkUJtg5UP29Mn1KONc4ZOeYUomzxJ820uLkT9z6g=="], + + "@google/genai": ["@google/genai@1.30.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.20.1" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w=="], + + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="], + + "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="], + + "@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + + "@types/react": ["@types/react@19.2.6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bcp-47": ["bcp-47@2.1.0", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="], + + "bun_python": ["bun_python@0.1.10", "", { "peerDependencies": { "typescript": "^5.7.3" } }, "sha512-6c5owYOI7lYI7lBbYX99L6SQ5dT4jsabsV8yKDX15zi3cRurl/nWO576L3KbSTpGUR8wqQ8TDGRS7Wqwg9gunQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "command-exists": ["command-exists@1.2.9", "", {}, "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w=="], + + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "file-type": ["file-type@18.7.0", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.2", "strtok3": "^7.0.0", "token-types": "^5.0.1" } }, "sha512-ihHtXRzXEziMrQ56VSgU7wkxh55iNchFkosu7Y9/S+tXHdKyrGjVK0ujbqNnsxzea+78MaLhN6PGmfYSAv1ACw=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + + "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "framer-motion": ["framer-motion@12.23.24", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w=="], + + "franc-all": ["franc-all@7.2.0", "", { "dependencies": { "trigram-utils": "^2.0.0" } }, "sha512-ZR6ciLQTDBaOvBdkOd8+vqDzaLtmIXRa9GCzcAlaBpqNAKg9QrtClPmqiKac5/xZXfCZGMo1d8dIu1T0BLhHEg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "groq-sdk": ["groq-sdk@0.36.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-wvxl7i6QWxLcIfM00mQQybYk15OAXJG0NBBQuMDHrQ2vi68uz2RqFTBKUNfEOVz8Lwy4eAgQIPBEFW5P3cXybA=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.0", "", { "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "lucide-react": ["lucide-react@0.554.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "motion": ["motion@12.23.24", "", { "dependencies": { "framer-motion": "^12.23.24", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw=="], + + "motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="], + + "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "n-gram": ["n-gram@2.0.2", "", {}, "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "node-html-parser": ["node-html-parser@7.0.1", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "peek-readable": ["peek-readable@5.4.2", "", {}, "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg=="], + + "playht": ["playht@0.21.0", "", { "dependencies": { "@grpc/grpc-js": "^1.9.4", "axios": "^1.4.0", "cross-fetch": "^4.0.0", "deepmerge-ts": "^7.1.5", "file-type": "^18.5.0", "protobufjs": "^7.2.5", "tslib": "^2.1.0" } }, "sha512-63dWfsoGNOxfl91U3knrON4HcgtdPZ+e0Q3F8JX22T6dvX17i217lfw8cq1OzIBWVxpHms8ebhgUU/Gvs0/8Eg=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "prosody-ui": ["prosody-ui@file:../../libs/prosody-ui", { "dependencies": { "franc-all": "^7.2.0", "glotscript": "file:../glotscript", "motion": "^12.11.3", "sortug": "file:../sortug", "sortug-ai": "file:../models" }, "peerDependencies": { "react": ">=19.0.0", "react-dom": ">=19.0.0", "typescript": "^5.0.0" } }], + + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + + "replicate": ["replicate@1.4.0", "", { "optionalDependencies": { "readable-stream": ">=4.0.0" } }, "sha512-1ufKejfUVz/azy+5TnzQP7U1+MHVWZ6psnQ06az8byUUnRhT+DZ/MvewzB1NQYBVMgNKR7xPDtTwlcP5nv/5+w=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sortug": ["@sortug/lib@file:../../libs/sortug", { "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5" } }], + + "sortug-ai": ["@sortug/ai@file:../../libs/models", { "dependencies": { "@anthropic-ai/sdk": "latest", "@elevenlabs/elevenlabs-js": "^2.24.1", "@google/genai": "latest", "bcp-47": "^2.1.0", "franc-all": "^7.2.0", "groq-sdk": "latest", "iso-639-3": "file:../lang", "openai": "latest", "playht": "latest", "replicate": "latest", "sortug": "file:../:sortug" }, "devDependencies": { "@types/bun": "latest", "@types/mime-types": "^3.0.1" }, "peerDependencies": { "typescript": "latest" } }], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strtok3": ["strtok3@7.1.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^5.1.3" } }, "sha512-mKX8HA/cdBqMKUr0MMZAFssCkIGoZeSCMXgnt79yKxNFguMLVFgRe6wB+fsL0NmoHDbeyZXczy7vEPSoo3rkzg=="], + + "token-types": ["token-types@5.0.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "trigram-utils": ["trigram-utils@2.0.1", "", { "dependencies": { "collapse-white-space": "^2.0.0", "n-gram": "^2.0.0" } }, "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "prosody-ui/glotscript": ["glotscript@file:../../libs/glotscript", {}], + + "prosody-ui/sortug": ["sortug@file:../../libs/sortug", {}], + + "prosody-ui/sortug-ai": ["sortug-ai@file:../../libs/models", {}], + + "sortug-ai/iso-639-3": ["iso-639-3@file:../../libs/lang", {}], + + "sortug-ai/sortug": ["sortug@file:../../libs/:sortug", {}], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + } +} diff --git a/packages/tweetdeck/bunfig.toml b/packages/tweetdeck/bunfig.toml new file mode 100644 index 0000000..9819bf6 --- /dev/null +++ b/packages/tweetdeck/bunfig.toml @@ -0,0 +1,2 @@ +[serve.static] +env = "BUN_PUBLIC_*"
\ No newline at end of file diff --git a/packages/tweetdeck/package.json b/packages/tweetdeck/package.json new file mode 100644 index 0000000..bc9a71c --- /dev/null +++ b/packages/tweetdeck/package.json @@ -0,0 +1,26 @@ +{ + "name": "bun-react-tweetdeck", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot src/index.ts", + "build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", + "start": "NODE_ENV=production bun src/index.ts" + }, + "dependencies": { + "bun_python": "^0.1.10", + "lucide-react": "latest", + "node-html-parser": "^7.0.1", + "react": "^19", + "react-dom": "^19", + "@sortug/lib": "workspce:*", + "@sortug/prosody-ui": "workspce:*", + "@sortug/ai": "workspce:*" + }, + "devDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/bun": "latest" + } +} diff --git a/packages/tweetdeck/src/APITester.tsx b/packages/tweetdeck/src/APITester.tsx new file mode 100644 index 0000000..fd2af48 --- /dev/null +++ b/packages/tweetdeck/src/APITester.tsx @@ -0,0 +1,39 @@ +import { useRef, type FormEvent } from "react"; + +export function APITester() { + const responseInputRef = useRef<HTMLTextAreaElement>(null); + + const testEndpoint = async (e: FormEvent<HTMLFormElement>) => { + e.preventDefault(); + + try { + const form = e.currentTarget; + const formData = new FormData(form); + const endpoint = formData.get("endpoint") as string; + const url = new URL(endpoint, location.href); + const method = formData.get("method") as string; + const res = await fetch(url, { method }); + + const data = await res.json(); + responseInputRef.current!.value = JSON.stringify(data, null, 2); + } catch (error) { + responseInputRef.current!.value = String(error); + } + }; + + return ( + <div className="api-tester"> + <form onSubmit={testEndpoint} className="endpoint-row"> + <select name="method" className="method"> + <option value="GET">GET</option> + <option value="PUT">PUT</option> + </select> + <input type="text" name="endpoint" defaultValue="/api/hello" className="url-input" placeholder="/api/hello" /> + <button type="submit" className="send-button"> + Send + </button> + </form> + <textarea ref={responseInputRef} readOnly placeholder="Response will appear here..." className="response-area" /> + </div> + ); +} diff --git a/packages/tweetdeck/src/App.tsx b/packages/tweetdeck/src/App.tsx new file mode 100644 index 0000000..924ff9a --- /dev/null +++ b/packages/tweetdeck/src/App.tsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import "./styles/normalize.css"; +import "./styles/index.css"; +import { Sidebar, type NewAccountInput } from "./components/Sidebar"; +import { ColumnBoard } from "./components/ColumnBoard"; +import { AddColumnModal } from "./components/AddColumnModal"; +import { usePersistentState } from "./hooks/usePersistentState"; +import type { + ColumnSnapshot, + ColumnState, + DeckAccount, + DeckColumn, + DeckListsCache, + FullscreenState, +} from "./types/app"; +import type { Tweet } from "./lib/fetching/types"; +import { generateId } from "./lib/utils/id"; +import { twitterClient } from "./lib/client/twitterClient"; +import { FullscreenColumn } from "./components/FullscreenColumn"; + +const ACCOUNTS_KEY = "tweetdeck.accounts"; +const COLUMNS_KEY = "tweetdeck.columns"; + +export function App() { + const [accounts, setAccounts] = usePersistentState<DeckAccount[]>( + ACCOUNTS_KEY, + [], + ); + const [columns, setColumns] = usePersistentState<DeckColumn[]>( + COLUMNS_KEY, + [], + ); + const [listsCache, setListsCache] = useState<DeckListsCache>({}); + const [activeAccountId, setActiveAccountId] = useState<string | undefined>( + () => accounts[0]?.id, + ); + const [isModalOpen, setModalOpen] = useState(false); + const [toast, setToast] = useState<string | null>(null); + const [fullscreen, setFullscreen] = useState<FullscreenState | null>(null); + const [columnSnapshots, setColumnSnapshots] = useState< + Record<string, ColumnSnapshot> + >({}); + + useEffect(() => { + if (!activeAccountId) { + const firstAccount = accounts[0]; + if (firstAccount) { + setActiveAccountId(firstAccount.id); + } + } + }, [accounts, activeAccountId]); + + useEffect(() => { + const acs = accounts.filter((a) => !a.avatar || !a.username); + console.log({ acs }); + const nacs = acs.map(async (acc) => { + const our = await twitterClient.own({ cookie: acc.cookie }); + const nacc = { + ...acc, + handle: our.username, + label: our.name, + avatar: our.avatar, + }; + return nacc; + }); + Promise.all(nacs) + .then((acs) => setAccounts(acs)) + .catch((err) => console.error(err)); + }, []); + + const handleAddAccount = useCallback( + (payload: NewAccountInput) => { + const label = `Session ${accounts.length + 1}`; + const account: DeckAccount = { + id: generateId(), + label, + accent: randomAccent(), + cookie: payload.cookie.trim(), + createdAt: Date.now(), + }; + setAccounts((prev) => [...prev, account]); + setActiveAccountId(account.id); + setToast(`${account.label} is ready`); + }, + [accounts.length, setAccounts], + ); + + const handleRemoveAccount = useCallback( + (accountId: string) => { + setAccounts((prev) => prev.filter((account) => account.id !== accountId)); + setColumns((prev) => + prev.filter((column) => column.accountId !== accountId), + ); + setListsCache((prev) => { + const next = { ...prev }; + delete next[accountId]; + return next; + }); + if (activeAccountId === accountId) { + setActiveAccountId(undefined); + } + }, + [activeAccountId, setAccounts, setColumns], + ); + + const handleAddColumn = useCallback( + (column: Omit<DeckColumn, "id">) => { + const nextColumn = { ...column, id: generateId() }; + setColumns((prev) => [...prev, nextColumn]); + setToast(`${nextColumn.title} added to deck`); + }, + [setColumns], + ); + + const handleRemoveColumn = useCallback( + (id: string) => { + setColumns((prev) => prev.filter((column) => column.id !== id)); + }, + [setColumns], + ); + + const fetchLists = useCallback( + async (accountId: string) => { + const account = accounts.find((acc) => acc.id === accountId); + if (!account) throw new Error("Account not found"); + if (listsCache[accountId]) return listsCache[accountId]; + console.log({ listsCache }); + const lists = await twitterClient.lists({ cookie: account.cookie }); + console.log({ lists }); + setListsCache((prev) => ({ ...prev, [accountId]: lists })); + return lists; + }, + [accounts, listsCache], + ); + + const handleColumnStateChange = useCallback( + (columnId: string, state: ColumnState) => { + setColumns((prev) => + prev.map((column) => + column.id === columnId ? { ...column, state } : column, + ), + ); + }, + [setColumns], + ); + + const handleColumnSnapshot = useCallback( + (columnId: string, snapshot: ColumnSnapshot) => { + setColumnSnapshots((prev) => { + const existing = prev[columnId]; + if ( + existing && + existing.tweets === snapshot.tweets && + existing.label === snapshot.label + ) { + return prev; + } + return { + ...prev, + [columnId]: { tweets: snapshot.tweets, label: snapshot.label }, + }; + }); + }, + [], + ); + + const openFullscreen = useCallback( + (payload: FullscreenState) => { + const snapshot = columnSnapshots[payload.column.id]; + setFullscreen({ + ...payload, + tweets: snapshot?.tweets ?? payload.tweets, + columnLabel: snapshot?.label ?? payload.columnLabel, + }); + }, + [columnSnapshots], + ); + + useEffect(() => { + if (!fullscreen) return; + const snapshot = columnSnapshots[fullscreen.column.id]; + if (!snapshot) return; + if ( + snapshot.tweets === fullscreen.tweets && + snapshot.label === fullscreen.columnLabel + ) { + return; + } + setFullscreen((prev) => { + if (!prev) return prev; + if (prev.column.id !== fullscreen.column.id) return prev; + const tweets = snapshot.tweets; + const index = Math.min(prev.index, Math.max(tweets.length - 1, 0)); + return { + ...prev, + tweets, + columnTitle: snapshot.label, + index, + }; + }); + }, [columnSnapshots, fullscreen]); + + const content = useMemo( + () => ( + <ColumnBoard + columns={columns} + accounts={accounts} + onRemove={handleRemoveColumn} + onStateChange={handleColumnStateChange} + onSnapshot={handleColumnSnapshot} + onEnterFullscreen={openFullscreen} + /> + ), + [ + accounts, + columns, + handleRemoveColumn, + handleColumnStateChange, + handleColumnSnapshot, + openFullscreen, + ], + ); + + return ( + <div className="app-shell"> + <Sidebar + accounts={accounts} + activeAccountId={activeAccountId} + onActivate={(id) => setActiveAccountId(id)} + onAddAccount={handleAddAccount} + onRemoveAccount={handleRemoveAccount} + onAddColumn={() => setModalOpen(true)} + /> + + <main>{content}</main> + + <AddColumnModal + accounts={accounts} + activeAccountId={activeAccountId} + isOpen={isModalOpen} + onClose={() => setModalOpen(false)} + onAdd={handleAddColumn} + fetchLists={fetchLists} + listsCache={listsCache} + /> + + {toast && ( + <div className="toast" onAnimationEnd={() => setToast(null)}> + {toast} + </div> + )} + + {fullscreen && ( + <FullscreenColumn + state={fullscreen} + onExit={() => setFullscreen(null)} + onNavigate={(step) => { + setFullscreen((prev) => { + if (!prev) return prev; + if (!prev.tweets.length) return prev; + const nextIndex = Math.min( + prev.tweets.length - 1, + Math.max(0, prev.index + step), + ); + if (nextIndex === prev.index) return prev; + return { ...prev, index: nextIndex }; + }); + }} + hasPrevColumn={fullscreen.columnIndex > 0} + hasNextColumn={fullscreen.columnIndex < columns.length - 1} + onSwitchColumn={(direction) => { + setFullscreen((prev) => { + if (!prev) return prev; + const nextIndex = prev.columnIndex + direction; + if (nextIndex < 0) return prev; + if (nextIndex >= columns.length) { + setModalOpen(true); + return prev; + } + const nextColumn = columns[nextIndex]; + if (!nextColumn) return prev; + const snapshot = columnSnapshots[nextColumn.id]; + const account = accounts.find( + (acc) => acc.id === nextColumn.accountId, + ); + const tweets = snapshot?.tweets ?? []; + return { + column: nextColumn, + columnIndex: nextIndex, + columnLabel: snapshot?.label ?? nextColumn.title, + accent: account?.accent ?? prev.accent, + tweets, + index: 0, + }; + }); + }} + onAddColumn={() => setModalOpen(true)} + /> + )} + </div> + ); +} + +function randomAccent(): string { + const palette = ["#7f5af0", "#2cb67d", "#f25f4c", "#f0a500", "#19a7ce"]; + const pick = palette[Math.floor(Math.random() * palette.length)]; + return pick ?? "#7f5af0"; +} + +export default App; diff --git a/packages/tweetdeck/src/components/AddColumnModal.tsx b/packages/tweetdeck/src/components/AddColumnModal.tsx new file mode 100644 index 0000000..b7098ed --- /dev/null +++ b/packages/tweetdeck/src/components/AddColumnModal.tsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useState } from "react"; +import type { DeckAccount, DeckColumn, DeckListsCache } from "../types/app"; +import type { TwitterList } from "../lib/fetching/types"; + +interface AddColumnModalProps { + accounts: DeckAccount[]; + isOpen: boolean; + onClose: () => void; + onAdd: (column: Omit<DeckColumn, "id">) => void; + activeAccountId?: string; + fetchLists: (accountId: string) => Promise<TwitterList[]>; + listsCache: DeckListsCache; +} + +const columnOptions = [ + { + id: "foryou", + label: "For You", + description: "Twitter's AI-ranked firehose", + }, + { + id: "following", + label: "Following", + description: "Chronological feed of people you follow", + }, + { id: "bookmarks", label: "Bookmarks", description: "Your saved deep dives" }, + { id: "list", label: "List", description: "Curate a topic-specific stream" }, + { id: "chat", label: "Chat", description: "Mentions & notifications" }, +] as const; + +export function AddColumnModal({ + accounts, + activeAccountId, + isOpen, + onClose, + onAdd, + fetchLists, + listsCache, +}: AddColumnModalProps) { + const [kind, setKind] = useState<DeckColumn["kind"]>("foryou"); + const [accountId, setAccountId] = useState<string>( + activeAccountId || accounts[0]?.id || "", + ); + const [title, setTitle] = useState("For You"); + const [listId, setListId] = useState<string>(""); + const [listOptions, setListOptions] = useState<TwitterList[]>([]); + const [listsLoading, setListsLoading] = useState(false); + const [listsError, setListsError] = useState<string | undefined>(); + + useEffect(() => { + if (!isOpen) return; + setAccountId(activeAccountId || accounts[0]?.id || ""); + }, [isOpen, activeAccountId, accounts]); + + useEffect(() => { + setTitle(defaultTitle(kind)); + }, [kind]); + + useEffect(() => { + if (!isOpen || kind !== "list" || !accountId) return; + let mounted = true; + async function loadLists() { + try { + setListsLoading(true); + setListsError(undefined); + const cached = listsCache[accountId]; + if (cached) { + setListOptions(cached); + return; + } + const lists = await fetchLists(accountId); + if (!mounted) return; + setListOptions(lists); + } catch (error) { + setListsError( + error instanceof Error ? error.message : "Failed to load lists", + ); + } finally { + if (mounted) setListsLoading(false); + } + } + loadLists(); + return () => { + mounted = false; + }; + }, [accountId, fetchLists, isOpen, kind, listsCache]); + + useEffect(() => { + if (!isOpen) return; + setListId(""); + setListOptions([]); + }, [accountId, isOpen, kind]); + + const selectedAccount = useMemo( + () => accounts.find((account) => account.id === accountId), + [accountId, accounts], + ); + + const canSubmit = Boolean(selectedAccount && (kind !== "list" || listId)); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!canSubmit || !selectedAccount) return; + onAdd({ + accountId: selectedAccount.id, + account: selectedAccount.username || "", + kind, + title: title.trim() || defaultTitle(kind), + listId: listId || undefined, + listName: + kind === "list" + ? listOptions.find((list) => list.id === listId)?.name + : undefined, + }); + onClose(); + }; + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( + <div + className="modal-backdrop" + role="dialog" + aria-modal="true" + onMouseDown={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} + > + <div className="modal"> + <header> + <div> + <p className="eyebrow">New column</p> + <h2>Design your stream</h2> + </div> + <button + className="ghost" + onClick={onClose} + aria-label="Close add column modal" + > + × + </button> + </header> + <form onSubmit={handleSubmit} className="modal-body"> + <label> + Account + <select + value={accountId} + onChange={(e) => setAccountId(e.target.value)} + > + {accounts.map((account) => ( + <option value={account.id} key={account.id}> + {account.label} + {account.handle ? ` (@${account.handle})` : ""} + </option> + ))} + </select> + </label> + <div className="option-grid"> + {columnOptions.map((option) => ( + <button + type="button" + key={option.id} + className={`option ${kind === option.id ? "selected" : ""}`} + onClick={() => setKind(option.id)} + > + <div> + <strong>{option.label}</strong> + <p>{option.description}</p> + </div> + </button> + ))} + </div> + {kind === "list" && ( + <label> + Choose a list + {listsError && <span className="error">{listsError}</span>} + <select + value={listId} + disabled={listsLoading} + onChange={(e) => setListId(e.target.value)} + > + <option value="" disabled> + {listsLoading ? "Loading lists..." : "Select a list"} + </option> + {listOptions.map((list) => ( + <option key={list.id} value={list.id}> + {list.name} ({list.member_count}) + </option> + ))} + </select> + </label> + )} + <label> + Column title + <input + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Custom title" + /> + </label> + <button className="primary" type="submit" disabled={!canSubmit}> + Add column + </button> + </form> + </div> + </div> + ); +} + +function defaultTitle(kind: DeckColumn["kind"]) { + switch (kind) { + case "following": + return "Following"; + case "bookmarks": + return "Bookmarks"; + case "list": + return "List"; + case "chat": + return "Chat"; + default: + return "For You"; + } +} diff --git a/packages/tweetdeck/src/components/ChatCard.tsx b/packages/tweetdeck/src/components/ChatCard.tsx new file mode 100644 index 0000000..d7d885c --- /dev/null +++ b/packages/tweetdeck/src/components/ChatCard.tsx @@ -0,0 +1,56 @@ +import type { TwitterNotification } from "../lib/fetching/types"; +import { timeAgo } from "../lib/utils/time"; + +interface ChatCardProps { + notification: TwitterNotification; + accent: string; +} + +export function ChatCard({ notification, accent }: ChatCardProps) { + const firstUser = Object.values(notification.users)[0]; + const timestamp = timeAgo(Number(notification.timestampMs)); + + return ( + <article className="chat-card" style={{ borderColor: accent }}> + <div className="chat-avatar"> + {firstUser?.profile_image_url_https ? ( + <img src={firstUser.profile_image_url_https} alt={firstUser.name} loading="lazy" /> + ) : ( + <span>{firstUser?.name?.[0] ?? "?"}</span> + )} + </div> + <div className="chat-body"> + <header> + <strong>{firstUser?.name ?? "Notification"}</strong> + {firstUser?.screen_name && <span className="muted">@{firstUser.screen_name}</span>} + <span className="muted dot" aria-hidden="true"> + • + </span> + <span className="muted">{timestamp}</span> + </header> + <p>{highlight(notification.message.text)}</p> + </div> + </article> + ); +} + +function highlight(text: string) { + const parts = text.split(/([@#][A-Za-z0-9_]+)/g); + return parts.map((part, index) => { + if (part.startsWith("@")) { + return ( + <span key={index} className="mention"> + {part} + </span> + ); + } + if (part.startsWith("#")) { + return ( + <span key={index} className="hashtag"> + {part} + </span> + ); + } + return <span key={index}>{part}</span>; + }); +} diff --git a/packages/tweetdeck/src/components/ChatColumn.tsx b/packages/tweetdeck/src/components/ChatColumn.tsx new file mode 100644 index 0000000..0c336e5 --- /dev/null +++ b/packages/tweetdeck/src/components/ChatColumn.tsx @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from "react"; +import type { DeckAccount, DeckColumn, ChatState } from "../types/app"; +import { twitterClient } from "../lib/client/twitterClient"; +import { ChatCard } from "./ChatCard"; + +interface ChatColumnProps { + column: DeckColumn & { kind: "chat" }; + account: DeckAccount; + onRemove: () => void; +} + +export function ChatColumn({ column, account, onRemove }: ChatColumnProps) { + const [state, setState] = useState<ChatState>({ entries: [], isLoading: false }); + const [error, setError] = useState<string | undefined>(); + + const refresh = useCallback(async () => { + setState(prev => ({ ...prev, isLoading: true })); + setError(undefined); + try { + const notifications = await twitterClient.notifications({ cookie: account.cookie }); + setState({ entries: notifications, isLoading: false }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to refresh chat"); + setState(prev => ({ ...prev, isLoading: false })); + } + }, [account.cookie]); + + useEffect(() => { + refresh(); + }, [refresh, account.id]); + + return ( + <article className="column"> + <header> + <div> + <p className="eyebrow">Signal</p> + <h3>{column.title || "Chat"}</h3> + <p className="muted tiny">Mentions, follows and notifications for {account.label}</p> + </div> + <div className="column-actions"> + <button className="ghost" onClick={refresh} aria-label="Refresh chat"> + ↻ + </button> + <button className="ghost" onClick={onRemove} aria-label="Remove column"> + × + </button> + </div> + </header> + {error && <p className="error">{error}</p>} + {state.isLoading && !state.entries.length ? ( + <div className="column-loading">Loading…</div> + ) : ( + <div className="chat-stack"> + {state.entries.map(entry => ( + <ChatCard key={entry.id} notification={entry} accent={account.accent} /> + ))} + {!state.entries.length && <p className="muted">No recent notifications.</p>} + </div> + )} + </article> + ); +} diff --git a/packages/tweetdeck/src/components/ColumnBoard.tsx b/packages/tweetdeck/src/components/ColumnBoard.tsx new file mode 100644 index 0000000..87da3e1 --- /dev/null +++ b/packages/tweetdeck/src/components/ColumnBoard.tsx @@ -0,0 +1,93 @@ +import { useMemo } from "react"; +import type { + ColumnSnapshot, + ColumnState, + DeckAccount, + DeckColumn, + FullscreenState, +} from "../types/app"; +import { TimelineColumn, type TimelineConfig } from "./TimelineColumn"; +import { ChatColumn } from "./ChatColumn"; + +interface ColumnBoardProps { + columns: DeckColumn[]; + accounts: DeckAccount[]; + onRemove: (id: string) => void; + onStateChange: (columnId: string, state: ColumnState) => void; + onSnapshot: (columnId: string, snapshot: ColumnSnapshot) => void; + onEnterFullscreen: (payload: FullscreenState) => void; +} + +export function ColumnBoard({ + columns, + accounts, + onRemove, + onStateChange, + onSnapshot, + onEnterFullscreen, +}: ColumnBoardProps) { + const accountMap = useMemo( + () => Object.fromEntries(accounts.map(account => [account.id, account])), + [accounts], + ); + + if (!columns.length) { + return ( + <section className="empty-board"> + <p className="eyebrow">No columns yet</p> + <h2>Build your deck</h2> + <p>Add some columns from the left panel to start streaming timelines.</p> + </section> + ); + } + + return ( + <section className="column-board"> + {columns.map((column, columnIndex) => { + const account = accountMap[column.accountId]; + if (!account) { + return ( + <div className="column missing" key={column.id}> + <header> + <div> + <p className="eyebrow">Account missing</p> + <h3>{column.title}</h3> + </div> + <button className="ghost" onClick={() => onRemove(column.id)}> + Remove + </button> + </header> + <p className="muted">The account for this column was removed.</p> + </div> + ); + } + if (isChatColumn(column)) { + return ( + <ChatColumn + key={column.id} + column={column} + account={account} + onRemove={() => onRemove(column.id)} + /> + ); + } + return ( + <TimelineColumn + key={column.id} + column={column as TimelineConfig} + account={account} + onRemove={() => onRemove(column.id)} + onStateChange={onStateChange} + onSnapshot={onSnapshot} + onEnterFullscreen={onEnterFullscreen} + columnIndex={columnIndex} + /> + ); + })} + </section> + ); +} + +function isChatColumn(column: DeckColumn): column is DeckColumn & { kind: "chat" } { + return column.kind === "chat"; +} diff --git a/packages/tweetdeck/src/components/FullscreenColumn.tsx b/packages/tweetdeck/src/components/FullscreenColumn.tsx new file mode 100644 index 0000000..66959b8 --- /dev/null +++ b/packages/tweetdeck/src/components/FullscreenColumn.tsx @@ -0,0 +1,134 @@ +import { useEffect } from "react"; +import type { FullscreenState } from "../types/app"; +import { TweetCard } from "./TweetCard"; + +interface FullscreenColumnProps { + state: FullscreenState; + onExit: () => void; + onNavigate: (step: number) => void; + onSwitchColumn: (step: number) => void; + hasPrevColumn: boolean; + hasNextColumn: boolean; + onAddColumn: () => void; +} + +export function FullscreenColumn({ + state, + onExit, + onNavigate, + onSwitchColumn, + hasPrevColumn, + hasNextColumn, + onAddColumn, +}: FullscreenColumnProps) { + console.log({ state }); + const tweet = state.tweets[state.index]; + const hasTweets = state.tweets.length > 0; + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onExit(); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + onNavigate(1); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + onNavigate(-1); + } + if (event.key === "ArrowRight") { + event.preventDefault(); + if (hasNextColumn) { + onSwitchColumn(1); + } else { + onAddColumn(); + } + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + if (hasPrevColumn) { + onSwitchColumn(-1); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [ + onExit, + onNavigate, + onSwitchColumn, + hasPrevColumn, + hasNextColumn, + onAddColumn, + ]); + + return ( + <div className="fullscreen-overlay"> + <button + className="ghost fullscreen-close" + onClick={onExit} + aria-label="Close fullscreen view" + > + × + </button> + <div className="fullscreen-content"> + <header> + <p className="eyebrow"> + {state.columnLabel}@{state.column.account} + </p> + <p className="muted tiny"> + {hasTweets + ? `${state.index + 1} / ${state.tweets.length}` + : "0 / 0"} + </p> + </header> + <div className="fullscreen-card"> + {hasTweets && tweet ? ( + <TweetCard tweet={tweet} accent={state.accent} /> + ) : ( + <div className="fullscreen-empty"> + <p>No tweets loaded for this column yet.</p> + <p className="muted"> + Try refreshing the column or exit fullscreen. + </p> + </div> + )} + </div> + <div className="fullscreen-controls"> + <button + className="ghost" + onClick={() => onNavigate(-1)} + disabled={!hasTweets || state.index === 0} + > + ↑ Previous tweet + </button> + <button + className="ghost" + onClick={() => onNavigate(1)} + disabled={!hasTweets || state.index >= state.tweets.length - 1} + > + Next tweet ↓ + </button> + </div> + <div className="fullscreen-column-controls"> + <button + className="ghost" + onClick={() => onSwitchColumn(-1)} + disabled={!hasPrevColumn} + > + ← Previous column + </button> + <button + className="ghost" + onClick={() => (hasNextColumn ? onSwitchColumn(1) : onAddColumn())} + > + {hasNextColumn ? "Next column →" : "+ Add column →"} + </button> + </div> + </div> + </div> + ); +} diff --git a/packages/tweetdeck/src/components/Sidebar.tsx b/packages/tweetdeck/src/components/Sidebar.tsx new file mode 100644 index 0000000..3d9b85d --- /dev/null +++ b/packages/tweetdeck/src/components/Sidebar.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; +import type { DeckAccount } from "../types/app"; +import { twitterClient } from "@/lib/client/twitterClient"; + +export interface NewAccountInput { + cookie: string; +} + +interface SidebarProps { + accounts: DeckAccount[]; + activeAccountId?: string; + onActivate: (id: string) => void; + onAddAccount: (payload: NewAccountInput) => void; + onRemoveAccount: (id: string) => void; + onAddColumn: () => void; +} + +export function Sidebar({ + accounts, + activeAccountId, + onActivate, + onAddAccount, + onRemoveAccount, + onAddColumn, +}: SidebarProps) { + const [isAdding, setIsAdding] = useState(!accounts.length); + const [cookie, setCookie] = useState(""); + const [showCookie, setShowCookie] = useState(false); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!cookie.trim()) return; + onAddAccount({ cookie: decodeURIComponent(cookie.trim()) }); + setCookie(""); + setIsAdding(false); + }; + + return ( + <aside className="sidebar"> + <div> + <div className="brand"> + <div className="brand-glow" /> + <div> + <p className="eyebrow">Project Starling</p> + <h1>Open TweetDeck</h1> + <p className="tagline"> + Multi-account Twitter cockpit powered by Bun. + </p> + </div> + </div> + + <section className="sidebar-section"> + <header> + <p className="eyebrow">Accounts</p> + <button className="ghost" onClick={() => setIsAdding((v) => !v)}> + {isAdding ? "Close" : "Add"} + </button> + </header> + {!accounts.length && !isAdding && ( + <p className="muted"> + Add a Twitter session cookie to start streaming timelines. You can + rename the account later once data loads. + </p> + )} + {accounts.map((account) => ( + <div + role="button" + tabIndex={0} + key={account.id} + className={`account-chip ${account.id === activeAccountId ? "active" : ""}`} + onClick={() => onActivate(account.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivate(account.id); + } + }} + > + <span + className="chip-accent" + style={{ background: account.accent }} + /> + <span> + <strong>{account.label}</strong> + {account.handle ? <small>@{account.handle}</small> : null} + </span> + <span className="chip-actions"> + <button + type="button" + className="ghost" + onClick={(event) => { + event.stopPropagation(); + onRemoveAccount(account.id); + }} + aria-label={`Remove ${account.label}`} + > + × + </button> + </span> + </div> + ))} + {isAdding && ( + <form className="account-form" onSubmit={handleSubmit}> + <label> + Twitter session cookie + <textarea + className={!showCookie ? "masked" : undefined} + placeholder="Paste the entire Cookie header" + value={cookie} + onChange={(e) => setCookie(e.target.value)} + rows={4} + /> + </label> + <label className="checkbox"> + <input + type="checkbox" + checked={showCookie} + onChange={(e) => setShowCookie(e.target.checked)} + /> + Reveal cookie contents + </label> + <small className="muted"> + Cookie stays in your browser via localStorage. It is only sent + to your Bun server when fetching timelines. + </small> + <button + className="primary" + type="submit" + disabled={!cookie.trim()} + > + Save account + </button> + </form> + )} + </section> + </div> + + <div className="sidebar-footer"> + <button + className="primary wide" + onClick={onAddColumn} + disabled={!accounts.length} + > + + Add column + </button> + <p className="muted tiny"> + Need a cookie? Open x.com, inspect network requests and copy the + request `Cookie` header. Keep it secret. + </p> + </div> + </aside> + ); +} diff --git a/packages/tweetdeck/src/components/TimelineColumn.tsx b/packages/tweetdeck/src/components/TimelineColumn.tsx new file mode 100644 index 0000000..534b2dd --- /dev/null +++ b/packages/tweetdeck/src/components/TimelineColumn.tsx @@ -0,0 +1,500 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { + ColumnSnapshot, + ColumnState, + ColumnView, + DeckAccount, + DeckColumn, + FullscreenState, + TimelineState, +} from "../types/app"; +import type { Tweet } from "../lib/fetching/types"; +import { twitterClient } from "../lib/client/twitterClient"; +import { TweetCard } from "./TweetCard"; + +export type TimelineConfig = DeckColumn & { + kind: Exclude<DeckColumn["kind"], "chat">; +}; + +type TimelineView = Extract<ColumnView, { type: "timeline" }>; + +interface TimelineColumnProps { + column: TimelineConfig; + account: DeckAccount; + onRemove: () => void; + onStateChange: (columnId: string, state: ColumnState) => void; + onSnapshot: (columnId: string, snapshot: ColumnSnapshot) => void; + onEnterFullscreen: (payload: FullscreenState) => void; + columnIndex: number; +} + +export function TimelineColumn({ + column, + account, + onRemove, + onStateChange, + onSnapshot, + onEnterFullscreen, + columnIndex, +}: TimelineColumnProps) { + const [state, setState] = useState<TimelineState>({ + tweets: [], + cursorTop: "", + cursorBottom: "", + isLoading: false, + isAppending: false, + }); + const [error, setError] = useState<string | undefined>(); + const [transitionDirection, setTransitionDirection] = useState< + "forward" | "backward" | null + >(null); + + const baseView = useMemo( + () => createBaseView(column), + [column.kind, column.title, column.listId, column.listName], + ); + const initialStack = useMemo<ColumnView[]>(() => { + return column.state?.stack?.length ? column.state.stack : [baseView]; + }, [column.state, baseView]); + + const [viewStack, setViewStack] = useState<ColumnView[]>(initialStack); + + useEffect(() => { + setViewStack(initialStack); + }, [initialStack]); + + const activeView = viewStack[viewStack.length - 1] ?? baseView; + const canGoBack = viewStack.length > 1; + + const descriptor = useMemo( + () => describeView(column, activeView), + [column, activeView], + ); + + const handleMaximize = useCallback(() => { + onEnterFullscreen({ + column: column, + columnLabel: descriptor.label, + accent: account.accent, + tweets: state.tweets, + index: 0, + columnIndex, + }); + }, [ + state.tweets, + onEnterFullscreen, + column.id, + descriptor.label, + account.accent, + columnIndex, + ]); + + const handleAnimationEnd = useCallback(() => { + setTransitionDirection(null); + }, []); + + const pushView = useCallback((view: ColumnView) => { + setTransitionDirection("forward"); + setViewStack((prev) => [...prev, view]); + }, []); + + const popView = useCallback(() => { + setViewStack((prev) => { + if (prev.length <= 1) return prev; + setTransitionDirection("backward"); + return prev.slice(0, -1); + }); + }, []); + + useEffect(() => { + onStateChange(column.id, { stack: viewStack }); + }, [column.id, onStateChange, viewStack]); + + useEffect(() => { + onSnapshot(column.id, { tweets: state.tweets, label: descriptor.label }); + }, [column.id, state.tweets, descriptor.label, onSnapshot]); + + const fetchPage = useCallback( + async (cursor?: string) => { + const payload: Record<string, unknown> = { cookie: account.cookie }; + // + let mode: string; + + if (activeView.type === "thread") { + mode = "thread"; + payload.tweetId = activeView.tweetId; + } else if (activeView.type === "user") { + mode = "user"; + payload.userId = activeView.userId; + } else { + mode = activeView.mode; + if (activeView.mode === "list" && activeView.listId) { + payload.listId = activeView.listId; + } + } + + if (cursor) payload.cursor = cursor; + return twitterClient.timeline(mode, payload); + }, + [account.cookie, activeView], + ); + + const refresh = useCallback(async () => { + if ( + activeView.type === "timeline" && + activeView.mode === "list" && + !activeView.listId + ) { + setError("Select a list for this column"); + return; + } + setState((prev) => ({ ...prev, isLoading: true })); + setError(undefined); + try { + const data = await fetchPage(state.cursorTop); + setState({ + tweets: data.tweets, + cursorTop: data.cursorTop, + cursorBottom: data.cursorBottom, + isLoading: false, + isAppending: false, + }); + } catch (err) { + console.error(err); + setError(err instanceof Error ? err.message : "Failed to load timeline"); + setState((prev) => ({ ...prev, isLoading: false, isAppending: false })); + } + }, [activeView, fetchPage]); + + useEffect(() => { + setState({ + tweets: [], + cursorTop: "", + cursorBottom: "", + isLoading: true, + isAppending: false, + }); + }, [activeView]); + + useEffect(() => { + refresh(); + }, [refresh]); + + const loadMore = useCallback(async () => { + if (!state.cursorBottom) return; + setState((prev) => ({ ...prev, isAppending: true })); + try { + const data = await fetchPage(state.cursorBottom); + setState((prev) => ({ + tweets: [...prev.tweets, ...data.tweets], + cursorTop: prev.cursorTop || data.cursorTop, + cursorBottom: data.cursorBottom, + isLoading: false, + isAppending: false, + })); + } catch (err) { + console.error(err); + setError(err instanceof Error ? err.message : "Failed to load more"); + setState((prev) => ({ ...prev, isAppending: false })); + } + }, [fetchPage, state.cursorBottom, state.cursorTop]); + + const updateTweetById = useCallback( + (tweetId: string, updater: (tweet: Tweet) => Tweet) => { + setState((prev) => ({ + ...prev, + tweets: prev.tweets.map((tweet) => + tweet.id === tweetId ? updater(tweet) : tweet, + ), + })); + }, + [], + ); + + const handleRemoveBookmark = useCallback( + async (tweetId: string) => { + try { + await twitterClient.removeBookmark({ cookie: account.cookie, tweetId }); + setState((prev) => ({ + ...prev, + tweets: prev.tweets.filter((tweet) => tweet.id !== tweetId), + })); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Unable to remove bookmark right now", + ); + } + }, + [account.cookie], + ); + + const likeTweet = useCallback( + async (tweetId: string, nextState: boolean) => { + try { + await twitterClient.like(tweetId, { + cookie: account.cookie, + undo: !nextState, + }); + updateTweetById(tweetId, (tweet) => ({ ...tweet, liked: nextState })); + } catch (err) { + setError(err instanceof Error ? err.message : "Unable to update like"); + } + }, + [account.cookie, updateTweetById], + ); + + const retweet = useCallback( + async (tweetId: string, nextState: boolean) => { + try { + await twitterClient.retweet(tweetId, { + cookie: account.cookie, + undo: !nextState, + }); + updateTweetById(tweetId, (tweet) => ({ ...tweet, rted: nextState })); + } catch (err) { + setError( + err instanceof Error ? err.message : "Unable to update retweet", + ); + } + }, + [account.cookie, updateTweetById], + ); + + const bookmarkTweet = useCallback( + async (tweetId: string, nextState: boolean) => { + try { + await twitterClient.bookmark(tweetId, { + cookie: account.cookie, + undo: !nextState, + }); + if (column.kind === "bookmarks" && !nextState) { + setState((prev) => ({ + ...prev, + tweets: prev.tweets.filter((tweet) => tweet.id !== tweetId), + })); + } else { + updateTweetById(tweetId, (tweet) => ({ + ...tweet, + bookmarked: nextState, + })); + } + } catch (err) { + setError( + err instanceof Error ? err.message : "Unable to update bookmark", + ); + } + }, + [account.cookie, column.kind, updateTweetById], + ); + + const replyToTweet = useCallback( + (tweetId: string, text: string) => + twitterClient.reply(tweetId, { + cookie: account.cookie, + text, + }), + [account.cookie], + ); + + const handleOpenAuthor = useCallback( + (author: Tweet["author"]) => { + if (!author?.id) return; + pushView({ + type: "user", + userId: author.id, + username: author.username, + title: author.name || `@${author.username}`, + }); + }, + [pushView], + ); + + const handleOpenThread = useCallback( + (tweet: Tweet) => { + pushView({ + type: "thread", + tweetId: tweet.id, + title: `Thread · ${tweet.author.name}`, + }); + }, + [pushView], + ); + + const breadcrumbs = useMemo( + () => viewStack.map((view) => labelForView(view)).join(" / "), + [viewStack], + ); + + return ( + <article className="column"> + <header> + <div> + <p className="eyebrow">{descriptor.badge}</p> + <h3>{descriptor.label}</h3> + <p className="muted tiny"> + {descriptor.description} · {account.label} + </p> + {viewStack.length > 1 && <p className="muted tiny">{breadcrumbs}</p>} + </div> + <div className="column-actions"> + <button + className="ghost" + onClick={handleMaximize} + aria-label="Maximize column" + > + ⤢ + </button> + {canGoBack && ( + <button className="ghost" onClick={popView} aria-label="Go back"> + ← + </button> + )} + <button + className="ghost" + onClick={refresh} + aria-label="Refresh column" + > + ↻ + </button> + <button + className="ghost" + onClick={onRemove} + aria-label="Remove column" + > + × + </button> + </div> + </header> + <div + className={`column-content ${transitionDirection ? `slide-${transitionDirection}` : ""}`} + onAnimationEnd={handleAnimationEnd} + > + {error && <p className="error">{error}</p>} + {state.isLoading && !state.tweets.length ? ( + <div className="column-loading">Loading…</div> + ) : ( + <div className="tweet-stack"> + {state.tweets + .filter((t) => t.language === "th") + .slice(0, 10) + .map((tweet) => ( + <TweetCard + key={tweet.id} + tweet={tweet} + accent={account.accent} + allowBookmarkRemoval={column.kind === "bookmarks"} + onRemoveBookmark={handleRemoveBookmark} + onToggleLike={likeTweet} + onToggleRetweet={retweet} + onToggleBookmark={bookmarkTweet} + onReply={replyToTweet} + onOpenAuthor={handleOpenAuthor} + onOpenThread={handleOpenThread} + /> + ))} + {!state.tweets.length && !state.isLoading && ( + <p className="muted">No tweets yet. Try refreshing.</p> + )} + {state.cursorBottom ? ( + <div className="load-more-row"> + <button + className="ghost" + disabled={state.isAppending} + onClick={loadMore} + > + {state.isAppending ? "Loading…" : "Load more"} + </button> + </div> + ) : ( + state.tweets.length > 0 && ( + <div className="load-more-row"> + <p className="muted">End of feed</p> + </div> + ) + )} + </div> + )} + </div> + </article> + ); +} + +function createBaseView(column: TimelineConfig): TimelineView { + return { + type: "timeline", + mode: column.kind, + title: column.title || describeTimeline(column.kind).label, + listId: column.listId, + listName: column.listName, + }; +} + +function labelForView(view: ColumnView): string { + if (view.type === "timeline") { + return view.title || describeTimeline(view.mode).label; + } + if (view.type === "user") { + return view.title || `@${view.username}`; + } + return view.title || "Thread"; +} + +function describeView(column: TimelineConfig, view: ColumnView) { + if (view.type === "timeline") { + const base = describeTimeline(view.mode); + if (view.mode === "list" && view.listName) { + return { + ...base, + label: view.title || view.listName, + description: `Tweets from ${view.listName}`, + }; + } + return { + ...base, + label: view.title || base.label, + }; + } + if (view.type === "user") { + return { + label: view.title || `@${view.username}`, + badge: "Profile", + description: `Posts from @${view.username}`, + }; + } + return { + label: view.title || "Thread", + badge: "Thread", + description: "Deep dive into the conversation", + }; +} + +function describeTimeline(kind: TimelineConfig["kind"]) { + switch (kind) { + case "following": + return { + label: "Following", + badge: "Chrono", + description: "Latest posts from people you follow", + }; + case "bookmarks": + return { + label: "Bookmarks", + badge: "Library", + description: "Saved gems queued for later", + }; + case "list": + return { + label: "List", + badge: "Curated", + description: "Tweets from a Twitter List", + }; + default: + return { + label: "For You", + badge: "Ranked", + description: "AI-ranked home timeline", + }; + } +} diff --git a/packages/tweetdeck/src/components/TweetCard.tsx b/packages/tweetdeck/src/components/TweetCard.tsx new file mode 100644 index 0000000..7cd2936 --- /dev/null +++ b/packages/tweetdeck/src/components/TweetCard.tsx @@ -0,0 +1,337 @@ +import { useCallback, useState } from "react"; +import { Bookmark, Heart, Link2, MessageCircle, Repeat2 } from "lucide-react"; +import type { Tweet } from "../lib/fetching/types"; +import { timeAgo } from "../lib/utils/time"; +import { LangText } from "prosody-ui"; + +interface TweetCardProps { + tweet: Tweet; + accent: string; + allowBookmarkRemoval?: boolean; + onRemoveBookmark?: (tweetId: string) => void; + onReply?: (tweetId: string, text: string) => Promise<unknown>; + onToggleRetweet?: (tweetId: string, next: boolean) => Promise<unknown>; + onToggleLike?: (tweetId: string, next: boolean) => Promise<unknown>; + onToggleBookmark?: (tweetId: string, next: boolean) => Promise<unknown>; + onOpenAuthor?: (author: Tweet["author"]) => void; + onOpenThread?: (tweet: Tweet) => void; + isQuote?: boolean; +} + +type ActionKind = "reply" | "retweet" | "like" | "bookmark" | null; + +export function TweetCard(props: TweetCardProps) { + const { + tweet, + accent, + allowBookmarkRemoval, + onRemoveBookmark, + onOpenAuthor, + onOpenThread, + isQuote, + } = props; + const timestamp = timeAgo(tweet.time); + + return ( + <article + className="tweet-card" + lang={tweet.language} + style={{ borderColor: accent }} + > + {tweet.retweeted_by && ( + <p + onClick={() => onOpenAuthor?.(tweet.retweeted_by!.author)} + className="muted tiny retweet-banner" + > + Retweeted by {tweet.retweeted_by.author.name} + <span>{timeAgo(tweet.retweeted_by.time)}</span> + </p> + )} + <header> + <div className="author"> + <img + src={tweet.author.avatar} + alt={tweet.author.name} + loading="lazy" + /> + <button + type="button" + className="link-button author-meta" + onClick={() => onOpenAuthor?.(tweet.author)} + title={`View @${tweet.author.username}`} + disabled={!onOpenAuthor} + > + <strong>{tweet.author.name}</strong> + <span className="muted">@{tweet.author.username}</span> + </button> + </div> + <div className="meta"> + <button + type="button" + className="link-button muted" + onClick={() => onOpenThread?.(tweet)} + title="Open thread" + disabled={!onOpenThread} + > + {timestamp} + </button> + {allowBookmarkRemoval && onRemoveBookmark && ( + <button + className="ghost" + onClick={() => onRemoveBookmark(tweet.id)} + > + Remove + </button> + )} + </div> + </header> + {isQuote && tweet.replyingTo.length > 0 && ( + <div className="tweet-replying-to"> + <span>replying to</span> + {tweet.replyingTo.map((rt) => ( + <span key={rt.username}>@{rt.username}</span> + ))} + </div> + )} + <div className="tweet-body"> + <div className="tweet-text"> + <LangText lang={tweet.language} text={tweet.text} /> + {/*renderText(tweet.text)}*/} + </div> + {!!tweet.media.pics.length && ( + <div + className={`media-grid pics-${Math.min(tweet.media.pics.length, 4)}`} + > + {tweet.media.pics.map((pic) => ( + <img key={pic} src={pic} alt="Tweet media" loading="lazy" /> + ))} + </div> + )} + {tweet.media.video.url && ( + <div className="video-wrapper"> + <video controls preload="none" poster={tweet.media.video.thumb}> + <source src={tweet.media.video.url} /> + </video> + </div> + )} + {!!tweet.urls.length && ( + <div className="link-chips"> + {tweet.urls.map((link) => ( + <a + key={link.expandedUrl} + href={link.expandedUrl} + target="_blank" + rel="noreferrer" + > + {link.displayUrl} + </a> + ))} + </div> + // end body + )} + {tweet.quoting && <Quote tweet={tweet.quoting} />} + </div> + {!isQuote && <Actions {...props} />} + </article> + ); +} + +type Token = { type: "text" | "mention" | "hashtag" | "url"; value: string }; + +function tokenize(text: string): Token[] { + const tokens: Token[] = []; + const regex = /(https?:\/\/\S+|@[A-Za-z0-9_]+|#[A-Za-z0-9_]+)/g; + let lastIndex = 0; + for (const match of text.matchAll(regex)) { + const value = match[0]; + const index = match.index ?? 0; + if (index > lastIndex) { + tokens.push({ type: "text", value: text.slice(lastIndex, index) }); + } + if (value.startsWith("http")) { + tokens.push({ type: "url", value }); + } else if (value.startsWith("@")) { + tokens.push({ type: "mention", value }); + } else if (value.startsWith("#")) { + tokens.push({ type: "hashtag", value }); + } + lastIndex = index + value.length; + } + if (lastIndex < text.length) { + tokens.push({ type: "text", value: text.slice(lastIndex) }); + } + return tokens; +} + +function renderText(text: string) { + return tokenize(text).map((token, index) => { + if (token.type === "text") { + return token.value.split("\n").map((segment, segmentIndex, arr) => ( + <span key={`${index}-${segmentIndex}`}> + {segment} + {segmentIndex < arr.length - 1 ? <br /> : null} + </span> + )); + } + if (token.type === "url") { + return ( + <a key={index} href={token.value} target="_blank" rel="noreferrer"> + {token.value} + </a> + ); + } + if (token.type === "mention") { + return ( + <span key={index} className="mention"> + {token.value} + </span> + ); + } + return ( + <span key={index} className="hashtag"> + {token.value} + </span> + ); + }); +} + +function Actions(props: TweetCardProps) { + const { tweet, onReply, onToggleRetweet, onToggleLike, onToggleBookmark } = + props; + + const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`; + const [copied, setCopied] = useState(false); + const [pendingAction, setPendingAction] = useState<ActionKind>(null); + + const copyLink = useCallback(async () => { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(tweetUrl); + } else if (typeof document !== "undefined") { + const textarea = document.createElement("textarea"); + textarea.value = tweetUrl; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } catch (error) { + console.warn("Failed to copy tweet link", error); + } + }, [tweetUrl]); + + const executeAction = useCallback( + async (kind: ActionKind, fn?: () => Promise<unknown>) => { + if (!fn) return; + setPendingAction(kind); + try { + await fn(); + } catch (error) { + console.error(`Failed to perform ${kind ?? "action"}`, error); + } finally { + setPendingAction(null); + } + }, + [], + ); + + const handleReply = useCallback(() => { + if (!onReply || typeof window === "undefined") return; + const prefill = `@${tweet.author.username} `; + const text = window.prompt("Reply", prefill); + if (!text || !text.trim()) return; + executeAction("reply", () => onReply(tweet.id, text.trim())); + }, [executeAction, onReply, tweet.author.username, tweet.id]); + + const handleRetweet = useCallback(() => { + if (!onToggleRetweet) return; + const next = !tweet.rted; + executeAction("retweet", () => onToggleRetweet(tweet.id, next)); + }, [executeAction, onToggleRetweet, tweet.id, tweet.rted]); + + const handleLike = useCallback(() => { + if (!onToggleLike) return; + const next = !tweet.liked; + executeAction("like", () => onToggleLike(tweet.id, next)); + }, [executeAction, onToggleLike, tweet.id, tweet.liked]); + + const handleBookmark = useCallback(() => { + if (!onToggleBookmark) return; + const next = !tweet.bookmarked; + executeAction("bookmark", () => onToggleBookmark(tweet.id, next)); + }, [executeAction, onToggleBookmark, tweet.bookmarked, tweet.id]); + + return ( + <footer className="tweet-actions"> + <button + type="button" + className={`action ${pendingAction === "reply" ? "in-flight" : ""}`} + aria-label="Reply" + title="Reply" + disabled={pendingAction === "reply"} + onClick={handleReply} + > + <MessageCircle /> + <span className="sr-only">Reply</span> + </button> + <button + type="button" + className={`action retweet ${tweet.rted ? "active" : ""} ${pendingAction === "retweet" ? "in-flight" : ""}`} + aria-label={tweet.rted ? "Undo Retweet" : "Retweet"} + aria-pressed={tweet.rted} + title={tweet.rted ? "Undo Retweet" : "Retweet"} + disabled={pendingAction === "retweet"} + onClick={handleRetweet} + > + <Repeat2 /> + <span className="sr-only">Retweet</span> + </button> + <button + type="button" + className={`action like ${tweet.liked ? "active" : ""} ${pendingAction === "like" ? "in-flight" : ""}`} + aria-label={tweet.liked ? "Undo like" : "Like"} + aria-pressed={tweet.liked} + title={tweet.liked ? "Undo like" : "Like"} + disabled={pendingAction === "like"} + onClick={handleLike} + > + <Heart /> + <span className="sr-only">Like</span> + </button> + <button + type="button" + className={`action bookmark ${tweet.bookmarked ? "active" : ""} ${pendingAction === "bookmark" ? "in-flight" : ""}`} + aria-label={tweet.bookmarked ? "Remove bookmark" : "Bookmark"} + aria-pressed={tweet.bookmarked} + title={tweet.bookmarked ? "Remove bookmark" : "Bookmark"} + disabled={pendingAction === "bookmark"} + onClick={handleBookmark} + > + <Bookmark /> + <span className="sr-only">Bookmark</span> + </button> + <button + type="button" + className={`action ${copied ? "copied" : ""}`} + aria-label="Copy link" + title="Copy link" + onClick={copyLink} + > + <Link2 /> + <span className="sr-only">Copy link</span> + </button> + </footer> + ); +} + +function Quote({ tweet }: { tweet: Tweet }) { + return ( + <div className="tweet-quote"> + <TweetCard tweet={tweet} accent="" isQuote={true} /> + </div> + ); +} diff --git a/packages/tweetdeck/src/frontend.tsx b/packages/tweetdeck/src/frontend.tsx new file mode 100644 index 0000000..5691535 --- /dev/null +++ b/packages/tweetdeck/src/frontend.tsx @@ -0,0 +1,26 @@ +/** + * This file is the entry point for the React app, it sets up the root + * element and renders the App component to the DOM. + * + * It is included in `src/index.html`. + */ + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const elem = document.getElementById("root")!; +const app = ( + // <StrictMode> + <App /> + // </StrictMode> +); + +if (import.meta.hot) { + // With hot module reloading, `import.meta.hot.data` is persisted. + const root = (import.meta.hot.data.root ??= createRoot(elem)); + root.render(app); +} else { + // The hot module reloading API is not available in production. + createRoot(elem).render(app); +} diff --git a/packages/tweetdeck/src/hooks/usePersistentState.ts b/packages/tweetdeck/src/hooks/usePersistentState.ts new file mode 100644 index 0000000..7465f53 --- /dev/null +++ b/packages/tweetdeck/src/hooks/usePersistentState.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from "react"; + +type Initializer<T> = T | (() => T); + +const isBrowser = typeof window !== "undefined"; + +function readFromStorage<T>(key: string, fallback: Initializer<T>): T { + if (!isBrowser) { + return typeof fallback === "function" ? (fallback as () => T)() : fallback; + } + try { + const raw = window.localStorage.getItem(key); + if (raw) { + return JSON.parse(raw) as T; + } + } catch (error) { + console.warn("Failed to parse localStorage value", { key, error }); + } + return typeof fallback === "function" ? (fallback as () => T)() : fallback; +} + +export function usePersistentState<T>(key: string, initial: Initializer<T>) { + const initialRef = useRef<T | null>(null); + if (initialRef.current === null) { + initialRef.current = readFromStorage<T>(key, initial); + } + const [value, setValue] = useState<T>(() => initialRef.current as T); + + useEffect(() => { + if (!isBrowser) return; + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn("Failed to write localStorage value", { key, error }); + } + }, [key, value]); + + return [value, setValue] as const; +} diff --git a/packages/tweetdeck/src/index.html b/packages/tweetdeck/src/index.html new file mode 100644 index 0000000..fa411d2 --- /dev/null +++ b/packages/tweetdeck/src/index.html @@ -0,0 +1,16 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <link rel="icon" type="image/svg+xml" href="./logo.svg" /> + <title>Sordeck</title> +</head> + +<body> + <div id="root"></div> + <script type="module" src="./frontend.tsx"></script> +</body> + +</html>
\ No newline at end of file diff --git a/packages/tweetdeck/src/index.ts b/packages/tweetdeck/src/index.ts new file mode 100644 index 0000000..ccc86e7 --- /dev/null +++ b/packages/tweetdeck/src/index.ts @@ -0,0 +1,242 @@ +import { serve } from "bun"; +import index from "./index.html"; +import { TwitterApiService } from "./lib/fetching/twitter-api"; + +const jsonResponse = (data: unknown, init?: ResponseInit) => + Response.json(data, init); + +async function withTwitterService( + req: Request, + handler: ( + service: TwitterApiService, + payload: Record<string, any>, + ) => Promise<Response>, +) { + try { + const payload = await req.json(); + const cookie = payload?.cookie; + + if (!cookie || typeof cookie !== "string") { + return jsonResponse( + { error: "Missing twitter auth cookie" }, + { status: 400 }, + ); + } + + const service = new TwitterApiService(cookie); + return await handler(service, payload); + } catch (error) { + console.error("Twitter API route error", error); + return jsonResponse( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 }, + ); + } +} + +const server = serve({ + port: 3010, + routes: { + // Serve index.html for all unmatched routes. + "/*": index, + + "/api/hello": { + async GET(req) { + return Response.json({ + message: "Hello, world!", + method: "GET", + }); + }, + async PUT(req) { + return Response.json({ + message: "Hello, world!", + method: "PUT", + }); + }, + }, + + "/api/hello/:name": async (req) => { + const name = req.params.name; + return Response.json({ + message: `Hello, ${name}!`, + }); + }, + + "/api/twitter/our": { + async POST(req) { + return withTwitterService(req, async (service, _payload) => { + return jsonResponse(await service.findOwn()); + }); + }, + }, + "/api/twitter/timeline/:mode": { + async POST(req) { + const { mode } = req.params; + console.log("fetching tweets", mode); + return withTwitterService(req, async (service, payload) => { + const cursor = + typeof payload.cursor === "string" ? payload.cursor : undefined; + switch (mode) { + case "foryou": + return jsonResponse(await service.fetchForyou(cursor)); + case "following": + return jsonResponse(await service.fetchFollowing(cursor)); + case "bookmarks": + return jsonResponse(await service.fetchBookmarks(cursor)); + case "list": + if (!payload.listId) { + return jsonResponse( + { error: "Missing listId" }, + { status: 400 }, + ); + } + return jsonResponse( + await service.fetchList(String(payload.listId), cursor), + ); + case "user": + if (!payload.userId) { + return jsonResponse( + { error: "Missing userId" }, + { status: 400 }, + ); + } + return jsonResponse( + await service.fetchUserTweets(String(payload.userId), cursor), + ); + case "thread": + if (!payload.tweetId) { + return jsonResponse( + { error: "Missing tweetId" }, + { status: 400 }, + ); + } + return jsonResponse( + await service.fetchThread(String(payload.tweetId), cursor), + ); + default: + return jsonResponse( + { error: `Unknown timeline mode: ${mode}` }, + { status: 400 }, + ); + } + }); + }, + }, + + "/api/twitter/lists": { + async POST(req) { + return withTwitterService(req, async (service) => { + return jsonResponse(await service.fetchLists()); + }); + }, + }, + + "/api/twitter/notifications": { + async POST(req) { + return withTwitterService(req, async (service, payload) => { + const cursor = + typeof payload.cursor === "string" ? payload.cursor : undefined; + return jsonResponse(await service.fetchNotifications(cursor)); + }); + }, + }, + + "/api/twitter/bookmarks/remove": { + async POST(req) { + return withTwitterService(req, async (service, payload) => { + const tweetId = payload?.tweetId; + if (!tweetId) { + return jsonResponse({ error: "Missing tweetId" }, { status: 400 }); + } + await service.removeBookmark(String(tweetId)); + return jsonResponse({ status: "ok" }); + }); + }, + }, + + "/api/twitter/tweets/:tweetId/like": { + async POST(req) { + const { tweetId } = req.params; + return withTwitterService(req, async (service, payload) => { + if (!tweetId) { + return jsonResponse({ error: "Missing tweetId" }, { status: 400 }); + } + const undo = Boolean(payload?.undo); + if (undo) { + await service.removeLike(tweetId); + } else { + await service.addLike(tweetId); + } + return jsonResponse({ status: "ok" }); + }); + }, + }, + + "/api/twitter/tweets/:tweetId/retweet": { + async POST(req) { + const { tweetId } = req.params; + return withTwitterService(req, async (service, payload) => { + if (!tweetId) { + return jsonResponse({ error: "Missing tweetId" }, { status: 400 }); + } + const undo = Boolean(payload?.undo); + if (undo) { + await service.removeRT(tweetId); + } else { + await service.addRT(tweetId); + } + return jsonResponse({ status: "ok" }); + }); + }, + }, + + "/api/twitter/tweets/:tweetId/bookmark": { + async POST(req) { + const { tweetId } = req.params; + return withTwitterService(req, async (service, payload) => { + if (!tweetId) { + return jsonResponse({ error: "Missing tweetId" }, { status: 400 }); + } + const undo = Boolean(payload?.undo); + if (undo) { + await service.removeBookmark(tweetId); + } else { + await service.addBookmark(tweetId); + } + return jsonResponse({ status: "ok" }); + }); + }, + }, + + "/api/twitter/tweets/:tweetId/reply": { + async POST(req) { + const { tweetId } = req.params; + return withTwitterService(req, async (service, payload) => { + if (!tweetId) { + return jsonResponse({ error: "Missing tweetId" }, { status: 400 }); + } + const text = + typeof payload?.text === "string" ? payload.text.trim() : ""; + if (!text) { + return jsonResponse( + { error: "Missing reply text" }, + { status: 400 }, + ); + } + await service.createTweet(text, { reply: tweetId }); + return jsonResponse({ status: "ok" }); + }); + }, + }, + }, + + development: process.env.NODE_ENV !== "production" && { + // Enable browser hot reloading in development + hmr: true, + + // Echo console logs from the browser to the server + console: true, + }, +}); + +console.log(`🚀 Server running at ${server.url}`); diff --git a/packages/tweetdeck/src/lib/client/twitterClient.ts b/packages/tweetdeck/src/lib/client/twitterClient.ts new file mode 100644 index 0000000..b8914b5 --- /dev/null +++ b/packages/tweetdeck/src/lib/client/twitterClient.ts @@ -0,0 +1,75 @@ +import { + type TwitterUser, + type TweetList, + type TwitterList, + type TwitterNotification, +} from "../fetching/types"; + +const headers = { "Content-Type": "application/json" }; + +async function postJson<T>( + url: string, + body: Record<string, unknown>, +): Promise<T> { + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } + + return (await res.json()) as T; +} + +export const twitterClient = { + own(payload: Record<string, unknown>) { + // return postJson<TwitterUser>(`/api/twitter/our`, payload); + }, + timeline(mode: string, payload: Record<string, unknown>) { + console.log("fetching tweets", mode); + return postJson<TweetList>(`/api/twitter/timeline/${mode}`, payload); + }, + lists(payload: Record<string, unknown>) { + return postJson<TwitterList[]>("/api/twitter/lists", payload); + }, + notifications(payload: Record<string, unknown>) { + return postJson<TwitterNotification[]>( + "/api/twitter/notifications", + payload, + ); + }, + removeBookmark(payload: Record<string, unknown>) { + return postJson<{ status: string }>( + "/api/twitter/bookmarks/remove", + payload, + ); + }, + like(tweetId: string, payload: Record<string, unknown>) { + return postJson<{ status: string }>( + `/api/twitter/tweets/${tweetId}/like`, + payload, + ); + }, + retweet(tweetId: string, payload: Record<string, unknown>) { + return postJson<{ status: string }>( + `/api/twitter/tweets/${tweetId}/retweet`, + payload, + ); + }, + bookmark(tweetId: string, payload: Record<string, unknown>) { + return postJson<{ status: string }>( + `/api/twitter/tweets/${tweetId}/bookmark`, + payload, + ); + }, + reply(tweetId: string, payload: Record<string, unknown>) { + return postJson<{ status: string }>( + `/api/twitter/tweets/${tweetId}/reply`, + payload, + ); + }, +}; diff --git a/packages/tweetdeck/src/lib/fetching/python.ts b/packages/tweetdeck/src/lib/fetching/python.ts new file mode 100644 index 0000000..760cb2c --- /dev/null +++ b/packages/tweetdeck/src/lib/fetching/python.ts @@ -0,0 +1,52 @@ +import python from "bun_python"; + +export class TransactionIdGenerator { + private initialHtmlContent!: string; + private client_transaction: any; + private cookie: string; + private headers: any; + + private BeautifulSoup: any; + private ClientTransaction: any; + + constructor(cookie: string) { + this.cookie = cookie; + } + public async init() { + const genheaders = await python.import("x_client_transaction.utils") + .generate_headers; + const hs = genheaders(); + this.headers = { ...hs, Cookie: this.cookie }; + const currentUrl = "https://x.com"; + const response = await fetch(currentUrl, { headers: this.headers }); + const html = await response.text(); + this.initialHtmlContent = html; + } + + public async getTransactionId(method: string, path: string): Promise<string> { + if (!this.BeautifulSoup || !this.ClientTransaction) { + this.BeautifulSoup = await python.import("bs4").BeautifulSoup; + this.ClientTransaction = await python.import("x_client_transaction") + .ClientTransaction; + } + + if (!this.client_transaction) { + const soup = this.BeautifulSoup(this.initialHtmlContent, "lxml"); + const onDemand = await python.import("x_client_transaction.utils") + .get_ondemand_file_url; + const file = onDemand(soup); + const ondemand_res = await fetch(file, { + method: "GET", + headers: this.headers, + }); + const ondemand_text = await ondemand_res.text(); + this.client_transaction = this.ClientTransaction(soup, ondemand_text); + } + + const transaction_id = this.client_transaction.generate_transaction_id( + method, + path, + ); + return transaction_id; + } +} diff --git a/packages/tweetdeck/src/lib/fetching/twitter-api.ts b/packages/tweetdeck/src/lib/fetching/twitter-api.ts new file mode 100644 index 0000000..8ea4709 --- /dev/null +++ b/packages/tweetdeck/src/lib/fetching/twitter-api.ts @@ -0,0 +1,1178 @@ +import type { + Tweet, + TweetList, + TweetResult, + TwitterBookmarkResponse, + TwitterList, + TwitterListTimelineResponse, + TwitterListsManagementResponse, + TwitterNotification, + TwitterNotificationsTimelineResponse, + TimelineEntry, + TwitterTimelineResponse, + TwitterTweetDetailResponse, + TwitterUserTweetsResponse, + APITwitterList, + TweetWithVisibilityResult, + TwitterUser, + RTMetadata, + TwitterProfilesResponse, + UserResult, +} from "./types"; +import { TransactionIdGenerator } from "./python"; + +const TWITTER_INTERNAL_API_KEY = + "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"; + +export class TwitterApiService { + cookie: string; + constructor(cookie: string) { + this.cookie = cookie; + } + // Read endpoints + private static readonly BOOKMARKS_URL = new URL( + "https://x.com/i/api/graphql/C7CReOA1R0PwKorWAxnNUQ/Bookmarks", + ); + private static readonly FOLLOWING_URL = new URL( + "https://x.com/i/api/graphql/fhqL7Cgmvax9jOhRMOhWpA/HomeLatestTimeline", + ); + private static readonly FORYOU_URL = new URL( + "https://x.com/i/api/graphql/sMNeM4wvNe4JnRUZ2jd2zw/HomeTimeline", + ); + private static readonly LISTS_URL = new URL( + "https://x.com/i/api/graphql/wLXb5F6pIEOrYtTjXFLQsA/ListsManagementPageTimeline", + ); + private static readonly LIST_URL = new URL( + "https://x.com/i/api/graphql/p-5fXSlJaR-aZ4UUBdPMAg/ListLatestTweetsTimeline", + ); + private static readonly NOTIFICATIONS_URL = new URL( + "https://api.x.com/1.1/notifications/timeline.json", + ); + private static readonly USERDATA_URL = new URL( + "https://x.com/i/api/graphql/2AtIgw7Kz26sV6sEBrQjSQ/UsersByRestIds", + ); + private static readonly USER_URL = new URL( + "https://x.com/i/api/graphql/Le1DChzkS7ioJH_yEPMi3w/UserTweets", + ); + private static readonly THREAD_URL = new URL( + "https://x.com/i/api/graphql/aTYmkYpjWyvUyrinVWSiYA/TweetDetail", + ); + + private static readonly HEADERS = { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36", + Accept: "*/*", + Referer: "https://x.com/i/bookmarks", + "Content-Type": "application/json", + "X-Twitter-Auth-Type": "OAuth2Session", + "X-Twitter-Active-User": "yes", + "X-Twitter-Client-Language": "en", + }; + + private get csrfToken() { + return this.cookie.match(/ct0=([^;]+)/)?.[1]; + } + + private buildHeaders(extra?: Record<string, string>) { + const headers: Record<string, string> = { + ...TwitterApiService.HEADERS, + Authorization: TWITTER_INTERNAL_API_KEY, + Cookie: this.cookie, + ...(extra ?? {}), + }; + const csrf = this.csrfToken; + if (csrf) headers["X-Csrf-Token"] = csrf; + return headers; + } + + private async request(url: URL, init: RequestInit) { + const headers = this.buildHeaders(init.headers as Record<string, string>); + + const xcs = new TransactionIdGenerator(""); + await xcs.init(); + const xclientid = await xcs.getTransactionId(init.method!, url.pathname); + headers["X-Client-Transaction-Id"] = xclientid; + const response = await fetch(url, { ...init, headers }); + if (!response.ok) { + console.log(headers); + console.log(response); + throw new Error( + `Twitter API request failed: ${response.status} ${response.statusText}`, + ); + } + + return await response.json(); + } + async postCall(url: URL, payload: Record<string, unknown>) { + const body = JSON.stringify(payload); + return this.request(url, { + method: "POST", + body, + headers: { "Content-Type": "application/json" }, + }); + } + + async getCall(url: URL) { + return this.request(url, { method: "GET" }); + } + async findOwn(): Promise<TwitterUser> { + const cookie = decodeURIComponent(this.cookie); + const match = cookie.match(/twid=u=([^;]+)/); + const id = match![1]!; + const profs = await this.fetchProfiles([id]); + return profs[0]!; + } + async fetchProfiles(userIds: string[]): Promise<TwitterUser[]> { + const variables = { + userIds, + }; + const features = { + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: false, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + }; + const url = new URL(TwitterApiService.USERDATA_URL); + url.searchParams.set("variables", JSON.stringify(variables)); + url.searchParams.set("features", JSON.stringify(features)); + + const data = (await this.getCall(url)) as TwitterProfilesResponse; + const users = data?.data?.users; + if (!users) throw new Error("error parsing ids"); + const parsed = users.map((u) => + TwitterApiService.extractUserData(u.result), + ); + return parsed; + } + async fetchForyou(cursor?: string): Promise<TweetList> { + const payload = { + variables: { + count: 50, + cursor: cursor || "DAABCgABGv-ytnDAJxEKAAIa_qVgidrhcwgAAwAAAAEAAA", + includePromotedContent: true, + latestControlAvailable: true, + withCommunity: true, + seenTweetIds: [], + }, + features: { + rweb_video_screen_enabled: false, + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }, + queryId: "sMNeM4wvNe4JnRUZ2jd2zw", + }; + const data = (await this.postCall( + TwitterApiService.FORYOU_URL, + payload, + )) as TwitterTimelineResponse; + try { + return TwitterApiService.parseTimelineResponse(data, "foryou"); + } catch (e) { + console.error(e); + console.dir(data, { depth: null }); + throw new Error("wtf"); + } + } + + async fetchFollowing(cursor?: string) { + const payload = { + variables: { + count: 50, + cursor: cursor || "DAABCgABGv-ytnDAJxEKAAIa_qVgidrhcwgAAwAAAAEAAA", + includePromotedContent: true, + latestControlAvailable: true, + requestContext: "launch", + seenTweetIds: [], + }, + features: { + rweb_video_screen_enabled: false, + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }, + queryId: "fhqL7Cgmvax9jOhRMOhWpA", + }; + const data = (await this.postCall( + TwitterApiService.FOLLOWING_URL, + payload, + )) as TwitterTimelineResponse; + try { + return TwitterApiService.parseTimelineResponse(data, "following"); + } catch (e) { + console.error(e); + console.dir(data, { depth: null }); + throw new Error("wtf"); + } + } + + async fetchList(listId: string, cursor?: string): Promise<TweetList> { + const variables = { listId, count: 20, cursor }; + const features = { + rweb_video_screen_enabled: false, + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; + const url = new URL(TwitterApiService.LIST_URL); + url.searchParams.set("variables", JSON.stringify(variables)); + url.searchParams.set("features", JSON.stringify(features)); + const data = (await this.getCall(url)) as TwitterListTimelineResponse; + return TwitterApiService.parseListTimelineResponse(data); + } + async fetchLists(): Promise<TwitterList[]> { + const variables = { count: 100 }; + + const features = { + rweb_video_screen_enabled: false, + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; + const url = new URL(TwitterApiService.LISTS_URL); + url.searchParams.set("variables", JSON.stringify(variables)); + url.searchParams.set("features", JSON.stringify(features)); + const data = (await this.getCall(url)) as TwitterListsManagementResponse; + try { + return TwitterApiService.parseListsManagementResponse(data); + } catch (e) { + console.error(e); + // console.dir(data.data.viewer.list_management_timeline, { depth: null }); + throw e; + } + } + + async fetchNotifications(cursor?: string): Promise<TwitterNotification[]> { + const variables: Record<string, string | number> = { + include_profile_interstitial_type: 1, + include_blocking: 1, + include_blocked_by: 1, + include_followed_by: 1, + include_want_retweets: 1, + include_mute_edge: 1, + include_can_dm: 1, + include_can_media_tag: 1, + include_ext_has_nft_avatar: 1, + include_ext_is_blue_verified: 1, + include_ext_verified_type: 1, + include_ext_profile_image_shape: 1, + skip_status: 1, + cards_platform: "Web-12", + include_cards: 1, + include_composer_source: "true", + include_ext_alt_text: "true", + include_ext_limited_action_results: "false", + include_reply_count: 1, + tweet_mode: "extended", + include_entities: "true", + include_user_entities: "true", + include_ext_media_color: "true", + include_ext_media_availability: "true", + include_ext_sensitive_media_warning: "true", + include_ext_trusted_friends_metadata: "true", + send_error_codes: "true", + simple_quoted_tweet: "true", + count: 40, + ext: "mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,enrichments,superFollowMetadata,unmentionInfo,editControl,vibe", + }; + if (cursor) { + variables.cursor = cursor; + } + const url = new URL(TwitterApiService.NOTIFICATIONS_URL); + Object.keys(variables).forEach((key) => + url.searchParams.set(key, variables[key]!.toString()), + ); + + const data = (await this.getCall( + url, + )) as TwitterNotificationsTimelineResponse; + + return TwitterApiService.parseNotificationsResponse(data); + } + async fetchUserTweets(userId: string, cursor?: string): Promise<TweetList> { + const variables = { + userId, + count: 50, + includePromotedContent: true, + withQuickPromoteEligibilityTweetFields: true, + withVoice: true, + }; + const features = { + rweb_video_screen_enabled: false, + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; + const fieldToggles = { withArticlePlainText: true }; + + const url = new URL(TwitterApiService.USER_URL); + url.searchParams.set("variables", JSON.stringify(variables)); + url.searchParams.set("features", JSON.stringify(features)); + url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles)); + const data = (await this.getCall(url)) as TwitterUserTweetsResponse; + + try { + return TwitterApiService.parseUserTweetsResponse(data); + } catch (e) { + console.error(e); + console.dir(data, { depth: null }); + throw new Error("bad"); + } + } + async fetchThread(tweetId: string, cursor?: string): Promise<TweetList> { + const variables = { + focalTweetId: tweetId, + referrer: "profile", + with_rux_injections: false, + rankingMode: "Relevance", + includePromotedContent: true, + withCommunity: true, + withQuickPromoteEligibilityTweetFields: true, + withBirdwatchNotes: true, + withVoice: true, + }; + const features = { + rweb_video_screen_enabled: false, + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; + const fieldToggles = { + withArticleRichContentState: true, + withArticlePlainText: true, + withGrokAnalyze: true, + withDisallowedReplyControls: false, + }; + + const url = new URL(TwitterApiService.THREAD_URL); + url.searchParams.set("variables", JSON.stringify(variables)); + url.searchParams.set("features", JSON.stringify(features)); + url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles)); + const data = (await this.getCall(url)) as TwitterTweetDetailResponse; + + try { + return TwitterApiService.parseThreadResponse(data); + } catch (e) { + console.error(e); + console.dir(data, { depth: null }); + throw new Error("Bad"); + } + } + async fetchBookmarks(cursor?: string): Promise<TweetList> { + const variables = { + count: 40, + cursor: cursor || null, + includePromotedContent: true, + }; + + const features = { + rweb_video_screen_enabled: false, + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_enhance_cards_enabled: false, + }; + const url = new URL(TwitterApiService.BOOKMARKS_URL); + url.searchParams.set("variables", JSON.stringify(variables)); + url.searchParams.set("features", JSON.stringify(features)); + const data = (await this.getCall(url)) as TwitterBookmarkResponse; + + return TwitterApiService.parseBookmarkResponse(data); + } + + private static parseUserTweetsResponse( + response: TwitterUserTweetsResponse, + ): TweetList { + const tweets: Tweet[] = []; + let cursorBottom = ""; + let cursorTop = ""; + const instructions = + response.data?.user?.result?.timeline?.timeline?.instructions; + if (!instructions || !Array.isArray(instructions)) + throw new Error("error parsing user feed"); + + for (const instruction of instructions) { + if (instruction.type === "TimelineAddEntries") { + if (!instruction.entries || !Array.isArray(instruction.entries)) + throw new Error("error parsing user feed"); + for (const entry of instruction.entries) { + if (entry.content?.entryType === "TimelineTimelineItem") { + const tweetData = entry.content.itemContent?.tweet_results?.result; + if (tweetData) { + const tweet = this.extractTweetData(tweetData); + if (tweet) { + tweets.push(tweet); + } + } + } else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Bottom" + ) + cursorBottom = entry.content?.value || ""; + else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Top" + ) + cursorTop = entry.content?.value || ""; + } + } + } + + return { tweets, cursorBottom, cursorTop }; + } + + private static parseThreadResponse( + response: TwitterTweetDetailResponse, + ): TweetList { + const tweets: Tweet[] = []; + let cursorBottom = ""; + let cursorTop = ""; + + const instructions = + response.data?.threaded_conversation_with_injections_v2?.instructions; + if (!instructions || !Array.isArray(instructions)) + throw new Error("error parsing thread "); + + for (const instruction of instructions) { + if (instruction.type === "TimelineAddEntries") { + if (!instruction.entries || !Array.isArray(instruction.entries)) + throw new Error("error parsing thread feed"); + for (const entry of instruction.entries) { + if (entry.content?.entryType === "TimelineTimelineItem") { + const tweetData = entry.content.itemContent?.tweet_results?.result; + if (tweetData) { + const tweet = this.extractTweetData(tweetData); + if (tweet) { + tweets.push(tweet); + } + } + } else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Bottom" + ) + cursorBottom = entry.content?.value || ""; + else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Top" + ) + cursorTop = entry.content?.value || ""; + } + } + } + return { tweets, cursorBottom, cursorTop }; + } + + private static parseBookmarkResponse( + response: TwitterBookmarkResponse, + ): TweetList { + const tweets: Tweet[] = []; + let cursorBottom = ""; + let cursorTop = ""; + + const instructions = + response.data?.bookmark_timeline_v2?.timeline?.instructions || []; + + for (const instruction of instructions) { + if ( + instruction.type === "TimelineAddEntries" || + instruction.type === "TimelineReplaceEntry" || + instruction.type === "TimelineShowMoreEntries" + ) { + const entries = [ + ...(instruction.entries || []), + ...(((instruction as { entry?: TimelineEntry }).entry + ? [(instruction as { entry?: TimelineEntry }).entry!] + : []) as TimelineEntry[]), + ]; + + for (const entry of entries) { + const content = entry.content; + + if (content?.entryType === "TimelineTimelineItem") { + const tweetData = content.itemContent?.tweet_results?.result; + if (tweetData) { + const bookmark = this.extractTweetData(tweetData); + if (bookmark) { + tweets.push(bookmark); + } + } else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Bottom" + ) + cursorBottom = entry.content?.value || ""; + else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Top" + ) + cursorTop = entry.content?.value || ""; + } + } + } + } + + return { + tweets, + cursorTop, + cursorBottom, + }; + } + + private static parseTimelineResponse( + response: TwitterTimelineResponse, + type: "foryou" | "following", + ): TweetList { + const tweets: Tweet[] = []; + let cursorBottom = ""; + let cursorTop = ""; + + const instructions = response.data?.home?.home_timeline_urt?.instructions; + response.data?.home_timeline_urt?.instructions; + + if (!instructions || !Array.isArray(instructions)) + throw new Error("error parsing thread "); + + for (const instruction of instructions) { + if (instruction.type === "TimelineAddEntries") { + if (!instruction.entries || !Array.isArray(instruction.entries)) + throw new Error("error parsing thread feed"); + + for (const entry of instruction.entries) { + // if (entry.content.entryType.includes("ursor")) console.log(entry); + // if (entry.content.entryType.includes("odule")) + // console.log("module", entry); + + if (entry.content?.entryType === "TimelineTimelineItem") { + const tweetData = entry.content?.itemContent?.tweet_results?.result; + if (tweetData) { + try { + const tweet = this.extractTweetData(tweetData); + tweets.push(tweet); + } catch (e) { + console.error(e); + // console.dir(entry, { depth: null }); + } + } + } else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Bottom" + ) + cursorBottom = entry.content?.value || ""; + else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Gap" // TODO wtf??? + ) + cursorBottom = entry.content?.value || ""; + else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Top" + ) + cursorTop = entry.content?.value || ""; + } + } + } + + return { tweets, cursorTop, cursorBottom }; + } + + private static parseListTimelineResponse( + response: TwitterListTimelineResponse, + ): TweetList { + const tweets: Tweet[] = []; + let cursorBottom = ""; + let cursorTop = ""; + + const instructions = + response.data?.list?.tweets_timeline?.timeline?.instructions; + if (!instructions || !Array.isArray(instructions)) + throw new Error("error parsing tw timeline res"); + + for (const instruction of instructions) { + if (instruction.type === "TimelineAddEntries") { + if (!instruction.entries || !Array.isArray(instruction.entries)) + throw new Error("error parsing tw timeline res"); + for (const entry of instruction.entries) { + if (entry.content?.entryType === "TimelineTimelineItem") { + const tweetData = entry.content.itemContent?.tweet_results?.result; + if (tweetData) { + const tweet = this.extractTweetData(tweetData); + tweets.push(tweet); + } + } else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Bottom" + ) + cursorBottom = entry.content?.value || ""; + else if ( + entry.content?.entryType === "TimelineTimelineCursor" && + entry.content?.cursorType === "Top" + ) + cursorTop = entry.content?.value || ""; + } + } + } + + return { tweets, cursorBottom, cursorTop }; + } + + private static parseListsManagementResponse( + response: TwitterListsManagementResponse, + ): TwitterList[] { + const lists: TwitterList[] = []; + const instructions = + response.data?.viewer?.list_management_timeline?.timeline?.instructions; + if (!instructions || !Array.isArray(instructions)) + throw new Error("error parsing tw lists res"); + for (const instruction of instructions) { + if (instruction.type === "TimelineAddEntries") { + if (!instruction?.entries || !Array.isArray(instruction.entries)) + throw new Error("error parsing tw lists res 2"); + for (const entry of instruction.entries) { + console.log("entry", entry.content.__typename); + // if (entry.content.__typename === "TimelineTimelineModule") + if (entry.content.__typename === "TimelineTimelineCursor") { + console.dir(entry, { depth: null }); + continue; + } + // entry.content.entryType can be TimelineTimelineModule, TimelineTimelineCursor, + // entry.entryId can be list-to-follow-<bignumber> which si the recommended lists that show on top + // or owned-subscribed-list-module-<smolnum> which is what we want + const listList = entry?.content?.items; + if (!listList || !Array.isArray(listList)) + throw new Error("error parsing tw lists res 3"); + for (const list of listList) { + lists.push(this.parseListResponse(list.item.itemContent.list)); + } + } + } + } + return lists; + } + + private static parseListResponse(res: APITwitterList): TwitterList { + const { name, id_str, member_count, subscriber_count } = res; + const creator = res.user_results.result.core.name; + return { name, id: id_str, member_count, subscriber_count, creator }; + } + private static parseNotificationsResponse( + response: TwitterNotificationsTimelineResponse, + ): TwitterNotification[] { + const notifications: TwitterNotification[] = []; + const timelineNotifs = + response.timeline.instructions[0]?.addEntries?.entries; + if (!timelineNotifs || !Array.isArray(timelineNotifs)) + throw new Error("error parsing notifs"); + for (const entry of timelineNotifs) { + const notificationId = entry.content.notification.id; + const notification = response.globalObjects.notifications[notificationId]; + if (notification) { + notifications.push(notification); + } + } + return notifications; + } + private static extractUserData(userResults: UserResult): TwitterUser { + return { + id: userResults.rest_id, + avatar: + userResults.avatar?.image_url || + userResults.legacy?.profile_image_url_https!, + name: userResults.legacy?.name || userResults.core?.name!, + username: + userResults.legacy?.screen_name || userResults.core?.screen_name!, + }; + } + + private static extractTweetData( + tweetRes: TweetResult | TweetWithVisibilityResult, + rter: RTMetadata | null = null, + ): Tweet { + const tweetData = + tweetRes.__typename === "Tweet" ? tweetRes : tweetRes.tweet; + + console.log({ tweetData }); + let quoting: Tweet | null = null; + let retweeted_by = rter; + const legacy = tweetData?.legacy; + const userResults = tweetData?.core?.user_results?.result; + // if (!legacy || !userResults) throw new Error("no legacy??"); + if (!legacy) throw new Error("no legacy??"); + if (!userResults) throw new Error("no userResults??"); + + const author = this.extractUserData(userResults); + const time = new Date(legacy.created_at).getTime(); + + // is_rt + if (legacy.retweeted_status_result) { + const d = legacy.retweeted_status_result.result; + if (!d) console.log("bad rt", tweetData); + return this.extractTweetData(legacy.retweeted_status_result.result, { + author, + time, + }); + } + // + + // quotes + if ( + tweetData.quoted_status_result && + tweetData.quoted_status_result.result + ) { + // const d = tweetData.quoted_status_result.result; + // if (!d) console.log("bad quote", tweetData); + quoting = this.extractTweetData(tweetData.quoted_status_result.result); + } + // + const mediaEntities = legacy.entities.media; + // if (!mediaEntities) { + // console.log("no media"); + // console.dir(legacy.entities, { depth: null }); + // } + const media = (mediaEntities || []).reduce( + ( + acc: { pics: string[]; video: { thumb: string; url: string } }, + item, + ) => { + if (item.type === "photo" && item.media_url_https) { + return { + pics: [...acc.pics, item.media_url_https], + video: acc.video, + }; + } + if (item.type === "video" && item.video_info?.variants) { + const video = item.video_info.variants.reduce( + ( + acc: { bitrate?: number; url: string }, + vid: { bitrate?: number; url: string }, + ) => { + if (!vid.bitrate) return acc; + if (!acc.bitrate || vid.bitrate > acc.bitrate) return vid; + return acc; + }, + { url: "" }, + ); + return { + pics: acc.pics, + video: { + url: video.url!, + thumb: item.media_url_https!, + }, + }; + } + return acc; + }, + { pics: [] as string[], video: { thumb: "", url: "" } }, + ); + if (legacy.full_text.includes("your computers")) + console.dir(tweetRes, { depth: null }); + const replyingTo = legacy.entities?.user_mentions + ? legacy.entities.user_mentions.map((m) => ({ + name: m.name, + username: m.screen_name, + id: m.id_str, + })) + : []; + + return { + id: tweetData.rest_id, + text: legacy.display_text_range + ? legacy.full_text.slice( + legacy.display_text_range[0], + legacy.display_text_range[1] + 1, + ) + : legacy.full_text!, + language: legacy.lang || "en", + author, + time, + urls: + legacy.entities?.urls?.map( + (url: { expanded_url: string; display_url: string }) => ({ + expandedUrl: url.expanded_url, + displayUrl: url.display_url, + }), + ) || [], + media, + hashtags: + legacy.entities?.hashtags?.map((tag: { text: string }) => tag.text) || + [], + quoting, + retweeted_by, + liked: legacy.favorited, + bookmarked: legacy.bookmarked, + rted: legacy.retweeted, + replyingTo, + }; + } + + async fetchAllBookmarks(): Promise<Tweet[]> { + const allBookmarks: Tweet[] = []; + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + try { + const result = await this.fetchBookmarks(cursor); + allBookmarks.push(...result.tweets); + cursor = result.cursorBottom; + + // Rate limiting - be nice to Twitter's API + await new Promise((resolve) => setTimeout(resolve, 1000)); + } catch (error) { + console.error("Error fetching bookmarks batch:", error); + break; + } + } + + return allBookmarks; + } + // WRITE ENDPOINTS + // TODO Grok stuff + // + // TODO add images, polls etc. + // quote is the URL https;//x.com/{user}/status/{id} of the quoted tweet; + async createTweet(text: string, topts: { quote?: string; reply?: string }) { + const queryId = `-fU2A9SG7hdlzUdOh04POw`; + const url = `https://x.com/i/api/graphql/${queryId}/createTweet`; + let variables: any = { + tweet_text: text, + dark_request: false, + media: { + media_entities: [], + possibly_sensitive: false, + }, + semantic_annotation_ids: [], + disallowed_reply_options: null, + }; + const features = { + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + tweet_awards_web_tipping_enabled: false, + responsive_web_grok_show_grok_translated_post: false, + responsive_web_grok_analysis_button_from_backend: true, + creator_subscriptions_quote_tweet_preview_enabled: false, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + payments_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: false, + rweb_tipjar_consumption_enabled: true, + verified_phone_label_enabled: false, + articles_preview_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: false, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_imagine_annotation_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_enhance_cards_enabled: false, + }; + if (topts.reply) + variables = { + ...variables, + reply: { + in_reply_to_tweet_id: topts.reply, + exclude_reply_user_ids: [], + }, + }; + if (topts.quote) variables = { ...variables, attachment_url: topts.quote }; + + const params = { ...features, variables, queryId }; + const body = JSON.stringify(params); + const nheaders = { "Content-type": "application/json" }; + const headers = { + ...TwitterApiService.HEADERS, + Authorization: TWITTER_INTERNAL_API_KEY, + Cookie: this.cookie, + ...nheaders, + }; + const opts = { + method: "POST", + headers, + body, + }; + const res = await fetch(url, opts); + console.log("like added", res); + } + async addLike(tweet_id: string) { + const queryId = `lI07N6Otwv1PhnEgXILM7A`; + const url = new URL(`https://x.com/i/api/graphql/${queryId}/FavoriteTweet`); + const body = { variables: { tweet_id }, queryId }; + return await this.postCall(url, body); + } + async removeLike(tweet_id: string) { + const queryId = `ZYKSe-w7KEslx3JhSIk5LA`; + const url = new URL( + `https://x.com/i/api/graphql/${queryId}/UnfavoriteTweet`, + ); + const body = { variables: { tweet_id }, queryId }; + return await this.postCall(url, body); + } + async addRT(tweet_id: string) { + const queryId = `ZYKSe-w7KEslx3JhSIk5LA`; + const url = new URL(`https://x.com/i/api/graphql/${queryId}/CreateRetweet`); + // TODO wtf is dark_request bruh + const body = { + variables: { tweet_id, dark_request: false }, + queryId, + }; + return await this.postCall(url, body); + } + async removeRT(tweet_id: string) { + const queryId = `iQtK4dl5hBmXewYZuEOKVw`; + const url = new URL(`https://x.com/i/api/graphql/${queryId}/DeleteRetweet`); + const body = { + variables: { tweet_id, dark_request: false }, + queryId, + }; + return await this.postCall(url, body); + } + async removeTweet(tweet_id: string) { + const queryId = `VaenaVgh5q5ih7kvyVjgtg`; + const url = new URL(`https://x.com/i/api/graphql/${queryId}/DeleteTweet`); + const body = { + variables: { tweet_id, dark_request: false }, + queryId, + }; + return await this.postCall(url, body); + } + + async addBookmark(tweet_id: string) { + const queryId = `aoDbu3RHznuiSkQ9aNM67Q`; + const url = new URL( + `https://x.com/i/api/graphql/${queryId}/CreateBookmark`, + ); + const body = { variables: { tweet_id }, queryId }; + try { + const res = await this.postCall(url, body); + return res?.data?.tweet_bookmark_put === "Done"; + } catch (e) { + console.log("wtf man", e); + // return this.removeBookmark(tweet_id); + } + } + async removeBookmark(tweet_id: string) { + const queryId = `Wlmlj2-xzyS1GN3a6cj-mQ`; + const url = new URL( + `https://x.com/i/api/graphql/${queryId}/DeleteBookmark`, + ); + const body = { variables: { tweet_id }, queryId }; + const res = await this.postCall(url, body); + return res?.data?.tweet_bookmark_delete === "Done"; + } +} diff --git a/packages/tweetdeck/src/lib/fetching/types.ts b/packages/tweetdeck/src/lib/fetching/types.ts new file mode 100644 index 0000000..deb5418 --- /dev/null +++ b/packages/tweetdeck/src/lib/fetching/types.ts @@ -0,0 +1,596 @@ +export type TweetList = { + tweets: Tweet[]; + cursorTop: string; + cursorBottom: string; +}; +export interface UserResult { + __typename: "User"; + id: string; // hash + rest_id: string; // number + affiliates_highlighted_label: {}; + avatar: { + image_url: string; + }; + core: { + created_at: string; // date string + name: string; + screen_name: string; + }; + dm_permissions: { + can_dm: boolean; + }; + has_graduated_access: boolean; + is_blue_verified: boolean; + legacy: { + profile_image_url_https?: string; + name?: string; + screen_name?: string; + default_profile: boolean; + default_profile_image: boolean; + description: string; + entities: { + description: { + urls: APITwitterURLEntity[]; + }; + url: { + urls: APITwitterURLEntity[]; + }; + }; + fast_followers_count: number; + favourites_count: number; + followers_count: number; + friends_count: number; + has_custom_timelines: boolean; + is_translator: boolean; + listed_count: number; + media_count: number; + needs_phone_verification: boolean; + normal_followers_count: number; + pinned_tweet_ids_str: string[]; + possibly_sensitive: boolean; + profile_interstitial_type: string; + statuses_count: number; + translator_type: string; // "none" + url: string; + want_retweets: boolean; + withheld_in_countries: string[]; + }; + location: { + location: string; + }; + media_permissions: { + can_media_tag: boolean; + }; + parody_commentary_fan_label: string; + profile_image_shape: string; + privacy: { + protected: boolean; + }; + relationship_perspectives: { + following: boolean; + }; + tipjar_settings: + | {} + | { + is_enabled: true; + bitcoin_handle: string; + ethereum_handle: string; + patreon_handle: string; + }; // TODO + super_follow_eligible?: boolean; + verification: { + verified: boolean; + }; + quick_promote_eligibility?: { + eligibility: "IneligibleNotProfessional"; // TODO + }; +} +export interface TweetWithVisibilityResult { + __typename: "TweetWithVisibilityResults"; + tweet: TweetResult; + limitedActionResults: { + limited_actions: Array<{ + action: "Reply"; // and? + prompts: { + __typename: "CtaLimitedActionPrompt"; // ? + cta_type: "SeeConversation"; + headline: { text: string; entities: [] }; + subtext: { text: string; entities: [] }; + }; + }>; + }; +} +export interface TweetResult { + __typename: "Tweet"; + rest_id: string; + post_video_description?: string; + has_birdwatch_notes?: boolean; + unmention_data: {}; + edit_control: { + edit_tweet_ids: string[]; + editable_until_msecs: string; + is_edit_eligible: boolean; + edits_remaining: number; + }; + is_translatable: boolean; + views: { + count: string; + state: "EnabledWithCount"; // TODO + }; + source: string; // "<a href=\"http://twitter.com/download/iphone\" rel=\"nofollow\">Twitter for iPhone</a>", + grok_analysis_button: boolean; + quoted_status_result?: { result: TweetResult }; + is_quote_status: boolean; + legacy: { + retweeted_status_result?: { result: TweetResult }; + quoted_status_id_str?: string; + quoted_status_permalink?: { + uri: string; + expanded: string; + display: string; + }; + id_str: string; + user_id_str: string; + bookmark_count: number; + bookmarked: boolean; + favorite_count: number; + favorited: boolean; + quote_count: number; + reply_count: number; + retweet_count: number; + retweeted: boolean; + conversation_control: { + policy: "ByInvitation"; // TODO + conversation_owner_results: { + result: { + __typename: "User"; + core: { + screen_name: string; + }; + }; + }; + }; + conversation_id_str: string; + display_text_range?: [number, number]; + full_text: string; + lang: string; + created_at: string; + possibly_sensitive: boolean; + possibly_sensitive_Editable: boolean; + entities: { + hashtags?: Array<{ text: string }>; + media?: APITwitterMediaEntity[]; + symbols: string[]; + timestamps: string[]; + urls: APITwitterURLEntity[]; // TODO + user_mentions: Array<{ + id_str: string; + name: string; + screen_name: string; + indices: [number, number]; + }>; + }; + extended_entities: { + media: APITwitterMediaExtendedEntity[]; + }; + limitedActionResults: { + limited_actions: Array<{ + actions: "Reply"; // TODO; + prompts: { + cta_type: string; + headline: { + text: string; + entities: APITwitterMediaEntity[]; // ? + }; + subtext: { + text: string; + entities: APITwitterMediaEntity[]; + }; + }; + }>; + }; + }; + core: { + user_results?: { + result: UserResult; + }; + }; +} +interface APITwitterURLEntity { + display_url: string; + expanded_url: string; + url: string; // minified + indices: [number, number]; +} +type APITwitterMediaEntity = APITwitterPhotoEntity | APITwitterVideoEntity; +interface APITwitterMediaBase { + additional_media_info?: { + monetizable: boolean; + }; + display_url: string; + expanded_url: string; + id_str: string; + indices: [number, number]; + media_key: string; + media_url_https: string; + url: string; // minified + ext_media_availability: { + status: "Available" | "Unavailable"; // ? + }; + features: { + large: { + faces: []; + }; + medium: { + faces: []; + }; + small: { + faces: []; + }; + orig: { + faces: []; + }; + }; + sizes: { + large: { + h: number; + w: number; + resize: "fit" | "crop"; + }; + medium: { + h: number; + w: number; + resize: "fit" | "crop"; + }; + small: { + h: number; + w: number; + resize: "fit" | "crop"; + }; + thumb: { + h: number; + w: number; + resize: "fit" | "crop"; + }; + }; + original_info: { + height: number; + width: number; + focus_rects: [ + { + x: number; + y: number; + w: number; + h: number; + }, + { + x: number; + y: number; + w: number; + h: number; + }, + { + x: number; + y: number; + w: number; + h: number; + }, + { + x: number; + y: number; + w: number; + h: number; + }, + { + x: number; + y: number; + w: number; + h: number; + }, + ]; + }; + media_results: { + result: { + media_key: string; + }; + }; +} +interface APITwitterPhotoEntity extends APITwitterMediaBase { + type: "photo"; +} +interface APITwitterVideoEntity extends APITwitterMediaBase { + type: "video"; + + video_info: { + aspect_ratio: [number, number]; + duration_millis: number; + variants: Array< + | { + content_type: "application/x-mpegURL"; + url: string; + } + | { + content_type: "video/mp4"; + bitrate: number; + url: string; + } + >; + }; +} + +type APITwitterMediaExtendedEntity = APITwitterMediaEntity; + +export interface TimelineEntry { + entryId: string; + sortIndex: string; + content: { + entryType: string; + __typename: string; + itemContent?: { + itemType: string; + __typename: string; + tweet_results?: { + result?: TweetResult | TweetWithVisibilityResult; + }; + user_results?: { + result?: { + __typename: string; + id: string; + rest_id: string; + legacy: { + name?: string; + screen_name?: string; + profile_image_url_https?: string; + }; + }; + }; + }; + cursorType?: string; + value?: string; + stopOnEmptyResponse?: boolean; + }; +} + +export interface TimelineInstruction { + type: string; + entries?: TimelineEntry[]; +} + +export interface TwitterProfilesResponse { + data: { users: Array<{ result: UserResult }> }; +} +export interface TwitterUserTweetsResponse { + data: { + user: { + result: { + __typename: "User"; + timeline: { + timeline: { + instructions: TimelineInstruction[]; + }; + }; + }; + }; + }; +} +export interface TwitterTweetDetailResponse { + data: { + threaded_conversation_with_injections_v2: { + instructions: TimelineInstruction[]; + }; + }; +} +export interface TwitterBookmarkResponse { + data: { + bookmark_timeline_v2: { + timeline: { + instructions: TimelineInstruction[]; + }; + }; + }; +} + +export interface TwitterTimelineResponse { + data: { + home?: { + home_timeline_urt: { + instructions: TimelineInstruction[]; + }; + }; + home_timeline_urt?: { + instructions: TimelineInstruction[]; + }; + }; +} + +export interface TwitterListTimelineResponse { + data: { + list: { + tweets_timeline: { + timeline: { + instructions: TimelineInstruction[]; + }; + }; + }; + }; +} + +export interface TwitterList { + id: string; + name: string; + member_count: number; + subscriber_count: number; + creator: string; +} +export interface APITwitterMediaInfo { + original_img_url: string; + original_img_width: number; + original_img_height: number; + salient_rect: { + left: number; + top: number; + width: number; + height: number; + }; +} +export interface APITwitterList { + created_at: number; + default_banner_media: { + media_info: APITwitterMediaInfo; + }; + default_banner_media_results: { + result: { + id: string; + media_key: string; + media_id: string; + media_info: APITwitterMediaInfo; + __typename: "ApiMedia"; + }; + }; + description: string; + facepile_urls: string[]; + following: boolean; + id: string; //hash + id_str: string; // timestamp + is_member: boolean; + member_count: number; + members_context: string; + mode: string; // "private or public" + muting: boolean; + name: string; + pinning: boolean; + subscriber_count: number; + user_results: { + result: UserResult; + }; +} + +export interface TwitterListsManagementResponse { + data: { + viewer: { + list_management_timeline: { + timeline: { + instructions: Array<{ + type: string; + entries: Array<{ + content: { + __typename: string; + items: Array<{ + entryId: string; + item: { + clientEventInfo: any; + itemContent: { + itemType: "TimelineTwitterList"; + displayType: "ListWithPin"; // ? + list: APITwitterList; + }; + }; + }>; + }; + }>; + }>; + }; + }; + }; + }; +} + +export interface TwitterNotification { + id: string; + timestampMs: string; + message: { + text: string; + entities: Array<{ + fromIndex: number; + toIndex: number; + ref: { + type: string; + screenName?: string; + mentionResults?: { + result?: { + legacy?: { + name?: string; + screen_name?: string; + }; + }; + }; + }; + }>; + rtl: boolean; + }; + icon: { + id: string; + }; + users: { + [key: string]: { + id: string; + screen_name: string; + name: string; + profile_image_url_https: string; + }; + }; +} + +export interface TwitterNotificationsTimelineResponse { + globalObjects: { + notifications: { [id: string]: TwitterNotification }; + users: { + [id: string]: { + id: string; + screen_name: string; + name: string; + profile_image_url_https: string; + }; + }; + tweets: { [id: string]: Tweet }; + }; + timeline: { + id: string; + instructions: Array<{ + addEntries?: { + entries: Array<{ + entryId: string; + sortIndex: string; + content: { + notification: { + id: string; + urls: Array<{ + url: string; + expandedUrl: string; + displayUrl: string; + }>; + }; + }; + }>; + }; + }>; + }; +} + +export type TwitterUser = { + id: string; + avatar: string; + name: string; + username: string; +}; +export interface Tweet { + id: string; + text: string; + language: string; + author: TwitterUser; + time: number; + urls: Array<{ + expandedUrl: string; + displayUrl: string; + }>; + media: { pics: string[]; video: { thumb: string; url: string } }; + hashtags: string[]; + quoting: Tweet | null; + liked: boolean; + bookmarked: boolean; + retweeted_by: RTMetadata | null; + rted: boolean; + replyingTo: Array<{ username: string }>; +} +export type RTMetadata = { author: TwitterUser; time: number }; +export type TwitterBookmark = Tweet; diff --git a/packages/tweetdeck/src/lib/utils/id.ts b/packages/tweetdeck/src/lib/utils/id.ts new file mode 100644 index 0000000..3008587 --- /dev/null +++ b/packages/tweetdeck/src/lib/utils/id.ts @@ -0,0 +1,4 @@ +export const generateId = () => + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2, 11); diff --git a/packages/tweetdeck/src/lib/utils/time.ts b/packages/tweetdeck/src/lib/utils/time.ts new file mode 100644 index 0000000..f2802bf --- /dev/null +++ b/packages/tweetdeck/src/lib/utils/time.ts @@ -0,0 +1,18 @@ +export function timeAgo(date: string | number | Date) { + const ts = typeof date === "string" || typeof date === "number" ? new Date(date).getTime() : date.getTime(); + const diff = Date.now() - ts; + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d`; + const weeks = Math.floor(days / 7); + if (weeks < 4) return `${weeks}w`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo`; + const years = Math.floor(days / 365); + return `${years}y`; +} diff --git a/packages/tweetdeck/src/logo.svg b/packages/tweetdeck/src/logo.svg new file mode 100644 index 0000000..7ef1500 --- /dev/null +++ b/packages/tweetdeck/src/logo.svg @@ -0,0 +1 @@ +<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>
\ No newline at end of file diff --git a/packages/tweetdeck/src/react.svg b/packages/tweetdeck/src/react.svg new file mode 100644 index 0000000..1ab815a --- /dev/null +++ b/packages/tweetdeck/src/react.svg @@ -0,0 +1,8 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348"> + <circle cx="0" cy="0" r="2.05" fill="#61dafb"/> + <g stroke="#61dafb" stroke-width="1" fill="none"> + <ellipse rx="11" ry="4.2"/> + <ellipse rx="11" ry="4.2" transform="rotate(60)"/> + <ellipse rx="11" ry="4.2" transform="rotate(120)"/> + </g> +</svg> diff --git a/packages/tweetdeck/src/styles/index.css b/packages/tweetdeck/src/styles/index.css new file mode 100644 index 0000000..e9a500f --- /dev/null +++ b/packages/tweetdeck/src/styles/index.css @@ -0,0 +1,835 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Inter:wght@400;500;600&display=swap"); + +:root { + color-scheme: dark; + --bg: radial-gradient(circle at top, #15234b 0%, #050914 55%); + --panel: rgba(9, 14, 28, 0.9); + --panel-border: rgba(255, 255, 255, 0.08); + --soft-border: rgba(255, 255, 255, 0.15); + --muted: rgba(255, 255, 255, 0.6); + --accent: #7f5af0; + font-family: "Inter", "Space Grotesk", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background-color: #050914; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: #f5f6fb; + min-height: 100vh; +} + +button, +input, +select, +textarea { + font-family: inherit; +} + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 320px 1fr; + color: inherit; +} + +.sidebar { + position: sticky; + top: 0; + align-self: start; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 2rem; + background: var(--panel); + border-right: 1px solid var(--panel-border); + gap: 2rem; +} + +.brand { + display: flex; + gap: 1rem; + position: relative; + padding-bottom: 1.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.brand-glow { + width: 16px; + height: 16px; + border-radius: 50%; + background: linear-gradient(120deg, #7f5af0, #2cb67d); + box-shadow: 0 0 24px #7f5af0; + margin-top: 6px; +} + +h1, +h2, +h3, +h4 { + font-family: "Space Grotesk", "Inter", sans-serif; + margin: 0.2rem 0; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.7rem; + color: var(--muted); + margin: 0; +} + +.tagline { + margin: 0.2rem 0 0; + color: var(--muted); +} + +.sidebar-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sidebar-section header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.account-chip { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 0.9rem; + border-radius: 12px; + border: 1px solid var(--panel-border); + cursor: pointer; + background: rgba(255, 255, 255, 0.02); +} + +.account-chip.active { + border-color: currentColor; + background: rgba(127, 90, 240, 0.2); +} + +.account-chip strong { + display: block; +} + +.account-chip small { + color: var(--muted); +} + +.chip-accent { + width: 6px; + height: 40px; + border-radius: 999px; +} + +.chip-actions button { + border: none; + background: transparent; + color: var(--muted); + font-size: 1rem; +} + +.account-form input, +.account-form textarea, +.account-form select { + width: 100%; + margin-top: 0.35rem; + padding: 0.65rem 0.75rem; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(5, 9, 20, 0.7); + color: inherit; + resize: vertical; +} + +.account-form textarea.masked { + filter: blur(6px); +} + +.checkbox { + display: flex; + gap: 0.5rem; + align-items: center; + font-size: 0.85rem; +} + +.sidebar-footer { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.button-row, +.sidebar-footer button, +.account-form button, +.modal button.primary, +.primary { + border: none; + border-radius: 999px; + padding: 0.75rem 1.5rem; + font-weight: 600; + background: linear-gradient(120deg, #7f5af0, #2cb67d); + color: #050914; + cursor: pointer; +} + +.primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ghost { + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 999px; + padding: 0.35rem 0.85rem; + background: transparent; + color: inherit; + cursor: pointer; +} + +button.ghost:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.muted { + color: var(--muted); + margin: 0; +} + +.muted.tiny { + font-size: 0.8rem; +} + +.sidebar-footer .tiny { + font-size: 0.7rem; +} + +main { + padding: 2.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + overflow-x: auto; +} + +.column-board { + display: flex; + gap: 1rem; + overflow-x: auto; + padding-bottom: 1rem; +} + +.column { + flex: 0 0 360px; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem; + border-radius: 18px; + border: 1px solid var(--panel-border); + background: rgba(8, 13, 26, 0.9); + max-height: calc(100vh - 120px); + width: 100%; +} + +.column.missing { + justify-content: center; + text-align: center; +} + +.column header { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.column-actions { + display: flex; + gap: 0.5rem; +} + +.column .tweet-stack, +.column .chat-stack { + flex: 1; + overflow-y: auto; + padding-right: 0.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.column .tweet-stack::-webkit-scrollbar, +.column .chat-stack::-webkit-scrollbar { + display: none; +} + +.load-more-row { + display: flex; + justify-content: center; + margin: 0.5rem 0 1rem; +} + +.load-more-row button { + min-width: 140px; +} + +.load-more-row p { + margin: 0; + text-align: center; +} + +.fullscreen-overlay { + position: fixed; + inset: 0; + background: rgba(3, 5, 12, 0.95); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 3rem; +} + +.fullscreen-content { + width: min(900px, 100%); + height: min(95vh, 100%); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.fullscreen-card { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.fullscreen-card .tweet-card { + display: flex; + flex-direction: column; + flex: 1; + min-height: 70vh; + font-size: 1.1rem; + + .tweet-body { + flex-grow: 1; + display: flex; + flex-direction: column; + + .media-grid { + flex-grow: 1; + + } + } + + footer { + button { + svg { + width: 3rem; + height: 3rem; + } + } + } +} + +.fullscreen-card header { + font-size: 1.8rem; +} + +.fullscreen-card .tweet-text { + font-size: 1.5rem; + line-height: 1.8; +} + +.fullscreen-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + text-align: center; + font-size: 1.3rem; +} + +.fullscreen-controls { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.fullscreen-column-controls { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.fullscreen-column-controls .ghost { + min-width: 180px; +} + +.fullscreen-close { + position: absolute; + top: 1.5rem; + right: 1.5rem; + font-size: 1.5rem; +} + +.column-content { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.column-content.slide-forward { + animation: columnForward 0.35s ease both; +} + +.column-content.slide-backward { + animation: columnBackward 0.35s ease both; +} + +@keyframes columnForward { + from { + opacity: 0; + transform: translateX(24px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes columnBackward { + from { + opacity: 0; + transform: translateX(-24px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.column footer { + text-align: center; +} + +.tweet-card, +.chat-card { + border-radius: 18px; + padding: 1rem; + border: 1px solid var(--soft-border); + background: rgba(4, 8, 18, 0.8); + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.retweet-banner { + margin: 0; + text-transform: none; +} + +.tweet-replying-to { + opacity: 0.5; + + span { + margin: 0 0.25ch; + } +} + +.tweet-actions { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 0.75rem; + margin-top: 0.25rem; +} + +.tweet-actions .action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.3rem; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 999px; + background: transparent; + color: inherit; + font-size: 0.8rem; + padding: 0.35rem 0.6rem; + cursor: pointer; + position: relative; + overflow: hidden; + transition: border-color 0.2s ease, background 0.2s ease, transform 0.15s ease; +} + +.tweet-actions .action.active { + border-color: rgba(255, 255, 255, 0.35); + transform: scale(1.08); +} + +.tweet-actions .action.like.active svg { + color: #f25f4c; +} + +.tweet-actions .action.retweet.active svg { + color: #2cb67d; +} + +.tweet-actions .action.bookmark.active svg { + color: #f0a500; +} + +.tweet-actions .action::after { + content: ""; + position: absolute; + inset: 50%; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0; + transform: translate(-50%, -50%) scale(1); + pointer-events: none; +} + +.tweet-actions .action:active::after { + animation: ripple 0.45s ease-out; +} + +@keyframes ripple { + 0% { + opacity: 0.25; + transform: translate(-50%, -50%) scale(0.2); + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(8); + } +} + +.tweet-actions .action.in-flight { + opacity: 0.5; + pointer-events: none; +} + +.tweet-actions .action.copied svg { + color: var(--accent); +} + +.tweet-actions .action:hover { + border-color: rgba(255, 255, 255, 0.35); + background: rgba(255, 255, 255, 0.08); +} + +.tweet-actions svg { + width: 16px; + height: 16px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.link-button { + border: none; + background: none; + color: inherit; + font: inherit; + padding: 0; + cursor: pointer; +} + +.link-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.link-button:disabled { + opacity: 0.6; + cursor: default; +} + +.tweet-card header, +.chat-card header { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.tweet-card img { + border-radius: 12px; +} + +.author { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.author img { + width: 44px; + height: 44px; + border-radius: 50%; +} + +.author-meta { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; +} + +.tweet-text { + white-space: pre-wrap; + line-height: 1.5; +} + +.mention { + color: #2cb67d; +} + +.hashtag { + color: #7f5af0; +} + +.media-grid { + display: grid; + gap: 0.5rem; +} + +.media-grid.pics-1 { + grid-template-columns: 1fr; +} + +.media-grid.pics-2 { + grid-template-columns: repeat(2, 1fr); +} + +.media-grid.pics-3, +.media-grid.pics-4 { + grid-template-columns: repeat(2, 1fr); +} + +.media-grid img { + width: 100%; +} + +.video-wrapper video { + width: 100%; + border-radius: 14px; + background: #000; +} + +.link-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.link-chips a { + border: 1px solid var(--soft-border); + border-radius: 999px; + padding: 0.35rem 0.9rem; + text-decoration: none; + color: inherit; + font-size: 0.85rem; +} + +.chat-card { + flex-direction: row; +} + +.chat-avatar img, +.chat-avatar span { + width: 42px; + height: 42px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; +} + +.chat-avatar img { + border-radius: 999px; +} + +.chat-body header { + align-items: baseline; + gap: 0.4rem; +} + +.chat-body p { + margin: 0.25rem 0 0; +} + +.chat-body .dot { + color: var(--muted); +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: grid; + place-items: center; + padding: 1rem; + z-index: 1000; +} + +.modal { + width: min(520px, 100%); + background: rgba(5, 9, 20, 0.95); + border-radius: 24px; + border: 1px solid var(--panel-border); + padding: 1.75rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-body { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal select, +.modal input { + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(8, 13, 26, 0.9); + color: inherit; + padding: 0.65rem 0.75rem; +} + +.option-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; +} + +.option { + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + padding: 0.75rem; + text-align: left; + background: rgba(3, 6, 15, 0.9); + cursor: pointer; +} + +.option.selected { + border-color: var(--accent); + background: rgba(127, 90, 240, 0.15); +} + +.option p { + margin: 0.2rem 0 0; + color: var(--muted); +} + +.error { + color: #f25f4c; +} + +.column-loading { + text-align: center; + padding: 2rem 0; + color: var(--muted); +} + +.empty-board { + border: 1px dashed rgba(255, 255, 255, 0.2); + border-radius: 24px; + padding: 2.5rem; + text-align: center; +} + +.toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 0.85rem 1.25rem; + border-radius: 999px; + background: rgba(15, 25, 50, 0.9); + border: 1px solid rgba(255, 255, 255, 0.2); + animation: fadeOut 4s forwards; +} + +@keyframes fadeOut { + 0% { + opacity: 1; + transform: translateY(0); + } + + 80% { + opacity: 1; + } + + 100% { + opacity: 0; + transform: translateY(12px); + } +} + +@media (max-width: 1024px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + position: relative; + min-height: unset; + } + + main { + padding: 1.5rem; + } + + .column { + flex-basis: 80vw; + } +} + + +/* language stuff */ +*[lang="th"], +*[lang="tha"] { + font-size: 3rem; +} + +/* .font-Thai-0 { */
\ No newline at end of file diff --git a/packages/tweetdeck/src/styles/normalize.css b/packages/tweetdeck/src/styles/normalize.css new file mode 100644 index 0000000..fdec4bd --- /dev/null +++ b/packages/tweetdeck/src/styles/normalize.css @@ -0,0 +1,379 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; + /* 1 */ + height: 0; + /* 1 */ + overflow: visible; + /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; + /* 1 */ + text-decoration: underline; + /* 2 */ + text-decoration: underline dotted; + /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + line-height: 1.15; + /* 1 */ + margin: 0; + /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { + /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { + /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; + /* 1 */ + color: inherit; + /* 2 */ + display: table; + /* 1 */ + max-width: 100%; + /* 1 */ + padding: 0; + /* 3 */ + white-space: normal; + /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; + /* 1 */ + padding: 0; + /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +}
\ No newline at end of file diff --git a/packages/tweetdeck/src/types/app.ts b/packages/tweetdeck/src/types/app.ts new file mode 100644 index 0000000..c8c9e80 --- /dev/null +++ b/packages/tweetdeck/src/types/app.ts @@ -0,0 +1,92 @@ +import type { + Tweet, + TwitterList, + TwitterNotification, +} from "../lib/fetching/types"; + +export type TimelineMode = + | "foryou" + | "following" + | "bookmarks" + | "list" + | "chat"; + +export interface DeckAccount { + id: string; + label: string; + handle?: string; + username?: string; + avatar?: string; + accent: string; + cookie: string; + createdAt: number; +} + +export type ColumnView = + | { + type: "timeline"; + mode: Exclude<TimelineMode, "chat">; + title?: string; + listId?: string; + listName?: string; + } + | { + type: "user"; + userId: string; + username: string; + title?: string; + } + | { + type: "thread"; + tweetId: string; + title?: string; + }; + +export interface ColumnState { + stack: ColumnView[]; +} + +export interface ColumnSnapshot { + tweets: Tweet[]; + label: string; +} + +export interface DeckColumn { + id: string; + kind: TimelineMode; + accountId: string; // wtf is this + account: string; // TODO ensure this gets populated + title: string; + listId?: string; + listName?: string; + state?: ColumnState; +} + +export interface TimelineState { + tweets: Tweet[]; + cursorTop: string; + cursorBottom: string; + isLoading: boolean; + isAppending: boolean; + error?: string; +} + +export interface FullscreenState { + column: DeckColumn; + columnLabel: string; + accent: string; + tweets: Tweet[]; + index: number; + columnIndex: number; +} + +export interface ChatState { + entries: TwitterNotification[]; + cursor?: string; + isLoading: boolean; + error?: string; +} + +export interface DeckListsCache { + [accountId: string]: TwitterList[]; +} diff --git a/packages/tweetdeck/test.sh b/packages/tweetdeck/test.sh new file mode 100644 index 0000000..a208049 --- /dev/null +++ b/packages/tweetdeck/test.sh @@ -0,0 +1 @@ +bun --eval "import { createRequire } from 'module'; const r=createRequire('/home/y/code/bun/libs/prosody-ui/src/fonts/FontChanger.tsx'); console.log('react ->', r.resolve('react')); console.log('glotscript->', r.resolve('glotscript'));" diff --git a/packages/tweetdeck/tests/js.js b/packages/tweetdeck/tests/js.js new file mode 100644 index 0000000..947d9a7 --- /dev/null +++ b/packages/tweetdeck/tests/js.js @@ -0,0 +1,185 @@ +let solverIframe; +let solveId = 0; +let solveCallbacks = {}; +let solveQueue = [] +let solverReady = false; +let solverErrored = false; +let sentData = false; + +let sandboxUrl = fetch(chrome.runtime.getURL(`sandbox.html`)) + .then(resp => resp.blob()) + .then(blob => URL.createObjectURL(blob)) + .catch(console.error); + +function createSolverFrame() { + if (solverIframe) solverIframe.remove(); + solverIframe = document.createElement('iframe'); + solverIframe.style.display = 'none'; + sandboxUrl.then(url => solverIframe.src = url); + let injectedBody = document.getElementById('injected-body'); + if(injectedBody) { + injectedBody.appendChild(solverIframe); + } else { + let int = setInterval(() => { + let injectedBody = document.getElementById('injected-body'); + if(injectedBody) { + injectedBody.appendChild(solverIframe); + clearInterval(int); + } + }, 10); + } +} +createSolverFrame(); + +function solveChallenge(path, method) { + return new Promise((resolve, reject) => { + if(solverErrored) { + reject('Solver errored during initialization'); + return; + } + let id = solveId++; + solveCallbacks[id] = { resolve, reject, time: Date.now() }; + if(!solverReady || !solverIframe || !solverIframe.contentWindow) { + solveQueue.push({ id, path, method }) + } else { + try { + solverIframe.contentWindow.postMessage({ action: 'solve', id, path, method }, '*'); + } catch(e) { + console.error(`Error sending challenge to solver:`, e); + reject(e); + } + // setTimeout(() => { + // if(solveCallbacks[id]) { + // solveCallbacks[id].reject('Solver timed out'); + // delete solveCallbacks[id]; + // } + // }, 1750); + } + }); +} + +setInterval(() => { + if(!document.getElementById('loading-box').hidden && sentData && solveQueue.length) { + console.log("Something's wrong with the challenge solver, reloading", solveQueue); + createSolverFrame(); + initChallenge(); + } +}, 2000); + +window.addEventListener('message', e => { + if(e.source !== solverIframe.contentWindow) return; + let data = e.data; + if(data.action === 'solved' && typeof data.id === 'number') { + let { id, result } = data; + if(solveCallbacks[id]) { + solveCallbacks[id].resolve(result); + delete solveCallbacks[id]; + } + } else if(data.action === 'error' && typeof data.id === 'number') { + let { id, error } = data; + if(solveCallbacks[id]) { + solveCallbacks[id].reject(error); + delete solveCallbacks[id]; + } + } else if(data.action === 'initError') { + solverErrored = true; + for(let id in solveCallbacks) { + solveCallbacks[id].reject('Solver errored during initialization'); + delete solveCallbacks[id]; + } + alert(`There was an error in initializing security header generator:\n${data.error}\nUser Agent: ${navigator.userAgent}\nOldTwitter doesn't allow unsigned requests anymore for your account security.`); + console.error('Error initializing solver:'); + console.error(data.error); + } else if(data.action === 'ready') { + solverReady = true; + for (let task of solveQueue) { + solverIframe.contentWindow.postMessage({ action: 'solve', id: task.id, path: task.path, method: task.method }, '*') + } + } +}); + +window._fetch = window.fetch; +fetch = async function(url, options) { + if(!url.startsWith('/i/api') && !url.startsWith('https://api.twitter.com') && !url.startsWith('https://api.x.com')) return _fetch(url, options); + if(!options) options = {}; + if(!options.headers) options.headers = {}; + if(!options.headers['x-twitter-auth-type']) { + options.headers['x-twitter-auth-type'] = 'OAuth2Session'; + } + if(!options.headers['x-twitter-active-user']) { + options.headers['x-twitter-active-user'] = 'yes'; + } + if(!options.headers['X-Client-UUID']) { + options.headers['X-Client-UUID'] = OLDTWITTER_CONFIG.deviceId; + } + if(!url.startsWith('http:') && !url.startsWith('https:')) { + let host = location.hostname; + if(!['x.com', 'twitter.com'].includes(host)) host = 'x.com'; + if(!url.startsWith('/')) url = '/' + url; + url = `https://${host}${url}`; + } + let parsedUrl = new URL(url); + // try { + let solved = await solveChallenge(parsedUrl.pathname, options.method ? options.method.toUpperCase() : 'GET'); + console.log({solved}) + options.headers['x-client-transaction-id'] = solved; + // } catch (e) { + // console.error(`Error solving challenge for ${url}:`); + // console.error(e); + // } + if(options.method && options.method.toUpperCase() === 'POST' && typeof options.body === 'string') { + options.headers['Content-Length'] = options.body.length; + } + + return _fetch(url, options); +} + +async function initChallenge() { + try { + let homepageData; + let sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + let host = location.hostname; + if(!['x.com', 'twitter.com'].includes(host)) host = 'x.com'; + try { + homepageData = await _fetch(`https://${host}/`).then(res => res.text()); + } catch(e) { + await sleep(500); + try { + homepageData = await _fetch(`https://${host}/`).then(res => res.text()); + } catch(e) { + throw new Error('Failed to fetch homepage: ' + e); + } + } + let dom = new DOMParser().parseFromString(homepageData, 'text/html'); + let verificationKey = dom.querySelector('meta[name="twitter-site-verification"]').content; + let anims = Array.from(dom.querySelectorAll('svg[id^="loading-x"]')).map(svg => svg.outerHTML); + + let challengeCode = homepageData.match(/"ondemand.s":"(\w+)"/)[1]; + + OLDTWITTER_CONFIG.verificationKey = verificationKey; + + function sendInit() { + sentData = true; + if(!solverIframe || !solverIframe.contentWindow) return setTimeout(sendInit, 50); + solverIframe.contentWindow.postMessage({ + action: 'init', + challengeCode, + anims, + verificationCode: OLDTWITTER_CONFIG.verificationKey + }, '*'); + } + setTimeout(sendInit, 50); + return true; + } catch (e) { + console.error(`Error during challenge init:`); + console.error(e); + if(location.hostname === 'twitter.com') { + alert(`There was an error in initializing security header generator: ${e}\nUser Agent: ${navigator.userAgent}\nOldTwitter doesn't allow unsigned requests anymore for your account security. Currently the main reason for this happening is social network tracker protection blocking the script. Try disabling such settings in your browser and extensions that do that and refresh the page. This also might be because you're either not logged in or using twitter.com instead of x.com.`); + } else { + alert(`There was an error in initializing security header generator: ${e}\nUser Agent: ${navigator.userAgent}\nOldTwitter doesn't allow unsigned requests anymore for your account security. Currently the main reason for this happening is social network tracker protection blocking the script. Try disabling such settings in your browser and extensions that do that and refresh the page. This can also happen if you're not logged in.`); + } + return false; + } +}; + +initChallenge(); diff --git a/packages/tweetdeck/tests/python.ts b/packages/tweetdeck/tests/python.ts new file mode 100644 index 0000000..73a174b --- /dev/null +++ b/packages/tweetdeck/tests/python.ts @@ -0,0 +1,150 @@ +import { TransactionIdGenerator as TransactionIdGenerator } from "../src/lib/fetching/python"; + +export const cookie = `auth_token=651d779cb99b54e7ce81bf8f8050e597c452b50b;gt=1868678280205029867;guest_id=v1%3A176150648682600359;__cf_bm=mfj12NkVbU0uPRz84om3BecRhAwv.8CCo.w0B4Gw45o-1761513085.2856057-1.0.1.1-gHPgX0w_GRneFL_GLZ5kPSXd7VCe4EuZnxN9FeKlpRg5PR5CCFrHazEjrE_EM53YxTbd1hDJoaWNXSU.Abr7FmBF5aCso.Jqn6M1ycwSXLhvQ3nTdZXRfiGpDrQugR6q;twid=u%3D1710606417324015616;g_state={"i_p":1733335699025,"i_l":1};external_referer=8e8t2xd8A2w%3D|0|F8C7rVpldvH1T6C9tW%2FBRRUSYlUEkKIshjiEn%2Br%2F70sqqRYvQQ%2FYUQ%3D%3D;auth_multi="1761426801275097088:f10fe04281e0bdf50e9dba703e2cba86adb84847";lang=en;night_mode=2;__cf_bm=koa1LKRskRZYwhHNG6YcIcSFN87JPc24Tb3umQqxxxU-1761507653-1.0.1.1-9OQutUp3hCAW8o5Sr8S0h.uE0eTWkxGRYiiESZu6WW9Q_Y_out9M6G9FqS5n9BVn1svlAUT97_FyLZosboQ45Q0M_HZ4F_rYtd3ojsAm6n8;__cuid=b4c86e91126647b5b7e07dadeac8ec2b;ct0=678e86afdb647f32835e25d1204c99af50b95d0e9ebcaf9db2d837fb9c4e48759a0e218c4845337f429c10e8d95f8a97cb9a7cee84c1bc5a7b522f440d5b6824466b6f4b62c328cc32d980a22fb00745;dnt=1;guest_id_ads=v1%3A176150648682600359;guest_id_marketing=v1%3A176150648682600359;kdt=camHTtNmw0pQUK0ZHaYd6jJnJRRYihbuZ0Ii7yN2;personalization_id="v1_KKOJ4dZoYTZuKLY9eadfIg=="`; + +async function initTransactionIdGenerator(): Promise<TransactionIdGenerator> { + const MAX_REDIRECTS = 5; + let currentUrl = "https://x.com"; + let html = ""; + const headers: Record<string, string> = { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept-Language": "en-US,en;q=0.9", + Cookie: cookie, + }; + + try { + for ( + let redirectCount = 0; + redirectCount < MAX_REDIRECTS; + redirectCount++ + ) { + const response = await fetch(currentUrl, { headers }); + headers["Cookie"] = cookie; + + if (!response.ok) { + throw new Error( + `Failed fetching ${currentUrl}: ${response.status} ${response.statusText}`, + ); + } + + html = await response.text(); + + // Check for meta refresh redirect + const metaRefreshMatch = html.match( + /<meta\s+http-equiv=["']refresh["']\s+content=["'][^;]+;\s*url=([^"']+)/i, + ); + const migrationRedirectionRegex = + /(http(?:s)?:\/\/(?:www\.)?(?:twitter|x)\.com(?:\/x)?\/migrate(?:[\/?])?tok=[a-zA-Z0-9%\-_]+)/i; + const bodyMatch = html.match(migrationRedirectionRegex); + let migrationUrl = metaRefreshMatch?.[1] || bodyMatch?.[0]; + + if (migrationUrl) { + currentUrl = migrationUrl; + continue; + } + + // Handle migration form + const formMatch = html.match( + /<form[^>]*?(?:name=["']f["']|action=["']https:\/\/x\.com\/x\/migrate["'])[^>]*>([\s\S]*?)<\/form>/i, + ); + if (formMatch) { + const formContent = formMatch[1]; + const actionMatch = formMatch[0].match(/action=["']([^"']+)["']/i); + const methodMatch = formMatch[0].match(/method=["']([^"']+)["']/i); + + const formAction = actionMatch?.[1] || "https://x.com/x/migrate"; + const formMethod = methodMatch?.[1]?.toUpperCase() || "POST"; + const formUrl = new URL(formAction, currentUrl); + if (!formUrl.searchParams.has("mx")) { + formUrl.searchParams.set("mx", "2"); + } + + const payload = new URLSearchParams(); + const inputRegex = + /<input[^>]*?name=["']([^"']+)["'][^>]*?(?:value=["']([^"']*)["'])?[^>]*?>/gi; + let inputMatch; + while ((inputMatch = inputRegex.exec(formContent!)) !== null) { + payload.append(inputMatch[1]!, inputMatch[2] || ""); + } + + const formHeaders = { + ...headers, + "Content-Type": "application/x-www-form-urlencoded", + Referer: currentUrl, + }; + + const response = await fetch(formUrl.toString(), { + method: formMethod, + headers: formHeaders, + body: payload, + redirect: "manual", + }); + headers["Cookie"] = cookie; + + if ( + response.status >= 300 && + response.status < 400 && + response.headers.has("location") + ) { + currentUrl = new URL( + response.headers.get("location")!, + currentUrl, + ).toString(); + continue; + } + + if (!response.ok) { + throw new Error( + `Migration form submission failed: ${response.status} ${response.statusText}`, + ); + } + + html = await response.text(); + + const subsequentMetaRefresh = html.match( + /<meta\s+http-equiv=["']refresh["']\s+content=["'][^;]+;\s*url=([^"']+)/i, + ); + const subsequentBodyMatch = html.match(migrationRedirectionRegex); + + if (subsequentMetaRefresh?.[1] || subsequentBodyMatch?.[0]) { + currentUrl = subsequentMetaRefresh?.[1] || subsequentBodyMatch![0]; + if (!currentUrl.startsWith("http")) { + currentUrl = new URL(currentUrl, response.url).toString(); + } + continue; + } + } + + break; + } + + if (!html) { + throw new Error("Failed to retrieve HTML after potential migrations."); + } + + const transactionIdGenerator = new TransactionIdGenerator(html); + return transactionIdGenerator; + } catch (error: any) { + throw new Error( + `Transaction ID initialization failed: ${error.message || error}`, + ); + } +} + +async function generateTransactionId( + method: string, + path: string, +): Promise<string> { + const gen = new TransactionIdGenerator(""); + await gen.init(); + // const gen = await initTransactionIdGenerator(); + + return gen.getTransactionId(method, path); +} + +const res = await generateTransactionId( + "GET", + "i/api/graphql/2AtIgw7Kz26sV6sEBrQjSQ/UsersByRestIds", +); +console.log({ res }); diff --git a/packages/tweetdeck/tsconfig.json b/packages/tweetdeck/tsconfig.json new file mode 100644 index 0000000..632a36f --- /dev/null +++ b/packages/tweetdeck/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + + "exclude": ["dist", "node_modules"] +} diff --git a/packages/tweetdeck/twatter-cookies.ts b/packages/tweetdeck/twatter-cookies.ts new file mode 100644 index 0000000..f001a79 --- /dev/null +++ b/packages/tweetdeck/twatter-cookies.ts @@ -0,0 +1,4 @@ +export const first = `guest_id_marketing=v1:173815032724456303; guest_id_ads=v:173815032724456303; personalization_id="v1_KKOJ4dZoYTZuKLY9eadfIg=="; night_mode=2; kdt=camHTtNmw0pQUK0ZHaYd6jJnJRRYihbuZ0Ii7yN2; auth_token=f10fe04281e0bdf50e9dba703e2cba86adb84847; dnt=1; auth_multi="1710606417324015616:651d779cb99b54e7ce81bf8f8050e597c452b50b"; twid=u=1761426801275097088; ads_prefs="HBESAAA="; guest_id=v:173815032724456303; ct0=8e701469358130a26157f02b7836e3a17585153e07ff333ad6f79409f9f5db9cb544eee4c8c56e7b18670beb159e36ace3206471c8bab809602292d60975006c02331f898b8114dbaca1aa2a1c4b0719; cf_clearance=KzFqh7S1eP8KW3jTDxoqJSA7wHoUFaU8Pd.SnFK7vpI-1752517603-1.2.1.1-IJsXxsVSGoUIhnuYX.jp8Q7BI47TUmy90fjmkj2VV6fjCGREbY92GKSfXUzVTHpnZCIe2B5TYBBqQBFX4eeaCjFKXtXQNwG3HEO.qOxqFgdIvvOB03di0ZEHhhDoPGDZfny62hpWMTFZOBbh9bdr5RRA7I8eUdHHBt5thctu.bRV7._mev_T_6SE4nt_SGxpAQRW5NHzBJLfyoWG6W6SfYNl59LCYnlDIcSfcvGq6.U; lang=en; __cf_bm=_rT5xRA6wTXq8eU3g5GKsmTkRcis.Kio4eMiItRdf1o-1752577372-1.0.1.1-0GbYnb9cjm30LzBGKItZ2zXdUlOiGkKUR3A0hgzCX.t4xnOIdBqhNW4uGuTXmeGtBh7iCIWOKtbI1zwI09AhoTX3QKEHiMjxwLv9N4sE1SI`; +export const second = `auth_token=fbfae81af3e0f4864b95ebd57bb64a2bd90c88f9;guest_id=v1%3A175666063354353443;__cf_bm=buNVVS7Cr7Kk3QGX4Gd7KI8jnwIWWh3k7l2bZkfHGnE-1761497629.346858-1.0.1.1-mNrW9iLykA6uImGT9OnSKHclCD71D_2qQSLpFxFT1FC5Z4Qn1I7VS1ji5pNfS6k4PhlYkK6BnHq0p5gU_BAUH7rW9Lb1U5NjRlVmy2m20ghy.xiv0_t4h4arbMWrkUSR;twid=u%3D1633158398555353096;external_referer=padhuUp37zgMUM44MkDWArGK1spFbbIK|0|8e8t2xd8A2w%3D;lang=en;__cuid=7d2dc54e7dc6494db2e0b5f6ba22fe42;ct0=c3df8a4a191d504bb88b5bc1c0364f29a3aec9779b8b3eb1bdac3d8c2db71ccc1be094abe5f6a3ac01dd90ec08d2198be4762bf9f9537007f22a1c93202e5a78d616ae725e6826df3e63e5d1c468bd6b;d_prefs=MjoxLGNvbnNlbnRfdmVyc2lvbjoyLHRleHRfdmVyc2lvbjoxMDAw;guest_id_ads=v1%3A175666063354353443;guest_id_marketing=v1%3A175666063354353443;kdt=Wb4iiFrzVHltVOgQLrHNSlrViq7Q8Zj6RGImz7dW;personalization_id="v1_kJCXAbmibH2kUGyOROCScg=="`; + +export const sa = `auth_token=651d779cb99b54e7ce81bf8f8050e597c452b50b;gt=1868678280205029867;guest_id=v1%3A176150648682600359;__cf_bm=mfj12NkVbU0uPRz84om3BecRhAwv.8CCo.w0B4Gw45o-1761513085.2856057-1.0.1.1-gHPgX0w_GRneFL_GLZ5kPSXd7VCe4EuZnxN9FeKlpRg5PR5CCFrHazEjrE_EM53YxTbd1hDJoaWNXSU.Abr7FmBF5aCso.Jqn6M1ycwSXLhvQ3nTdZXRfiGpDrQugR6q;twid=u%3D1710606417324015616;g_state={"i_p":1733335699025,"i_l":1};external_referer=8e8t2xd8A2w%3D|0|F8C7rVpldvH1T6C9tW%2FBRRUSYlUEkKIshjiEn%2Br%2F70sqqRYvQQ%2FYUQ%3D%3D;auth_multi="1761426801275097088:f10fe04281e0bdf50e9dba703e2cba86adb84847";lang=en;night_mode=2;__cf_bm=koa1LKRskRZYwhHNG6YcIcSFN87JPc24Tb3umQqxxxU-1761507653-1.0.1.1-9OQutUp3hCAW8o5Sr8S0h.uE0eTWkxGRYiiESZu6WW9Q_Y_out9M6G9FqS5n9BVn1svlAUT97_FyLZosboQ45Q0M_HZ4F_rYtd3ojsAm6n8;__cuid=b4c86e91126647b5b7e07dadeac8ec2b;ct0=678e86afdb647f32835e25d1204c99af50b95d0e9ebcaf9db2d837fb9c4e48759a0e218c4845337f429c10e8d95f8a97cb9a7cee84c1bc5a7b522f440d5b6824466b6f4b62c328cc32d980a22fb00745;dnt=1;guest_id_ads=v1%3A176150648682600359;guest_id_marketing=v1%3A176150648682600359;kdt=camHTtNmw0pQUK0ZHaYd6jJnJRRYihbuZ0Ii7yN2;personalization_id="v1_KKOJ4dZoYTZuKLY9eadfIg=="`; |
