├── bun.lockb ├── .eslintrc.json ├── src ├── state.ts ├── pglite-worker.ts ├── app │ ├── api │ │ └── get-footer-ads │ │ │ └── route.ts │ ├── global.css │ ├── providers.tsx │ ├── layout.tsx │ └── page.tsx ├── components │ ├── posthog-pageview.tsx │ ├── footer.tsx │ ├── generate-delete-script-button.tsx │ └── dropzone-tweet-js.tsx ├── theme.ts ├── database │ ├── schema.ts │ └── db.ts └── screens │ ├── initial-page.tsx │ └── search-page.tsx ├── panda.config.ts ├── postcss.config.cjs ├── .gitignore ├── tsconfig.json ├── next.config.mjs ├── README.md ├── package.json └── LICENSE /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bgwastu/deletex/HEAD/bun.lockb -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"], 3 | "rules": { 4 | "@typescript-eslint/no-explicit-any": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export type AppState = "loading" | "initial" | "ready"; 4 | 5 | export const appStateAtom = atom("loading"); 6 | -------------------------------------------------------------------------------- /src/pglite-worker.ts: -------------------------------------------------------------------------------- 1 | import { PGlite } from "@electric-sql/pglite"; 2 | import { worker } from "@electric-sql/pglite/worker"; 3 | 4 | worker({ 5 | async init() { 6 | return new PGlite("idb://twt-data"); 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/api/get-footer-ads/route.ts: -------------------------------------------------------------------------------- 1 | import { get } from "@vercel/edge-config"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function GET() { 5 | const res = await get("footer_ads"); 6 | 7 | return NextResponse.json(res); 8 | } 9 | -------------------------------------------------------------------------------- /panda.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@pandacss/dev"; 2 | 3 | export default defineConfig({ 4 | preflight: true, 5 | include: ["./src/**/*.{js,jsx,ts,tsx}"], 6 | exclude: [], 7 | theme: { 8 | extend: {}, 9 | }, 10 | outdir: "src/styled-system", 11 | lightningcss: true, 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/global.css: -------------------------------------------------------------------------------- 1 | @import url("@mantine/core/styles.css"); 2 | @import url("@mantine/dropzone/styles.css"); 3 | @import url("@mantine/notifications/styles.css"); 4 | @import url("@mantine/dates/styles.css"); 5 | @import url("@mantine/code-highlight/styles.css"); 6 | 7 | @layer reset, base, tokens, recipes, utilities; 8 | 9 | body { 10 | background-color: var(--mantine-color-brand-0); 11 | } 12 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-preset-mantine": {}, 4 | "postcss-simple-vars": { 5 | variables: { 6 | "mantine-breakpoint-xs": "36em", 7 | "mantine-breakpoint-sm": "48em", 8 | "mantine-breakpoint-md": "62em", 9 | "mantine-breakpoint-lg": "75em", 10 | "mantine-breakpoint-xl": "88em", 11 | }, 12 | }, 13 | "@pandacss/dev/postcss": {}, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import posthog from "posthog-js"; 3 | import { PostHogProvider } from "posthog-js/react"; 4 | import { useEffect } from "react"; 5 | 6 | export function PHProvider({ children }: { children: React.ReactNode }) { 7 | useEffect(() => { 8 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { 9 | api_host: "/ingest", 10 | ui_host: "https://us.posthog.com", 11 | person_profiles: "identified_only", 12 | capture_pageview: false, // Disable automatic pageview capture, as we capture manually 13 | }); 14 | }, []); 15 | 16 | return {children}; 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # generated schema 39 | generated-schema/ 40 | 41 | ## Panda 42 | src/styled-system 43 | src/styled-system-studio -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/posthog-pageview.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname, useSearchParams } from "next/navigation"; 4 | import { useEffect } from "react"; 5 | import { usePostHog } from "posthog-js/react"; 6 | 7 | export default function PostHogPageView(): null { 8 | const pathname = usePathname(); 9 | const searchParams = useSearchParams(); 10 | const posthog = usePostHog(); 11 | useEffect(() => { 12 | // Track pageviews 13 | if (pathname && posthog) { 14 | let url = window.origin + pathname; 15 | if (searchParams.toString()) { 16 | url = url + `?${searchParams.toString()}`; 17 | } 18 | posthog.capture("$pageview", { 19 | $current_url: url, 20 | }); 21 | } 22 | }, [pathname, searchParams, posthog]); 23 | 24 | return null; 25 | } 26 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | swcMinify: false, 4 | experimental: { 5 | optimizePackageImports: ["@mantine/core", "@mantine/hooks"], 6 | }, 7 | transpilePackages: ["@electric-sql/pglite-repl", "@electric-sql/pglite"], 8 | async rewrites() { 9 | return [ 10 | { 11 | source: "/ingest/static/:path*", 12 | destination: "https://us-assets.i.posthog.com/static/:path*", 13 | }, 14 | { 15 | source: "/ingest/:path*", 16 | destination: "https://us.i.posthog.com/:path*", 17 | }, 18 | { 19 | source: "/ingest/decide", 20 | destination: "https://us.i.posthog.com/decide", 21 | }, 22 | ]; 23 | }, 24 | // This is required to support PostHog trailing slash API requests 25 | skipTrailingSlashRedirect: true, 26 | }; 27 | 28 | export default nextConfig; 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DeleteX 2 | 3 | A web application that allows you to delete content on X (formerly known as Twiter) without having to log in or giving any third-party app access to your account. 4 | 5 | See it in action at [deletex.wastu.net](https://deletex.wastu.net). 6 | 7 | ## Features 8 | 9 | * Delete all type of content on X (post, repost, and comments) 10 | * Powerful filtering options (date range, content type, min/max likes, min/max reposts, etc.) 11 | * Full-text search 12 | 13 | ## How it works 14 | 15 | DeleteX uses your X archive data to generate a userscript that you can run in your browser to delete your tweets. The script will run in your browser and will only delete the tweets you selected. 16 | 17 | ## Privacy 18 | 19 | DeleteX uses [PGLite](https://pglite.dev) as local storage to store your X archive data and the generated userscript. This means that your data is processed locally and never leaves your device. -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme, rem } from "@mantine/core"; 2 | 3 | export const theme = createTheme({ 4 | fontFamily: "var(--font-text)", 5 | headings: { 6 | fontFamily: "var(--font-header)", 7 | }, 8 | defaultRadius: 3, 9 | white: "#fff", 10 | black: "#334155", 11 | primaryColor: "brand", 12 | colors: { 13 | brand: [ 14 | "#f4f4f5", 15 | "#e6e6e6", 16 | "#cacaca", 17 | "#aeaeae", 18 | "#959597", 19 | "#868689", 20 | "#7e7e83", 21 | "#6c6c71", 22 | "#5f5f67", 23 | "#52525c", 24 | ], 25 | }, 26 | primaryShade: 9, 27 | fontSizes: { 28 | xs: rem(12), 29 | sm: rem(16), 30 | md: rem(18), 31 | lg: rem(20), 32 | xl: rem(22), 33 | }, 34 | components: { 35 | Text: { 36 | defaultProps: { 37 | color: "brand.8", 38 | }, 39 | }, 40 | Anchor: { 41 | defaultProps: { 42 | underline: "always", 43 | fw: 600, 44 | }, 45 | }, 46 | SegmentedControl: { 47 | defaultProps: { 48 | bg: "brand.1", 49 | }, 50 | }, 51 | Button: { 52 | defaultProps: { 53 | style: { 54 | textDecoration: "none", 55 | }, 56 | }, 57 | }, 58 | }, 59 | }); 60 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { theme } from "@/theme"; 2 | import { ColorSchemeScript, MantineProvider } from "@mantine/core"; 3 | import { Notifications } from "@mantine/notifications"; 4 | import type { Metadata } from "next"; 5 | import { Figtree, Lora } from "next/font/google"; 6 | import "./global.css"; 7 | import { PHProvider } from "./providers"; 8 | 9 | const headerFont = Lora({ 10 | subsets: ["latin"], 11 | weight: ["700"], 12 | variable: "--font-header", 13 | }); 14 | 15 | const textFont = Figtree({ 16 | subsets: ["latin"], 17 | weight: ["500", "600", "700"], 18 | variable: "--font-text", 19 | }); 20 | 21 | export const metadata: Metadata = { 22 | title: "DeleteX", 23 | description: "Selectively delete your content on X (formerly Twitter).", 24 | }; 25 | 26 | export default function RootLayout({ 27 | children, 28 | }: Readonly<{ 29 | children: React.ReactNode; 30 | }>) { 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {children} 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { db, migrate } from "@/database/db"; 4 | import InitialPage from "@/screens/initial-page"; 5 | import SearchPage from "@/screens/search-page"; 6 | import { appStateAtom } from "@/state"; 7 | import { Loader, Stack, Text } from "@mantine/core"; 8 | import { sql } from "drizzle-orm"; 9 | import { useAtom } from "jotai"; 10 | import { useEffect } from "react"; 11 | 12 | export default function Home() { 13 | const [appState, setAppState] = useAtom(appStateAtom); 14 | 15 | useEffect( 16 | function initial() { 17 | (async () => { 18 | await migrate(); 19 | const res = await db?.execute(sql`SELECT COUNT(*) FROM tweets;`); 20 | const count = res?.rows[0]?.count; 21 | 22 | if (count === 0) { 23 | setAppState("initial"); 24 | } else { 25 | setAppState("ready"); 26 | } 27 | })(); 28 | }, 29 | [setAppState] 30 | ); 31 | 32 | if (appState === "loading") { 33 | return ( 34 | 35 | 36 | Initializing, please wait... 37 | 38 | ); 39 | } else if (appState === "ready") { 40 | return ; 41 | } else if (appState === "initial") { 42 | return ; 43 | } 44 | 45 | return <>; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { clear } from "@/database/db"; 4 | import { appStateAtom } from "@/state"; 5 | import { Anchor, Button, Flex } from "@mantine/core"; 6 | import { useFetch } from "@mantine/hooks"; 7 | import { useSetAtom } from "jotai"; 8 | import { useCallback, useState } from "react"; 9 | 10 | interface FooterAds { 11 | text: string; 12 | url: string; 13 | } 14 | 15 | export default function Footer() { 16 | const [isDeleting, setIsDeleting] = useState(false); 17 | const { data, loading } = useFetch("/api/get-footer-ads"); 18 | const setAppState = useSetAtom(appStateAtom); 19 | 20 | const resetData = useCallback(async () => { 21 | const confirmation = confirm("Are you sure you want to reset all data?"); 22 | if (!confirmation) return; 23 | 24 | setIsDeleting(true); 25 | await clear(); 26 | setAppState("initial"); 27 | setIsDeleting(false); 28 | }, [setAppState]); 29 | 30 | return ( 31 | 32 | 40 | 41 | {loading ? "Loading..." : data?.text} 42 | 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deletex", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "prepare": "panda codegen", 7 | "dev": "next dev", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "generate-schema": "rm -rf generated-schema/ && bun drizzle-kit generate --dialect postgresql --driver pglite --name init --schema src/database/schema.ts --out generated-schema" 12 | }, 13 | "dependencies": { 14 | "@electric-sql/pglite": "^0.2.6", 15 | "@electric-sql/pglite-repl": "^0.2.6", 16 | "@mantine/code-highlight": "^7.12.2", 17 | "@mantine/core": "^7.12.2", 18 | "@mantine/dates": "^7.12.2", 19 | "@mantine/dropzone": "^7.12.2", 20 | "@mantine/form": "^7.12.2", 21 | "@mantine/hooks": "^7.12.2", 22 | "@mantine/notifications": "^7.12.2", 23 | "@tabler/icons-react": "^3.17.0", 24 | "@vercel/edge-config": "^1.3.0", 25 | "dayjs": "^1.11.13", 26 | "drizzle-orm": "^0.33.0", 27 | "jotai": "^2.9.3", 28 | "next": "14.2.35", 29 | "posthog-js": "^1.164.2", 30 | "react": "^19", 31 | "react-dom": "^19", 32 | "tough-cookie": "^4.1.4" 33 | }, 34 | "devDependencies": { 35 | "@pandacss/dev": "^0.46.0", 36 | "@types/node": "^20", 37 | "@types/react": "^18", 38 | "@types/react-dom": "^18", 39 | "drizzle-kit": "^0.24.2", 40 | "eslint": "^8", 41 | "eslint-config-next": "14.2.8", 42 | "postcss": "^8.4.45", 43 | "postcss-preset-mantine": "^1.17.0", 44 | "postcss-simple-vars": "^7.0.1", 45 | "typescript": "^5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { InferSelectModel, relations, sql } from "drizzle-orm"; 2 | import { 3 | index, 4 | integer, 5 | pgEnum, 6 | pgTable, 7 | timestamp, 8 | varchar, 9 | } from "drizzle-orm/pg-core"; 10 | 11 | export const typeEnum = pgEnum("type", ["tweet", "retweet", "reply"]); 12 | export type TweetType = "tweet" | "retweet" | "reply"; 13 | 14 | export const tweets = pgTable( 15 | "tweets", 16 | { 17 | id: varchar("id").primaryKey(), 18 | text: varchar("text"), 19 | retweet: integer("retweet"), 20 | likes: integer("likes"), 21 | type: typeEnum("type"), 22 | createdAt: timestamp("created_at"), 23 | }, 24 | (table) => ({ 25 | textSearchIndex: index("text_search_index").using( 26 | "gin", 27 | sql`to_tsvector('english', ${table.text})` 28 | ), 29 | typeIndex: index("type_index").on(table.type), 30 | }) 31 | ); 32 | 33 | export const tweetRelations = relations(tweets, ({ many }) => ({ 34 | media: many(media), 35 | })); 36 | 37 | export type Tweet = InferSelectModel; 38 | export type TweetMedia = Tweet & { media: Media[] }; 39 | 40 | export const media = pgTable( 41 | "media", 42 | { 43 | id: varchar("id").primaryKey(), 44 | tweetId: varchar("tweet_id").references(() => tweets.id, { 45 | onDelete: "cascade", 46 | onUpdate: "cascade", 47 | }), 48 | previewUrl: varchar("url"), 49 | type: varchar("type"), 50 | }, 51 | (table) => ({ 52 | tweetIdIndex: index("tweet_id_index").on(table.tweetId), 53 | }) 54 | ); 55 | 56 | export const mediaRelations = relations(media, ({ one }) => ({ 57 | tweet: one(tweets, { 58 | fields: [media.tweetId], 59 | references: [tweets.id], 60 | }), 61 | })); 62 | 63 | export type Media = InferSelectModel; 64 | -------------------------------------------------------------------------------- /src/database/db.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | /* eslint-disable no-var */ 4 | /* eslint-disable prefer-const */ 5 | import { PGliteWorker } from "@electric-sql/pglite/worker"; 6 | import { drizzle, PgliteDatabase } from "drizzle-orm/pglite"; 7 | import * as schema from "./schema"; 8 | 9 | let db: PgliteDatabase | undefined; 10 | let client: PGliteWorker | undefined; 11 | 12 | if (typeof window !== "undefined") { 13 | if (!client) { 14 | client = new PGliteWorker( 15 | new Worker(new URL("../pglite-worker.ts", import.meta.url), { 16 | type: "module", 17 | }) 18 | ); 19 | } 20 | 21 | if (!db) { 22 | db = drizzle(client as any, { schema }); 23 | } 24 | } 25 | 26 | export { db, client }; 27 | 28 | /** 29 | * Executes the database migration. 30 | * 31 | * The query is manually generated by the drizzle-kit since the app is run in a browser. 32 | */ 33 | export async function migrate() { 34 | // generated by drizzle-kit 35 | const query = `DO $$ BEGIN 36 | CREATE TYPE "public"."type" AS ENUM('tweet', 'retweet', 'reply'); 37 | EXCEPTION 38 | WHEN duplicate_object THEN null; 39 | END $$; 40 | --> statement-breakpoint 41 | CREATE TABLE IF NOT EXISTS "media" ( 42 | "id" varchar PRIMARY KEY NOT NULL, 43 | "tweet_id" varchar, 44 | "url" varchar, 45 | "type" varchar 46 | ); 47 | --> statement-breakpoint 48 | CREATE TABLE IF NOT EXISTS "tweets" ( 49 | "id" varchar PRIMARY KEY NOT NULL, 50 | "text" varchar, 51 | "retweet" integer, 52 | "likes" integer, 53 | "type" "type", 54 | "created_at" timestamp 55 | ); 56 | --> statement-breakpoint 57 | DO $$ BEGIN 58 | ALTER TABLE "media" ADD CONSTRAINT "media_tweet_id_tweets_id_fk" FOREIGN KEY ("tweet_id") REFERENCES "public"."tweets"("id") ON DELETE cascade ON UPDATE cascade; 59 | EXCEPTION 60 | WHEN duplicate_object THEN null; 61 | END $$; 62 | --> statement-breakpoint 63 | CREATE INDEX IF NOT EXISTS "tweet_id_index" ON "media" USING btree ("tweet_id");--> statement-breakpoint 64 | CREATE INDEX IF NOT EXISTS "text_search_index" ON "tweets" USING gin (to_tsvector('english', "text"));--> statement-breakpoint 65 | CREATE INDEX IF NOT EXISTS "type_index" ON "tweets" USING btree ("type");`; 66 | 67 | await client?.exec(query); 68 | } 69 | 70 | /** 71 | * Delete all tables and remigrate the database. 72 | */ 73 | export async function clear() { 74 | const query = `DO $$ DECLARE 75 | r RECORD; 76 | BEGIN 77 | FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP 78 | EXECUTE 'DROP TABLE IF EXISTS ' || r.tablename || ' CASCADE'; 79 | END LOOP; 80 | END $$; 81 | `; 82 | await client?.exec(query); 83 | await migrate(); 84 | } 85 | -------------------------------------------------------------------------------- /src/screens/initial-page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import DropzoneTweetJs from "@/components/dropzone-tweet-js"; 4 | import { db } from "@/database/db"; 5 | import { media, TweetMedia, tweets } from "@/database/schema"; 6 | import { appStateAtom } from "@/state"; 7 | import { 8 | Button, 9 | Container, 10 | Divider, 11 | List, 12 | rem, 13 | Stack, 14 | Text, 15 | ThemeIcon, 16 | Title, 17 | } from "@mantine/core"; 18 | import { notifications } from "@mantine/notifications"; 19 | import { IconCheck } from "@tabler/icons-react"; 20 | import { useSetAtom } from "jotai"; 21 | import { useState } from "react"; 22 | 23 | export default function InitialPage() { 24 | const [listTweet, setListTweet] = useState(null); 25 | const [loading, setLoading] = useState(false); 26 | const setAppState = useSetAtom(appStateAtom); 27 | 28 | async function saveTweetsToDatabase() { 29 | if (listTweet === null) return; 30 | setLoading(true); 31 | 32 | try { 33 | // Chunk tweets for insertion 34 | const MAX_INSERT_BATCH_SIZE = 1000; 35 | for (let i = 0; i < listTweet.length; i += MAX_INSERT_BATCH_SIZE) { 36 | await db 37 | ?.insert(tweets) 38 | .values(listTweet.slice(i, i + MAX_INSERT_BATCH_SIZE)) 39 | .execute(); 40 | } 41 | 42 | // insert media 43 | const mediaList = listTweet 44 | .map((tweet) => tweet.media) 45 | .flat() 46 | // filter duplicate media 47 | .filter( 48 | (media, index, self) => 49 | index === self.findIndex((t) => t.id === media.id) 50 | ); 51 | 52 | // Chunk media for insertion 53 | for (let i = 0; i < mediaList.length; i += MAX_INSERT_BATCH_SIZE) { 54 | await db 55 | ?.insert(media) 56 | .values(mediaList.slice(i, i + MAX_INSERT_BATCH_SIZE)) 57 | .execute(); 58 | } 59 | 60 | notifications.show({ 61 | title: "Success", 62 | message: "Tweets imported successfully", 63 | }); 64 | setAppState("ready"); 65 | } catch (e: any) { 66 | console.error("Insertion error:", e); 67 | notifications.show({ 68 | title: "Error", 69 | message: "Failed to save data to database. See console for details.", 70 | color: "red", 71 | }); 72 | } finally { 73 | setLoading(false); 74 | } 75 | } 76 | 77 | return ( 78 | 79 | 80 | 81 | DeleteX 82 | Selectively delete your content on X (formerly Twitter). 83 | 84 | 85 | Features 86 | 92 | 93 | 94 | } 95 | > 96 | 97 | Deleting content using scripts (no auth/token required) 98 | 99 | 100 | Powerful filtering options (date, media, etc.) 101 | 102 | Full-text search 103 | 104 | 105 | 106 | 107 | {listTweet !== null && ( 108 | 111 | )} 112 | 113 | 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/components/generate-delete-script-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CodeHighlight } from "@mantine/code-highlight"; 4 | import { 5 | Button, 6 | CopyButton, 7 | Modal, 8 | ScrollArea, 9 | Stack, 10 | Text, 11 | } from "@mantine/core"; 12 | import { useDisclosure } from "@mantine/hooks"; 13 | import { IconCheck, IconCopy, IconFileTypeJs } from "@tabler/icons-react"; 14 | import { useState } from "react"; 15 | 16 | export default function GenerateDeleteScriptButton({ 17 | tweetIds, 18 | disabled, 19 | }: { 20 | tweetIds: string[]; 21 | disabled?: boolean; 22 | }) { 23 | const [opened, { open, close }] = useDisclosure(false); 24 | const [isLoading, setIsLoading] = useState(false); 25 | 26 | return ( 27 |
28 | 29 | 30 | 31 | 32 | STEP 1 33 | 34 | {` 35 | Open your browser's console (usually by pressing F12 or 36 | right-clicking on the page and selecting "Inspect" or "Inspect 37 | Element", then navigating to the "Console" tab). 38 | `} 39 | 40 | 41 | 42 | STEP 2 43 | 44 | 45 | Copy and paste the script into the console and press Enter. 46 | 47 | 48 | {tweetIds.length < 5000 ? ( 49 | 50 | 51 | 52 | ) : null} 53 | 54 | {({ copied, copy }) => ( 55 | 64 | )} 65 | 66 | 67 | 68 | 81 |
82 | ); 83 | } 84 | 85 | export function generate(tweetIds: string[]) { 86 | return ` 87 | var TweetDeleter = { 88 | deleteURL: "https://x.com/i/api/graphql/VaenaVgh5q5ih7kvyVjgtg/DeleteTweet", 89 | lastHeaders: {}, 90 | tIds: [${tweetIds.map((id) => `"${id}"`).join(", ")}], 91 | dCount: 0, 92 | 93 | init: function() { 94 | this.initXHR(); 95 | this.confirmDeletion(); 96 | }, 97 | 98 | initXHR: function() { 99 | var XHR_OpenOriginal = XMLHttpRequest.prototype.open; 100 | XMLHttpRequest.prototype.open = function () { 101 | if (arguments[1] && arguments[1].includes("DeleteTweet")) { 102 | TweetDeleter.deleteURL = arguments[1]; 103 | } 104 | XHR_OpenOriginal.apply(this, arguments); 105 | }; 106 | 107 | var XHR_SetRequestHeaderOriginal = 108 | XMLHttpRequest.prototype.setRequestHeader; 109 | XMLHttpRequest.prototype.setRequestHeader = function (a, b) { 110 | TweetDeleter.lastHeaders[a] = b; 111 | XHR_SetRequestHeaderOriginal.apply(this, arguments); 112 | }; 113 | }, 114 | 115 | confirmDeletion: async function() { 116 | if ( 117 | confirm("Are you sure you want to delete " + this.tIds.length + " tweets?") 118 | ) { 119 | this.deleteTweets(); 120 | } 121 | }, 122 | 123 | deleteTweets: async function() { 124 | while (!("authorization" in this.lastHeaders)) { 125 | await this.sleep(1000); 126 | console.log("Waiting for authorization..."); 127 | } 128 | 129 | console.log("Starting deletion..."); 130 | 131 | while (this.tIds.length > 0) { 132 | this.tId = this.tIds.pop(); 133 | try { 134 | let response = await fetch(this.deleteURL, { 135 | headers: { 136 | accept: "*/*", 137 | "accept-language": "en-US,en;q=0.5", 138 | authorization: this.lastHeaders.authorization, 139 | "content-type": "application/json", 140 | "sec-fetch-dest": "empty", 141 | "sec-fetch-mode": "cors", 142 | "sec-fetch-site": "same-origin", 143 | "x-client-transaction-id": 144 | this.lastHeaders["X-Client-Transaction-Id"], 145 | "x-client-uuid": this.lastHeaders["x-client-uuid"], 146 | "x-csrf-token": this.lastHeaders["x-csrf-token"], 147 | "x-twitter-active-user": "yes", 148 | "x-twitter-auth-type": "OAuth2Session", 149 | "x-twitter-client-language": "en", 150 | }, 151 | referrer: "https://x.com/" + document.location.href.split("/")[3] + "/with_replies", 152 | referrerPolicy: "strict-origin-when-cross-origin", 153 | body: '{"variables":{"tweet_id":"' + this.tId + '","dark_request":false},"queryId":"' + this.deleteURL.split("/")[6] + '"}', 154 | method: "POST", 155 | mode: "cors", 156 | credentials: "include", 157 | }); 158 | 159 | if (response.status == 200) { 160 | this.dCount++; 161 | console.log("Deleted tweet ID: " + this.tId + " - " + this.tIds.length + " tweets remaining..."); 162 | } else { 163 | console.log("Failed to delete tweet ID: " + this.tId, response); 164 | } 165 | } catch (error) { 166 | console.log("Error deleting tweet ID: " + this.tId, error); 167 | } 168 | } 169 | 170 | console.log("Tweet deletion complete!"); 171 | alert("Tweet deletion complete!"); 172 | }, 173 | 174 | sleep: function(ms) { 175 | return new Promise(function(resolve) { 176 | setTimeout(resolve, ms); 177 | }); 178 | }, 179 | }; 180 | 181 | TweetDeleter.init();`; 182 | } 183 | -------------------------------------------------------------------------------- /src/components/dropzone-tweet-js.tsx: -------------------------------------------------------------------------------- 1 | import { TweetMedia, TweetType } from "@/database/schema"; 2 | import { 3 | Anchor, 4 | BoxProps, 5 | Flex, 6 | Group, 7 | Pill, 8 | rem, 9 | Stack, 10 | Text, 11 | } from "@mantine/core"; 12 | import { Dropzone } from "@mantine/dropzone"; 13 | import { notifications } from "@mantine/notifications"; 14 | import { IconFileTypeJs, IconUpload, IconX } from "@tabler/icons-react"; 15 | import { useState } from "react"; 16 | 17 | type Props = { 18 | tweets: TweetMedia[] | null; 19 | setTweets: (tweets: TweetMedia[] | null) => void; 20 | } & BoxProps; 21 | 22 | export default function DropzoneTweetJs({ 23 | tweets, 24 | setTweets, 25 | ...props 26 | }: Props) { 27 | const [loading, setLoading] = useState(false); 28 | const [file, setFile] = useState(null); 29 | return ( 30 | 31 | {file === null ? ( 32 | 33 | { 36 | setLoading(true); 37 | try { 38 | const tweets = await fileToTweets(files[0]); 39 | setFile(files[0]); 40 | setTweets(tweets); 41 | } catch (error) { 42 | console.error("Error processing file:", error); 43 | notifications.show({ 44 | title: "Error", 45 | message: 46 | "Failed to process the file. Please check the console for more details.", 47 | color: "red", 48 | }); 49 | } finally { 50 | setLoading(false); 51 | } 52 | }} 53 | onReject={(files) => { 54 | files[0].errors.map((e) => { 55 | notifications.show({ 56 | title: `Error on file ${files[0].file.name}`, 57 | message: e.message, 58 | color: "red", 59 | }); 60 | }); 61 | }} 62 | multiple={false} 63 | > 64 | 70 | 71 | 79 | 80 | 81 | 89 | 90 | 91 | 99 | 100 | 101 | 102 | Upload your tweets.js file 103 | 104 | 105 | You can also drag and drop the file here 106 | 107 | 108 | 109 | 110 | 111 | Before continuing, you need to import tweets.js file from your X 112 | archive.{" "} 113 | 117 | See how to download it 118 | 119 | . 120 | 121 | 122 | ) : ( 123 | 124 | 125 | { 131 | setFile(null); 132 | setTweets(null); 133 | }, 134 | }} 135 | > 136 | {file?.name} ({tweets?.length} posts) 137 | 138 | 139 | 140 | 141 | Posts:{" "} 142 | 143 | {tweets?.filter((tweet) => tweet.type === "tweet").length} 144 | 145 | 146 | 147 | Retweets:{" "} 148 | 149 | {tweets?.filter((tweet) => tweet.type === "retweet").length}{" "} 150 | 151 | 152 | 153 | Replies:{" "} 154 | 155 | {tweets?.filter((tweet) => tweet.type === "reply").length}{" "} 156 | 157 | 158 | 159 | 160 | )} 161 | 162 | ); 163 | } 164 | 165 | async function fileToTweets(file: File): Promise { 166 | const content = await file.text(); 167 | const jsonString = content.replace(/^window\.YTD\.tweets\.part0 = /, ""); 168 | if (!jsonString) throw new Error("Invalid tweets.js file"); 169 | 170 | try { 171 | const data = JSON.parse(jsonString); 172 | 173 | return data.map((data: any) => { 174 | const tweet = data.tweet; 175 | 176 | let type: TweetType = "tweet"; 177 | 178 | if (tweet.in_reply_to_status_id_str !== undefined) { 179 | type = "reply"; 180 | } else if (tweet.full_text.startsWith("RT @")) { 181 | type = "retweet"; 182 | } 183 | 184 | return { 185 | id: tweet.id_str, 186 | text: tweet.full_text, 187 | retweet: Number(tweet.retweet_count), 188 | likes: Number(tweet.favorite_count), 189 | createdAt: new Date(tweet.created_at), 190 | type, 191 | media: [...(tweet.extended_entities?.media || [])].map( 192 | (media: any) => ({ 193 | id: media.id_str, 194 | tweetId: tweet.id_str, 195 | previewUrl: media.media_url_https, 196 | type: media.type, 197 | }) 198 | ), 199 | } as TweetMedia; 200 | }); 201 | } catch (error) { 202 | console.error(error); 203 | throw new Error("Invalid tweets.js file"); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/screens/search-page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Footer from "@/components/footer"; 4 | import GenerateDeleteScriptButton from "@/components/generate-delete-script-button"; 5 | import { db } from "@/database/db"; 6 | import { media, TweetMedia, tweets } from "@/database/schema"; 7 | import { css } from "@/styled-system/css"; 8 | import { 9 | Anchor, 10 | Box, 11 | Button, 12 | Center, 13 | Checkbox, 14 | Container, 15 | Flex, 16 | Group, 17 | Image, 18 | Input, 19 | Loader, 20 | MultiSelect, 21 | NumberInput, 22 | Popover, 23 | SimpleGrid, 24 | Stack, 25 | Text, 26 | TextInput, 27 | ThemeIcon, 28 | } from "@mantine/core"; 29 | import { DatePickerInput } from "@mantine/dates"; 30 | import { useForm } from "@mantine/form"; 31 | import { useDebouncedCallback, useDisclosure } from "@mantine/hooks"; 32 | import { notifications } from "@mantine/notifications"; 33 | import { 34 | IconArticle, 35 | IconFilter, 36 | IconFilterFilled, 37 | IconHeart, 38 | IconHeartFilled, 39 | IconMessage, 40 | IconPlayerPlayFilled, 41 | IconRepeat, 42 | } from "@tabler/icons-react"; 43 | import { and, desc, eq, exists, gte, lt, lte, or, sql } from "drizzle-orm"; 44 | import { useCallback, useEffect, useMemo, useState } from "react"; 45 | 46 | const PAGE_SIZE = 20; 47 | const TWEET_TYPES = [ 48 | { value: "tweet", label: "Post" }, 49 | { value: "retweet", label: "Repost" }, 50 | { value: "reply", label: "Reply" }, 51 | ]; 52 | 53 | export default function SearchPage() { 54 | const [loadingState, setLoadingState] = useState< 55 | "initial" | "pagination" | "filter" | "reset" | "select_all" | null 56 | >("initial"); 57 | const [listTweet, setListTweet] = useState([]); 58 | const [selectedTweetId, setSelectedTweetId] = useState([]); 59 | const [query, setQuery] = useState(""); 60 | const [filterOpened, { close: closeFilter, open: openFilter }] = 61 | useDisclosure(false); 62 | const [totalPossibleTweet, setTotalPossibleTweet] = useState(0); 63 | 64 | const form = useForm({ 65 | initialValues: { 66 | tweetType: [] as ("retweet" | "tweet" | "reply")[], 67 | startDate: null as Date | null, 68 | endDate: null as Date | null, 69 | minLikes: null as number | null, 70 | maxLikes: null as number | null, 71 | minRetweet: null as number | null, 72 | maxRetweet: null as number | null, 73 | containsMedia: false, 74 | }, 75 | validate: { 76 | startDate: (value, values) => 77 | value && values.endDate && value > values.endDate 78 | ? "Start date must be before end date" 79 | : null, 80 | endDate: (value, values) => 81 | value && values.startDate && value < values.startDate 82 | ? "End date must be after start date" 83 | : null, 84 | }, 85 | }); 86 | 87 | const isSelectAll = useMemo( 88 | () => 89 | (listTweet.length > 0 && 90 | loadingState === null && 91 | listTweet.every((tweet) => selectedTweetId.includes(tweet.id))) || 92 | selectedTweetId.length === totalPossibleTweet, 93 | [listTweet, loadingState, selectedTweetId, totalPossibleTweet] 94 | ); 95 | 96 | const getWhereClause = useCallback(() => { 97 | const { 98 | startDate, 99 | endDate, 100 | minLikes, 101 | maxLikes, 102 | minRetweet, 103 | maxRetweet, 104 | tweetType, 105 | containsMedia, 106 | } = form.values; 107 | if (!db) return; 108 | 109 | const mediaSubquery = db 110 | .select() 111 | .from(media) 112 | .where(eq(media.tweetId, tweets.id)); 113 | return and( 114 | minLikes !== null && typeof minLikes !== "string" 115 | ? gte(tweets.likes, minLikes) 116 | : undefined, 117 | maxLikes !== null && typeof maxLikes !== "string" 118 | ? lte(tweets.likes, maxLikes) 119 | : undefined, 120 | minRetweet !== null && typeof minRetweet !== "string" 121 | ? gte(tweets.retweet, minRetweet) 122 | : undefined, 123 | maxRetweet !== null && typeof maxRetweet !== "string" 124 | ? lte(tweets.retweet, maxRetweet) 125 | : undefined, 126 | startDate ? gte(tweets.createdAt, startDate) : undefined, 127 | endDate ? lte(tweets.createdAt, endDate) : undefined, 128 | tweetType.length > 0 129 | ? or(...tweetType.map((type) => eq(tweets.type, type))) 130 | : undefined, 131 | containsMedia ? exists(mediaSubquery) : undefined 132 | ); 133 | }, [form.values]); 134 | 135 | const getListTweet = useCallback( 136 | async ( 137 | cursor?: Date, 138 | query?: string, 139 | columns?: any, 140 | pageSize: number = PAGE_SIZE, 141 | withMedia: boolean = true, 142 | countResult: boolean = true 143 | ) => { 144 | const whereClause = getWhereClause(); 145 | const searchClause = query 146 | ? sql`to_tsvector('english', ${tweets.text}) @@ plainto_tsquery('english', ${query})` 147 | : undefined; 148 | const cursorClause = cursor ? lt(tweets.createdAt, cursor) : undefined; 149 | 150 | const w = withMedia 151 | ? { 152 | media: { 153 | columns: { 154 | previewUrl: true, 155 | type: true, 156 | }, 157 | }, 158 | } 159 | : undefined; 160 | 161 | const tweetResults = await db?.query.tweets.findMany({ 162 | columns, 163 | limit: pageSize, 164 | where: and(cursorClause, whereClause, searchClause), 165 | with: w, 166 | orderBy: desc(tweets.createdAt), 167 | }); 168 | 169 | if (tweetResults && countResult) { 170 | const countResults = await db 171 | ?.select({ count: sql`count(*)`.mapWith(Number) }) 172 | .from(tweets) 173 | .where(and(cursorClause, whereClause, searchClause)); 174 | 175 | if (countResults?.[0].count) 176 | setTotalPossibleTweet(countResults[0].count); 177 | 178 | if (countResults?.[0].count === listTweet.length) return []; 179 | } 180 | 181 | return tweetResults as any[]; 182 | }, 183 | [getWhereClause, listTweet.length] 184 | ); 185 | 186 | const selectAll = useCallback(async () => { 187 | setLoadingState("select_all"); 188 | let tweetIds: string[] = []; 189 | let cursor; 190 | while (true) { 191 | const res = await getListTweet( 192 | cursor, 193 | query, 194 | { 195 | id: true, 196 | createdAt: true, 197 | }, 198 | 2000, 199 | false, 200 | false 201 | ); 202 | if (res.length === 0 || tweetIds.length >= totalPossibleTweet) break; 203 | tweetIds = Array.from( 204 | new Set(tweetIds.concat(res.map((tweet) => tweet.id))) 205 | ); 206 | setSelectedTweetId(tweetIds); 207 | cursor = res[res.length - 1].createdAt; 208 | } 209 | 210 | if (tweetIds) { 211 | setSelectedTweetId(tweetIds); 212 | } 213 | setLoadingState(null); 214 | }, [getListTweet, query, totalPossibleTweet]); 215 | 216 | const clearAll = useCallback(() => { 217 | setSelectedTweetId([]); 218 | }, []); 219 | 220 | const loadMore = useCallback(async () => { 221 | setLoadingState("pagination"); 222 | const lastTweet = listTweet[listTweet.length - 1]; 223 | const res = await getListTweet(lastTweet.createdAt ?? undefined); 224 | if (res) { 225 | setListTweet((prev) => [...prev, ...res]); 226 | } else { 227 | notifications.show({ 228 | title: "No more content", 229 | message: "You have reached the end of the contents.", 230 | }); 231 | } 232 | setLoadingState(null); 233 | }, [getListTweet, listTweet]); 234 | 235 | const applyFilter = useCallback(async () => { 236 | setLoadingState("filter"); 237 | const res = await getListTweet(); 238 | if (res) setListTweet(res); 239 | setLoadingState(null); 240 | closeFilter(); 241 | }, [getListTweet, closeFilter]); 242 | 243 | const handleSearch = useDebouncedCallback(async (keyword: string) => { 244 | if (keyword === "") { 245 | const res = await getListTweet(); 246 | if (res) setListTweet(res); 247 | return; 248 | } 249 | 250 | setListTweet([]); 251 | const res = await getListTweet(undefined, keyword); 252 | if (res) setListTweet(res); 253 | clearAll(); 254 | }, 500); 255 | 256 | const toggleTweetSelection = useCallback((tweetId: string) => { 257 | setSelectedTweetId((prev) => 258 | prev.includes(tweetId) 259 | ? prev.filter((id) => id !== tweetId) 260 | : [...prev, tweetId] 261 | ); 262 | }, []); 263 | 264 | const renderTweetTypeIcon = useCallback((type: string) => { 265 | const iconProps = { style: { width: "70%", height: "70%" } }; 266 | switch (type) { 267 | case "retweet": 268 | return ; 269 | case "reply": 270 | return ; 271 | default: 272 | return ; 273 | } 274 | }, []); 275 | 276 | useEffect(() => { 277 | getListTweet().then((res) => { 278 | if (res) setListTweet(res); 279 | setLoadingState(null); 280 | }); 281 | // eslint-disable-next-line react-hooks/exhaustive-deps 282 | }, []); 283 | 284 | if (loadingState === "initial") { 285 | return ( 286 | 287 | 288 | Load the data, please wait... 289 | 290 | ); 291 | } 292 | 293 | return ( 294 | 295 | 296 | 297 | 298 | { 304 | setQuery(e.currentTarget.value); 305 | handleSearch(e.currentTarget.value); 306 | }} 307 | /> 308 | 316 | 317 | 331 | 332 | 333 |
334 | 335 | 342 | 343 | 344 | 345 | 351 | 358 | 359 | 360 | 361 | 362 | 363 | } 370 | hideControls 371 | {...form.getInputProps("minLikes")} 372 | /> 373 | } 381 | hideControls 382 | {...form.getInputProps("maxLikes")} 383 | /> 384 | 385 | 386 | 387 | 388 | } 395 | hideControls 396 | {...form.getInputProps("minRetweet")} 397 | /> 398 | } 404 | step={1} 405 | hideControls 406 | {...form.getInputProps("maxRetweet")} 407 | /> 408 | 409 | 410 | 416 | 419 | 420 |
421 |
422 |
423 |
424 | {selectedTweetId.length > 0 && ( 425 | 426 | 427 | 435 | {`${selectedTweetId.length} of ${totalPossibleTweet} selected`} 439 | 440 | 444 | 445 | )} 446 |
447 | 448 | {listTweet.map((tweet) => ( 449 | toggleTweetSelection(tweet.id)} 462 | bg="white" 463 | > 464 | 465 | 466 | 467 | 468 | 469 | 480 | {renderTweetTypeIcon(tweet.type ?? "tweet")} 481 | 482 | 483 | {TWEET_TYPES.find((t) => t.value === tweet.type)?.label} 484 | 485 | 486 | 492 | {tweet.createdAt?.toLocaleDateString()}{" "} 493 | {tweet.createdAt?.toLocaleString([], { 494 | hour: "2-digit", 495 | minute: "2-digit", 496 | })} 497 | 498 | 499 | {tweet.text} 500 | {tweet.media && tweet.media.length > 0 && ( 501 | 509 | {tweet.media.map((mediaItem, index) => ( 510 | 516 | {`media-${index}`} 520 | {mediaItem.type !== "photo" && ( 521 | 525 |
534 | 539 | 540 | 541 |
542 |
543 | )} 544 |
545 | ))} 546 |
547 | )} 548 | 549 | 550 | 551 | 554 | 555 | 556 | {tweet.likes} 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | {tweet.retweet} 565 | 566 | 567 | 568 |
569 |
570 |
571 | ))} 572 |
573 | 582 |
583 |