├── .nvmrc ├── .husky └── pre-commit ├── vercel.json ├── public └── favicon.png ├── src ├── helpers │ ├── asyncHelpers.ts │ ├── domHelpers.ts │ ├── typeHelpers.ts │ ├── mastodonHelpers.ts │ ├── mastodonContext.tsx │ ├── overlayBugWorkaround.tsx │ └── authHelpers.ts ├── pages │ ├── _app.tsx │ ├── review.tsx │ └── index.tsx ├── components │ ├── block.tsx │ ├── followingsLoadingIndicator.tsx │ ├── htmlRendered.tsx │ ├── feedWidgetIframe.tsx │ ├── linkButton.tsx │ ├── emojify.tsx │ ├── textField.tsx │ ├── radioGroup.tsx │ ├── blurhashImage.tsx │ ├── reviewerPrompt.tsx │ ├── htmlReactParserOptions.tsx │ ├── popover.tsx │ ├── feedWidget.tsx │ ├── button.tsx │ ├── options.tsx │ ├── reviewerFooter.tsx │ ├── menu.tsx │ ├── reviewerButtons.tsx │ ├── finished.tsx │ ├── status.tsx │ └── reviewer.tsx ├── env │ ├── server.mjs │ ├── client.mjs │ └── schema.mjs ├── styles │ └── globals.css └── store │ ├── index.ts │ ├── selectors.ts │ └── actions.ts ├── .vscode └── settings.json ├── prettier.config.cjs ├── postcss.config.cjs ├── lint-staged.config.cjs ├── tailwind.config.cjs ├── next.config.mjs ├── .env.example ├── .gitignore ├── tsconfig.json ├── eslint.config.mjs ├── README.md ├── LICENSE.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.4.1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eramdam/tokimeki-mastodon/HEAD/public/favicon.png -------------------------------------------------------------------------------- /src/helpers/asyncHelpers.ts: -------------------------------------------------------------------------------- 1 | export function delayAsync(n: number) { 2 | return new Promise((resolve) => setTimeout(resolve, n)); 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | plugins: [require.resolve("prettier-plugin-tailwindcss")], 4 | }; 5 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "tailwindcss/nesting": "postcss-nesting", 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/helpers/domHelpers.ts: -------------------------------------------------------------------------------- 1 | import { isElement as lodashIsElement } from "lodash-es"; 2 | 3 | export function isElement(node: EventTarget | null): node is Element { 4 | return lodashIsElement(node); 5 | } 6 | -------------------------------------------------------------------------------- /lint-staged.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.{json,html,svg}": "prettier --write", 3 | "*.{ts,tsx}": ["eslint --fix", "prettier --write"], 4 | "*.{js,mjs,cjs}": ["eslint --fix", "prettier --write"], 5 | }; 6 | -------------------------------------------------------------------------------- /src/helpers/typeHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | 3 | export type PropsWithHtmlProps< 4 | E extends keyof JSX.IntrinsicElements, 5 | P = PropsWithChildren, 6 | > = Omit & P; 7 | -------------------------------------------------------------------------------- /src/helpers/mastodonHelpers.ts: -------------------------------------------------------------------------------- 1 | import type { TokimekiAccount } from "../store"; 2 | 3 | export function makeAccountName(account: TokimekiAccount) { 4 | return ( 5 | account.displayName.trim() || account.username.trim() || account.acct.trim() 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | /** @type {import('tailwindcss').Config} */ 3 | module.exports = { 4 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [require("@tailwindcss/typography")], 9 | }; 10 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. 4 | * This is especially useful for Docker builds. 5 | */ 6 | // eslint-disable-next-line @typescript-eslint/no-unused-expressions 7 | !process.env.SKIP_ENV_VALIDATION && (await import("./src/env/server.mjs")); 8 | 9 | /** @type {import("next").NextConfig} */ 10 | const config = { 11 | reactStrictMode: false, 12 | swcMinify: true, 13 | i18n: { 14 | locales: ["en"], 15 | defaultLocale: "en", 16 | }, 17 | }; 18 | export default config; 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Since .env is gitignored, you can use .env.example to build a new `.env` file when you clone the repo. 2 | # Keep this file up-to-date when you add new variables to `.env`. 3 | 4 | # This file will be committed to version control, so make sure not to have any secrets in it. 5 | # If you are cloning this repo, create a copy of this file named `.env` and populate it with your secrets. 6 | 7 | # When adding additional env variables, the schema in /env/schema.mjs should be updated accordingly 8 | 9 | # Example: 10 | # SERVERVAR=foo 11 | # NEXT_PUBLIC_CLIENTVAR=bar 12 | NEXT_PUBLIC_CLIENT_NAME= -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | 3 | import { type AppType } from "next/dist/shared/lib/utils"; 4 | import Head from "next/head"; 5 | import Script from "next/script"; 6 | 7 | const MyApp: AppType = ({ Component, pageProps }) => { 8 | return ( 9 | <> 10 | 11 | Tokimeki Mastodon 12 | 13 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default MyApp; 24 | -------------------------------------------------------------------------------- /src/components/block.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | import type { PropsWithHtmlProps } from "../helpers/typeHelpers"; 4 | 5 | export function Block(props: PropsWithHtmlProps<"div">) { 6 | const { className, children, ...rest } = props; 7 | return ( 8 |
18 | {children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/followingsLoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { Block } from "./block"; 2 | 3 | export function FollowingsLoadingIndicator() { 4 | return ( 5 | 6 |

Loading your followings

7 |
8 | {Array.from({ length: 6 }).map((_, i) => { 9 | return ( 10 |
15 | ); 16 | })} 17 |
18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # database 12 | /prisma/db.sqlite 13 | /prisma/db.sqlite-journal 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | next-env.d.ts 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 35 | .env 36 | .env*.local 37 | 38 | # vercel 39 | .vercel 40 | 41 | # typescript 42 | *.tsbuildinfo 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "noUncheckedIndexedAccess": true, 18 | "preserveWatchOutput": false 19 | }, 20 | "include": [ 21 | "next-env.d.ts", 22 | "**/*.ts", 23 | "**/*.tsx", 24 | "**/*.cjs", 25 | "**/*.mjs", 26 | ".eslintrc.cjs" 27 | ], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /src/components/htmlRendered.tsx: -------------------------------------------------------------------------------- 1 | import parse from "html-react-parser"; 2 | import type { mastodon } from "masto"; 3 | import { useMemo } from "react"; 4 | import { ErrorBoundary } from "react-error-boundary"; 5 | 6 | import { getParserOptions } from "./htmlReactParserOptions"; 7 | 8 | interface HtmlRendererProps { 9 | content: string; 10 | emojiArray: mastodon.v1.CustomEmoji[]; 11 | classNames?: { 12 | p?: string; 13 | a?: string; 14 | }; 15 | } 16 | 17 | export function HtmlRenderer(props: HtmlRendererProps) { 18 | const { content, emojiArray, classNames } = props; 19 | const parseOptions = useMemo( 20 | () => getParserOptions({ emojiArray, classNames }), 21 | [classNames, emojiArray], 22 | ); 23 | 24 | return ( 25 | { 27 | return {content}; 28 | }} 29 | > 30 | {parse(content, parseOptions)} 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/env/server.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars. 4 | * It has to be a `.mjs`-file to be imported there. 5 | */ 6 | import { env as clientEnv, formatErrors } from "./client.mjs"; 7 | import { serverSchema } from "./schema.mjs"; 8 | 9 | const _serverEnv = serverSchema.safeParse(process.env); 10 | 11 | if (!_serverEnv.success) { 12 | console.error( 13 | "❌ Invalid environment variables:\n", 14 | ...formatErrors(_serverEnv.error.format()), 15 | ); 16 | throw new Error("Invalid environment variables"); 17 | } 18 | 19 | for (let key of Object.keys(_serverEnv.data)) { 20 | if (key.startsWith("NEXT_PUBLIC_")) { 21 | console.warn("❌ You are exposing a server-side env-variable:", key); 22 | 23 | throw new Error("You are exposing a server-side env-variable"); 24 | } 25 | } 26 | 27 | export const env = { ..._serverEnv.data, ...clientEnv }; 28 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import globals from "globals"; 5 | 6 | export default [ 7 | { 8 | ignores: [".next/"], 9 | }, 10 | { 11 | languageOptions: { 12 | globals: { 13 | ...globals.node, 14 | ...globals.browser, 15 | }, 16 | }, 17 | }, 18 | eslint.configs.recommended, 19 | ...tseslint.configs.recommended, 20 | { 21 | rules: { 22 | "@typescript-eslint/consistent-type-imports": "warn", 23 | "@typescript-eslint/no-unused-vars": [ 24 | "error", 25 | { 26 | args: "all", 27 | argsIgnorePattern: "^_", 28 | caughtErrors: "all", 29 | caughtErrorsIgnorePattern: "^_", 30 | destructuredArrayIgnorePattern: "^_", 31 | varsIgnorePattern: "^_", 32 | ignoreRestSiblings: true, 33 | }, 34 | ], 35 | "no-unused-vars": "off", 36 | }, 37 | }, 38 | ]; 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tokimeki Mastodon 2 | 3 | This project is a clone of [Tokimeki Unfollow](https://tokimeki-unfollow.glitch.me/) for Mastodon. It lets you review the accounts you follow, and saves your progress in your browser's local storage. 4 | 5 | ## Contributing 6 | 7 | The project is based on the [T3 stack](https://create.t3.gg/), it uses: 8 | 9 | - [NextJS](https://nextjs.org/) for the server, and [React](https://reactjs.org) for the UI 10 | - [Tailwind](https://tailwindcss.com/) 11 | - [Zustand](https://github.com/pmndrs/zustand) for the state-managed, using its [persist](https://github.com/pmndrs/zustand/blob/main/docs/integrations/persisting-store-data.md) middleware to save the data locally. 12 | 13 | You will need NodeJS (see [.nvrmrc](./.nvmrc) for the version) installed. 14 | 15 | ### Setup steps 16 | 17 | - `npm install` 18 | - `npm run dev` for the local dev server 19 | - `npm run typecheck` and `npm run typecheck:watch` to run TypeScript's type-checking process 20 | 21 | TODO: write more documentation. 22 | -------------------------------------------------------------------------------- /src/env/client.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { clientEnv, clientSchema } from "./schema.mjs"; 3 | 4 | const _clientEnv = clientSchema.safeParse(clientEnv); 5 | 6 | export const formatErrors = ( 7 | /** @type {import('zod').ZodFormattedError,string>} */ 8 | errors, 9 | ) => 10 | Object.entries(errors) 11 | .map(([name, value]) => { 12 | if (value && "_errors" in value) 13 | return `${name}: ${value._errors.join(", ")}\n`; 14 | }) 15 | .filter(Boolean); 16 | 17 | if (!_clientEnv.success) { 18 | console.error( 19 | "❌ Invalid environment variables:\n", 20 | ...formatErrors(_clientEnv.error.format()), 21 | ); 22 | throw new Error("Invalid environment variables"); 23 | } 24 | 25 | for (let key of Object.keys(_clientEnv.data)) { 26 | if (!key.startsWith("NEXT_PUBLIC_")) { 27 | console.warn( 28 | `❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`, 29 | ); 30 | 31 | throw new Error("Invalid public environment variable name"); 32 | } 33 | } 34 | 35 | export const env = _clientEnv.data; 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Damien Erambert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/feedWidgetIframe.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | interface FeedWidgetProps { 4 | width: number; 5 | height: number; 6 | theme: "dark" | "light" | "auto"; 7 | showBoosts: boolean; 8 | showReplies: boolean; 9 | showHeader: boolean; 10 | url: string; 11 | } 12 | 13 | export function FeedWidgetIframe(props: FeedWidgetProps) { 14 | const { width, height, theme, showBoosts, showReplies, showHeader, url } = 15 | props; 16 | 17 | const iframeSrc = useMemo(() => { 18 | const frameUrl = new URL("https://www.mastofeed.com/apiv2/feed"); 19 | const params = new URLSearchParams({ 20 | userurl: url, 21 | theme, 22 | header: String(showHeader), 23 | boosts: String(showBoosts), 24 | replies: String(showReplies), 25 | size: "100", 26 | }).toString(); 27 | 28 | frameUrl.search = params; 29 | 30 | return frameUrl.toString(); 31 | }, [showBoosts, showHeader, showReplies, theme, url]); 32 | 33 | return ( 34 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/env/schema.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { z } from "zod"; 3 | 4 | /** 5 | * Specify your server-side environment variables schema here. 6 | * This way you can ensure the app isn't built with invalid env vars. 7 | */ 8 | export const serverSchema = z.object({ 9 | NODE_ENV: z.enum(["development", "test", "production"]), 10 | }); 11 | 12 | /** 13 | * Specify your client-side environment variables schema here. 14 | * This way you can ensure the app isn't built with invalid env vars. 15 | * To expose them to the client, prefix them with `NEXT_PUBLIC_`. 16 | */ 17 | export const clientSchema = z.object({ 18 | NEXT_PUBLIC_CLIENT_NAME: z.string(), 19 | // NEXT_PUBLIC_CLIENTVAR: z.string(), 20 | }); 21 | 22 | /** 23 | * You can't destruct `process.env` as a regular object, so you have to do 24 | * it manually here. This is because Next.js evaluates this at build time, 25 | * and only used environment variables are included in the build. 26 | * @type {{ [k in keyof z.infer]: z.infer[k] | undefined }} 27 | */ 28 | export const clientEnv = { 29 | NEXT_PUBLIC_CLIENT_NAME: process.env.NEXT_PUBLIC_CLIENT_NAME, 30 | // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/linkButton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { PropsWithChildren } from "react"; 3 | import { useRef } from "react"; 4 | import type { AriaButtonProps } from "react-aria"; 5 | import { useButton } from "react-aria"; 6 | 7 | interface LinkButtonProps extends PropsWithChildren> { 8 | className?: string; 9 | position?: "northwest" | "northeast" | "southwest" | "southeast"; 10 | } 11 | 12 | export function LinkButton(props: LinkButtonProps) { 13 | const ref = useRef(null); 14 | const { buttonProps } = useButton(props, ref); 15 | 16 | const positionClassnames: Record< 17 | NonNullable, 18 | string 19 | > = { 20 | northwest: "top-2 left-2", 21 | northeast: "top-2 right-2", 22 | southeast: "bottom-2 right-2", 23 | southwest: "bottom-2 left-2", 24 | }; 25 | 26 | return ( 27 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/emojify.tsx: -------------------------------------------------------------------------------- 1 | import { isString, keyBy } from "lodash-es"; 2 | import type { mastodon } from "masto"; 3 | 4 | export function renderWithEmoji( 5 | emojiArray: mastodon.v1.CustomEmoji[], 6 | text: string, 7 | ) { 8 | const emojiMap = keyBy(emojiArray, (e) => e.shortcode); 9 | const shortcodes = Object.keys(emojiMap).map((s) => `:${s}:`); 10 | 11 | if (emojiArray.length < 1) { 12 | return isString(text) ? <>{text} : text; 13 | } 14 | const regex = new RegExp(`(${shortcodes.join("|")})`, "i"); 15 | const textParts = String(text).split(regex); 16 | 17 | if (textParts.length === 1) { 18 | return isString(text) ? <>{text} : text; 19 | } 20 | 21 | return ( 22 | <> 23 | {textParts.map((part, index) => { 24 | if (part.startsWith(":") && part.endsWith(":")) { 25 | const shortcode = part.slice(1, -1); 26 | const emoji = emojiMap[shortcode]; 27 | if (!emoji) { 28 | return part; 29 | } 30 | 31 | return ( 32 | {emoji.shortcode} 38 | ); 39 | } 40 | 41 | return part; 42 | })} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/textField.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { PropsWithChildren } from "react"; 3 | import { useRef } from "react"; 4 | import type { AriaTextFieldOptions } from "react-aria"; 5 | import { useTextField } from "react-aria"; 6 | 7 | interface TextFieldProps extends AriaTextFieldOptions<"input"> { 8 | className?: string; 9 | } 10 | 11 | export function TextInput(props: PropsWithChildren) { 12 | const { label } = props; 13 | const ref = useRef(null); 14 | 15 | const { labelProps, inputProps } = useTextField(props, ref); 16 | 17 | return ( 18 |
19 | 22 | 32 | {/* {props.errorMessage && props.validationState === "invalid" && ( 33 |
34 | {props.errorMessage} 35 |
36 | )} */} 37 | {props.children} 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/radioGroup.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { PropsWithChildren } from "react"; 3 | import { createContext, useContext, useRef } from "react"; 4 | import type { AriaButtonProps, AriaRadioGroupProps } from "react-aria"; 5 | import { useRadio, useRadioGroup } from "react-aria"; 6 | import type { RadioGroupState } from "react-stately"; 7 | import { useRadioGroupState } from "react-stately"; 8 | 9 | const RadioContext = createContext(null); 10 | 11 | export function RadioGroup( 12 | props: PropsWithChildren, 13 | ) { 14 | const { label, children } = props; 15 | const state = useRadioGroupState(props); 16 | const { labelProps, radioGroupProps } = useRadioGroup(props, state); 17 | 18 | return ( 19 |
23 | {label} 24 | {children} 25 |
26 | ); 27 | } 28 | 29 | export function Radio(props: AriaButtonProps<"input"> & { value: string }) { 30 | const { children } = props; 31 | const state = useContext(RadioContext); 32 | const ref = useRef(null); 33 | const { inputProps } = useRadio(props, state as RadioGroupState, ref); 34 | 35 | return ( 36 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/helpers/mastodonContext.tsx: -------------------------------------------------------------------------------- 1 | import type { mastodon } from "masto"; 2 | import { createRestAPIClient } from "masto"; 3 | import type { PropsWithChildren } from "react"; 4 | import { createContext, useContext, useEffect, useMemo, useState } from "react"; 5 | 6 | import { 7 | useAccessToken, 8 | useAccountId, 9 | useInstanceUrl, 10 | } from "../store/selectors"; 11 | 12 | const MastodonContext = createContext<{ 13 | client: mastodon.rest.Client | undefined; 14 | accountId: string | null; 15 | }>({ client: undefined, accountId: null }); 16 | 17 | export const MastodonProvider = (props: PropsWithChildren) => { 18 | const accessToken = useAccessToken(); 19 | const accountId = useAccountId(); 20 | const instanceUrl = useInstanceUrl(); 21 | const [masto, setMasto] = useState(); 22 | 23 | useEffect(() => { 24 | if (!accessToken || !instanceUrl) { 25 | return; 26 | } 27 | 28 | const mastoClient = createRestAPIClient({ 29 | url: instanceUrl, 30 | accessToken: accessToken, 31 | }); 32 | setMasto(mastoClient); 33 | }, [accessToken, instanceUrl]); 34 | 35 | const value = useMemo(() => { 36 | return { 37 | client: masto, 38 | accountId: accountId || null, 39 | }; 40 | }, [accountId, masto]); 41 | 42 | return ( 43 | 44 | {props.children} 45 | 46 | ); 47 | }; 48 | 49 | export function useMastodon() { 50 | return useContext(MastodonContext); 51 | } 52 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | body { 7 | @apply bg-gray-200 font-mono accent-violet-800 dark:bg-neutral-800 lg:dark:bg-gray-500; 8 | } 9 | h1 { 10 | @apply text-xl font-bold text-gray-800 lg:text-2xl; 11 | } 12 | 13 | #__next { 14 | @apply flex h-screen flex-col; 15 | } 16 | } 17 | 18 | @layer utilities { 19 | .text-accentColor { 20 | @apply text-violet-600; 21 | } 22 | 23 | .custom-prose { 24 | @apply prose prose-sm dark:prose-invert lg:prose-base; 25 | } 26 | } 27 | 28 | /* snowfall: http://pajasevi.github.io/CSSnowflakes/ */ 29 | @keyframes snowflakes-fall { 30 | 0% { 31 | top: -10%; 32 | } 33 | 100% { 34 | top: 100%; 35 | } 36 | } 37 | @keyframes snowflakes-shake { 38 | 0%, 39 | 100% { 40 | transform: translateX(0); 41 | } 42 | 50% { 43 | transform: translateX(80px); 44 | } 45 | } 46 | 47 | .snowflake { 48 | position: fixed; 49 | top: -10%; 50 | user-select: none; 51 | pointer-events: none; 52 | animation-name: snowflakes-fall, snowflakes-shake; 53 | animation-duration: 10s, 3s; 54 | animation-timing-function: linear, ease-in-out; 55 | animation-iteration-count: infinite, infinite; 56 | animation-play-state: running, running; 57 | } 58 | .snowflake img { 59 | width: 36px; 60 | height: 36px; 61 | } 62 | 63 | article .invisible { 64 | display: inline-block; 65 | font-size: 0; 66 | height: 0; 67 | line-height: 0; 68 | position: absolute; 69 | width: 0; 70 | } 71 | -------------------------------------------------------------------------------- /src/components/blurhashImage.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { useEffect, useRef, useState } from "react"; 3 | import { BlurhashCanvas } from "react-blurhash"; 4 | 5 | interface BlurhashImageProps { 6 | imgClassname?: string; 7 | canvasClassname?: string; 8 | src: string; 9 | width?: number; 10 | height?: number; 11 | hash: string; 12 | description?: string; 13 | isHidden?: boolean; 14 | isUncached?: boolean; 15 | } 16 | 17 | export function BlurhashImage(props: BlurhashImageProps) { 18 | const [isLoaded, setIsLoaded] = useState(false); 19 | const imgRef = useRef(null); 20 | 21 | useEffect(() => { 22 | if (imgRef.current?.complete) { 23 | setIsLoaded(true); 24 | } 25 | }, []); 26 | 27 | return ( 28 | <> 29 | setIsLoaded(true)} 40 | width={props.width || 32} 41 | height={props.height || 32} 42 | alt={props.description || ""} 43 | /> 44 | {!isLoaded && ( 45 | 51 | )} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/reviewerPrompt.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren, ReactNode } from "react"; 2 | 3 | import { makeAccountName } from "../helpers/mastodonHelpers"; 4 | import type { TokimekiAccount } from "../store"; 5 | import { renderWithEmoji } from "./emojify"; 6 | import { AnimationState } from "./reviewer"; 7 | 8 | export const ReviewerPrompt = ( 9 | props: PropsWithChildren<{ 10 | animationState: AnimationState; 11 | account: TokimekiAccount; 12 | loadingRender: ReactNode; 13 | }>, 14 | ) => { 15 | const { animationState, account } = props; 16 | const accountName = makeAccountName(account); 17 | 18 | function renderContent() { 19 | if (animationState === AnimationState.Keep) { 20 | return ( 21 | <> 22 | Glad to hear{" "} 23 | {renderWithEmoji(account.emojis, accountName)} 24 | 's toots are still important to you. 25 | 26 | ); 27 | } else if (animationState === AnimationState.Unfollow) { 28 | return ( 29 | <> 30 | Great, unfollowed! Let's thank{" "} 31 | {renderWithEmoji(account.emojis, accountName)} for 32 | all the toots you've enjoyed before.{" "} 33 | 34 | ); 35 | } 36 | 37 | return <>Do their toots still spark joy or feel important to you?; 38 | } 39 | 40 | if (animationState === AnimationState.Hidden) { 41 | return <>{props.loadingRender}; 42 | } 43 | 44 | return
{renderContent()}
; 45 | }; 46 | -------------------------------------------------------------------------------- /src/helpers/overlayBugWorkaround.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useReducer } from "react"; 2 | import type { MenuTriggerState, OverlayTriggerState } from "react-stately"; 3 | 4 | /** 5 | * Work around a bug that prevents overlay menus from opening 6 | * 7 | * This hook is a workaround for this weird bug: 8 | * https://github.com/adobe/react-spectrum/issues/3517 9 | * 10 | * Since the bug presumably exists somewhere in the interplay between Next.js 11 | * and React Aria, it is hard for either project to fix. And of course, our glue 12 | * code might be at fault as well. 13 | * 14 | * The bug is that `useOverlayPosition` usually looks at the position of the 15 | * overlay trigger, and then updates the style properties that it returns with 16 | * the values needed to position the overlay. However, that update does not 17 | * happen by itself. Hence, this hook returns an invisible element that you can 18 | * include in your view, and that will trigger a re-render whenever the menu 19 | * opens or closes. 20 | * 21 | * @param state The OverlayTriggerState controlling your overlay (i.e. whose `isOpen` property you pass to `useOverlayPosition`) 22 | * @returns An invisible element to include next to your overlay trigger 23 | */ 24 | export const useOverlayBugWorkaround = ( 25 | state: MenuTriggerState | OverlayTriggerState, 26 | ) => { 27 | const [renderCount, triggerRerender] = useReducer( 28 | (renders) => renders + 1, 29 | 0, 30 | ); 31 | 32 | useEffect(() => { 33 | // Not doing anything, just triggering a re-render if the menu opens to 34 | // work around 35 | // https://github.com/adobe/react-spectrum/issues/3517 36 | setTimeout(() => triggerRerender()); 37 | }, [state.isOpen]); 38 | 39 | // An invisible element that gets re-rendered every time the menu opens 40 | const elementToRender = ( 41 | 42 | ); 43 | return elementToRender; 44 | }; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tokimeki-mastodon", 3 | "version": "1.3.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "eslint .", 9 | "start": "next start", 10 | "typecheck": "tsc -p .", 11 | "typecheck:watch": "npm run typecheck -- --watch", 12 | "prepare": "husky" 13 | }, 14 | "dependencies": { 15 | "@github/relative-time-element": "^4.4.2", 16 | "@react-types/shared": "^3.24.1", 17 | "@types/eslint__js": "^8.42.3", 18 | "@types/lodash-es": "^4.17.12", 19 | "@types/zen-observable": "^0.8.7", 20 | "blurhash": "^2.0.5", 21 | "clsx": "^2.1.1", 22 | "globals": "^15.9.0", 23 | "html-react-parser": "^5.1.12", 24 | "localforage": "^1.10.0", 25 | "localforage-observable": "^2.1.1", 26 | "lodash-es": "^4.17.21", 27 | "masto": "^6.8.0", 28 | "next": "^14.2.5", 29 | "react": "18.3.1", 30 | "react-aria": "^3.34.1", 31 | "react-blurhash": "^0.3.0", 32 | "react-dom": "18.3.1", 33 | "react-error-boundary": "^4.0.13", 34 | "react-merge-refs": "^2.1.1", 35 | "react-stately": "3.32.1", 36 | "swr": "^2.2.5", 37 | "typescript-eslint": "^8.1.0", 38 | "zen-observable": "^0.10.0", 39 | "zod": "^3.23.8", 40 | "zustand": "^4.5.5" 41 | }, 42 | "devDependencies": { 43 | "@eslint/eslintrc": "^3.1.0", 44 | "@eslint/js": "^9.9.0", 45 | "@tailwindcss/typography": "^0.5.14", 46 | "@types/node": "^22.4.0", 47 | "@types/react": "^18.3.3", 48 | "@types/react-dom": "^18.3.0", 49 | "autoprefixer": "^10.4.20", 50 | "eslint": "^9.9.0", 51 | "husky": "^9.1.4", 52 | "lint-staged": "^15.2.9", 53 | "postcss": "^8.4.41", 54 | "postcss-nesting": "^13.0.0", 55 | "prettier": "^3.3.3", 56 | "prettier-plugin-tailwindcss": "^0.6.6", 57 | "tailwindcss": "^3.4.10", 58 | "typescript": "^5.5.4" 59 | }, 60 | "ct3aMetadata": { 61 | "initVersion": "6.11.4" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/htmlReactParserOptions.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { Text } from "domhandler"; 3 | import type { DOMNode, HTMLReactParserOptions } from "html-react-parser"; 4 | import { attributesToProps, domToReact, Element } from "html-react-parser"; 5 | import type { mastodon } from "masto"; 6 | import { mergeProps } from "react-aria"; 7 | 8 | import { renderWithEmoji } from "./emojify"; 9 | 10 | interface GetParserOptionsProps { 11 | emojiArray: mastodon.v1.CustomEmoji[]; 12 | classNames?: { 13 | p?: string; 14 | a?: string; 15 | }; 16 | } 17 | export function getParserOptions(options: GetParserOptionsProps) { 18 | const { emojiArray, classNames } = options; 19 | const parseOptions: HTMLReactParserOptions = { 20 | replace: (domNode) => { 21 | if (domNode.type === "text") { 22 | return renderWithEmoji(emojiArray, (domNode as Text).data); 23 | } 24 | 25 | if (domNode instanceof Element) { 26 | if (domNode.tagName === "br") { 27 | return
; 28 | } 29 | if (domNode.tagName === "a") { 30 | const anchorProps = mergeProps(attributesToProps(domNode.attribs), { 31 | className: clsx( 32 | "text-blue-500 hover:underline", 33 | classNames?.a, 34 | "statuses-link", 35 | ), 36 | target: "_blank", 37 | rel: "noreferrer noopener", 38 | }); 39 | return ( 40 | 41 | {domToReact(domNode.children as DOMNode[], parseOptions)} 42 | 43 | ); 44 | } 45 | if (domNode.tagName === "p") { 46 | return ( 47 |

48 | {domToReact(domNode.children as DOMNode[], parseOptions)} 49 |

50 | ); 51 | } 52 | } 53 | 54 | return domNode; 55 | }, 56 | }; 57 | 58 | return parseOptions; 59 | } 60 | -------------------------------------------------------------------------------- /src/helpers/authHelpers.ts: -------------------------------------------------------------------------------- 1 | import { createRestAPIClient } from "masto"; 2 | 3 | import { env } from "../env/client.mjs"; 4 | 5 | const OAUTH_SCOPES = [ 6 | "read:follows", 7 | "write:follows", 8 | "read:accounts", 9 | "read:statuses", 10 | "read:lists", 11 | "write:lists", 12 | ].join(" "); 13 | 14 | export async function registerApplication(instanceURL: string, origin: string) { 15 | const masto = createRestAPIClient({ 16 | url: instanceURL, 17 | }); 18 | 19 | return await masto.v1.apps.create({ 20 | clientName: env.NEXT_PUBLIC_CLIENT_NAME || "", 21 | redirectUris: `${origin}`, 22 | scopes: OAUTH_SCOPES, 23 | website: origin, 24 | }); 25 | } 26 | 27 | export function getAuthURL(opts: { clientId: string; instanceUrl: string }) { 28 | const authorizationParams = new URLSearchParams({ 29 | client_id: opts.clientId, 30 | scope: OAUTH_SCOPES, 31 | redirect_uri: `${location.origin}`, 32 | response_type: "code", 33 | }); 34 | const authorizationURL = `https://${opts.instanceUrl.replace( 35 | /https?:\/\//, 36 | "", 37 | )}/oauth/authorize?${authorizationParams.toString()}`; 38 | 39 | return authorizationURL; 40 | } 41 | 42 | export async function getAccessToken(opts: { 43 | clientId: string; 44 | instanceUrl: string; 45 | clientSecret: string; 46 | code: string; 47 | }): Promise<{ access_token: string } | null> { 48 | const params = new URLSearchParams({ 49 | client_id: opts.clientId, 50 | client_secret: opts.clientSecret, 51 | redirect_uri: location.origin, 52 | grant_type: "authorization_code", 53 | code: opts.code, 54 | scope: OAUTH_SCOPES, 55 | }); 56 | const tokenResponse = await fetch( 57 | `https://${opts.instanceUrl.replace(/https?:\/\//, "")}/oauth/token`, 58 | { 59 | method: "POST", 60 | headers: { 61 | "Content-Type": "application/x-www-form-urlencoded", 62 | }, 63 | body: params.toString(), 64 | }, 65 | ); 66 | const tokenJSON = await tokenResponse.json(); 67 | return tokenJSON; 68 | } 69 | -------------------------------------------------------------------------------- /src/components/popover.tsx: -------------------------------------------------------------------------------- 1 | import type { 2 | AriaOverlayProps, 3 | AriaPopoverProps, 4 | OverlayTriggerProps, 5 | } from "@react-aria/overlays"; 6 | import { useOverlayTrigger } from "@react-aria/overlays"; 7 | import { DismissButton, Overlay, usePopover } from "@react-aria/overlays"; 8 | import clsx from "clsx"; 9 | import * as React from "react"; 10 | import { useRef } from "react"; 11 | import type { OverlayTriggerState } from "react-stately"; 12 | import { useOverlayTriggerState } from "react-stately"; 13 | 14 | import { useOverlayBugWorkaround } from "../helpers/overlayBugWorkaround"; 15 | import { Button } from "./button"; 16 | 17 | interface PopoverProps extends Omit { 18 | children: React.ReactNode; 19 | state: OverlayTriggerState; 20 | } 21 | 22 | export function Popover(props: PopoverProps) { 23 | const ref = React.useRef(null); 24 | const { state, children } = props; 25 | 26 | const returnProps = usePopover( 27 | { 28 | ...props, 29 | popoverRef: ref, 30 | }, 31 | state, 32 | ); 33 | 34 | const { popoverProps, underlayProps } = returnProps; 35 | 36 | return ( 37 | 38 |
39 |
48 | 49 | {children} 50 | 51 |
52 | 53 | ); 54 | } 55 | 56 | interface PopoverButtonProps extends AriaOverlayProps, OverlayTriggerProps { 57 | label: string; 58 | children: React.ReactNode; 59 | } 60 | 61 | export function PopoverButton(props: PopoverButtonProps) { 62 | const state = useOverlayTriggerState(props); 63 | const menuBugWorkaround = useOverlayBugWorkaround(state); 64 | 65 | const ref = useRef(null); 66 | const { overlayProps, triggerProps } = useOverlayTrigger(props, state, ref); 67 | 68 | return ( 69 |
70 | {menuBugWorkaround} 71 | 79 | {state.isOpen && ( 80 | 86 | {props.children} 87 | 88 | )} 89 |
90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { omit, pick } from "lodash-es"; 2 | import type { mastodon } from "masto"; 3 | import { createWithEqualityFn } from "zustand/traditional"; 4 | import { devtools, persist } from "zustand/middleware"; 5 | 6 | export enum SortOrders { 7 | OLDEST = "oldest", 8 | RANDOM = "random", 9 | NEWEST = "newest", 10 | } 11 | 12 | export interface TokimekiAccount { 13 | id: string; 14 | acct: string; 15 | note: string; 16 | displayName: string; 17 | username: string; 18 | url: string; 19 | emojis: mastodon.v1.CustomEmoji[]; 20 | } 21 | 22 | export function pickTokimekiAccount( 23 | account: mastodon.v1.Account | TokimekiAccount, 24 | ): TokimekiAccount { 25 | return pick(account, [ 26 | "id", 27 | "acct", 28 | "note", 29 | "displayName", 30 | "username", 31 | "url", 32 | "emojis", 33 | ]); 34 | } 35 | 36 | export interface TokimekiRelationship { 37 | followedBy: boolean; 38 | note?: string | null; 39 | showingReblogs: boolean; 40 | } 41 | 42 | export interface TokimekiState { 43 | clientId?: string; 44 | clientSecret?: string; 45 | instanceUrl?: string; 46 | accessToken?: string; 47 | accountId?: string; 48 | accountUsername?: string; 49 | startCount?: number; 50 | unfollowedIds: string[]; 51 | keptIds: string[]; 52 | settings: { 53 | showBio: boolean; 54 | showNote: boolean; 55 | showFollowLabel: boolean; 56 | sortOrder: SortOrders; 57 | skipConfirmation: boolean; 58 | }; 59 | isFetching: boolean; 60 | currentAccount?: TokimekiAccount; 61 | currentAccountListIds?: string[]; 62 | currentRelationship?: TokimekiRelationship; 63 | nextAccount?: TokimekiAccount; 64 | nextAccountListIds?: string[]; 65 | nextRelationship?: TokimekiRelationship; 66 | baseFollowingIds: string[]; 67 | followingIds: string[]; 68 | isFinished: boolean; 69 | lists: mastodon.v1.List[]; 70 | } 71 | 72 | export const initialPersistedState: TokimekiState = { 73 | settings: { 74 | sortOrder: SortOrders.OLDEST, 75 | showBio: false, 76 | showNote: false, 77 | showFollowLabel: false, 78 | skipConfirmation: false, 79 | }, 80 | keptIds: [], 81 | unfollowedIds: [], 82 | isFinished: false, 83 | isFetching: false, 84 | baseFollowingIds: [], 85 | followingIds: [], 86 | lists: [], 87 | }; 88 | 89 | export const usePersistedStore = createWithEqualityFn()( 90 | devtools( 91 | persist(() => initialPersistedState, { 92 | name: "tokimeki-mastodon", // name of the item in the storage (must be unique) 93 | partialize(state) { 94 | return omit(state, ["actions", "nextAccount", "nextRelationship"]); 95 | }, 96 | version: 3, 97 | }), 98 | { name: "main-store" }, 99 | ), 100 | ); 101 | -------------------------------------------------------------------------------- /src/components/feedWidget.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { mastodon } from "masto"; 3 | import { useEffect, useMemo, useState } from "react"; 4 | 5 | import { useMastodon } from "../helpers/mastodonContext"; 6 | import type { TokimekiAccount } from "../store"; 7 | import { 8 | useCurrentAccountRelationship, 9 | useInstanceUrl, 10 | } from "../store/selectors"; 11 | import { Status } from "./status"; 12 | 13 | interface FeedWidgetProps { 14 | account?: TokimekiAccount; 15 | className?: string; 16 | } 17 | 18 | export function FeedWidget(props: FeedWidgetProps) { 19 | const { account } = props; 20 | const { client } = useMastodon(); 21 | const [isLoading, setIsLoading] = useState(true); 22 | const currentAccountRelationship = useCurrentAccountRelationship(); 23 | const [statuses, setStatuses] = useState([]); 24 | const instanceUrl = useInstanceUrl(); 25 | const isRemote = useMemo(() => { 26 | return !(instanceUrl && account?.url.startsWith(instanceUrl)); 27 | }, [account?.url, instanceUrl]); 28 | 29 | useEffect(() => { 30 | setIsLoading(true); 31 | if (!client || !account || !currentAccountRelationship) { 32 | return; 33 | } 34 | 35 | const statusesPromise = client.v1.accounts 36 | .$select(account.id) 37 | .statuses.list({ 38 | limit: 40, 39 | excludeReplies: true, 40 | excludeReblogs: !currentAccountRelationship.showingReblogs, 41 | }); 42 | 43 | statusesPromise.then((res) => { 44 | setStatuses(res.slice(0, 20)); 45 | setIsLoading(false); 46 | }); 47 | }, [account, client, currentAccountRelationship]); 48 | 49 | function renderContent() { 50 | if (isLoading || !account) { 51 | return

Loading...

; 52 | } 53 | if (statuses.length === 0) { 54 | if (isRemote) { 55 | return ( 56 |

57 | Older posts are not available for remote users. 58 |
59 | 60 | Browse original profile 61 | 62 |

63 | ); 64 | } 65 | 66 | return ( 67 |

68 | It seems this user has not posted anything yet! 69 |

70 | ); 71 | } 72 | 73 | return statuses.map((status) => { 74 | const statusToUse = status.reblog || status; 75 | 76 | return ( 77 | 82 | ); 83 | }); 84 | } 85 | 86 | return ( 87 |
93 | {renderContent()} 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { PropsWithChildren } from "react"; 3 | import { forwardRef } from "react"; 4 | import { useEffect } from "react"; 5 | import { useRef } from "react"; 6 | import type { AriaButtonProps } from "react-aria"; 7 | import { mergeProps, useButton, useFocusRing, useHover } from "react-aria"; 8 | import { mergeRefs } from "react-merge-refs"; 9 | 10 | interface ButtonProps extends AriaButtonProps<"button"> { 11 | className?: string; 12 | variant: "primary" | "secondary" | "monochrome"; 13 | isStatic?: boolean; 14 | isPressed?: boolean; 15 | } 16 | 17 | export const Button = forwardRef< 18 | HTMLButtonElement, 19 | PropsWithChildren 20 | >((props, outerRef) => { 21 | const ref = useRef(null); 22 | const { children, className, isStatic, variant, ...ariaProps } = props; 23 | const { buttonProps, isPressed: ariaPressed } = useButton(ariaProps, ref); 24 | const { isFocused } = useFocusRing(ariaProps); 25 | const { isHovered, hoverProps } = useHover(ariaProps); 26 | const mergedProps = mergeProps(buttonProps, hoverProps); 27 | const { disabled } = mergedProps; 28 | const isPressed = props.isPressed ?? ariaPressed; 29 | 30 | const baseClassname = clsx( 31 | "inline-block rounded-md px-4 py-2 shadow-lg outline-none text-sm lg:text-base", 32 | (isHovered || isFocused) && !isStatic && " -translate-y-0.5", 33 | !isStatic && "transition-all duration-200 ease-in-out", 34 | disabled && "cursor-not-allowed opacity-40", 35 | ); 36 | 37 | const variantClassnames: Record = { 38 | primary: clsx( 39 | "text-white shadow-violet-500/30", 40 | isPressed ? "bg-violet-800 ring-violet-800 " : "bg-violet-500", 41 | isHovered && "bg-violet-600 shadow-violet-600/30", 42 | isFocused && "ring-violet-600", 43 | ), 44 | secondary: clsx( 45 | "bg-white dark:bg-neutral-800 dark:text-violet-500 dark:ring-violet-500 text-violet-800 ring-2 ring-inset ring-violet-800", 46 | (isHovered || isPressed) && "bg-violet-200 dark:bg-violet-800", 47 | ), 48 | monochrome: clsx( 49 | "bg-black/40 text-white backdrop-blur-sm ", 50 | isHovered && "bg-black/80", 51 | ), 52 | }; 53 | 54 | // Workaround for react/react-aria #1513 55 | useEffect(() => { 56 | ref.current?.addEventListener("touchstart", (event: TouchEvent) => { 57 | event.preventDefault(); 58 | }); 59 | }, []); 60 | 61 | return ( 62 | 69 | ); 70 | }); 71 | Button.displayName = "Button"; 72 | 73 | export function SmallButton(props: PropsWithChildren) { 74 | return ( 75 | 45 | {state.isOpen && ( 46 | 47 | state.close()} 52 | style={{ 53 | minWidth: ref.current?.clientWidth 54 | ? ref.current?.clientWidth + 10 55 | : "", 56 | }} 57 | /> 58 | 59 | )} 60 |
61 | ); 62 | } 63 | 64 | interface MenuProps extends AriaMenuProps { 65 | onClose: () => void; 66 | style?: CSSProperties; 67 | } 68 | 69 | function Menu(props: MenuProps) { 70 | // Create state based on the incoming props 71 | const state = useTreeState(props); 72 | 73 | // Get props for the menu element 74 | const ref = useRef(null); 75 | const { menuProps } = useMenu(props, state, ref); 76 | 77 | return ( 78 |
    84 | {Array.from(state.collection).map((item) => ( 85 | 92 | ))} 93 |
94 | ); 95 | } 96 | 97 | interface MenuSectionProps { 98 | section: Node; 99 | state: TreeState; 100 | onAction?: (key: number | string) => void; 101 | onClose: () => void; 102 | } 103 | 104 | function MenuSection({ 105 | section, 106 | state, 107 | onAction, 108 | onClose, 109 | }: MenuSectionProps) { 110 | const { itemProps, groupProps } = useMenuSection({ 111 | heading: section.rendered, 112 | "aria-label": section["aria-label"], 113 | }); 114 | 115 | return ( 116 | <> 117 |
  • 124 |
      125 | {Array.from(section.childNodes).map((node) => ( 126 | 133 | ))} 134 |
    135 |
  • 136 | 137 | ); 138 | } 139 | 140 | interface MenuItemProps { 141 | item: Node; 142 | state: TreeState; 143 | onAction?: (key: string | number) => void; 144 | onClose: () => void; 145 | } 146 | 147 | function MenuItem({ item, state, onAction, onClose }: MenuItemProps) { 148 | // Get props for the menu item element 149 | const ref = React.useRef(null); 150 | const { menuItemProps } = useMenuItem( 151 | { 152 | key: item.key, 153 | onAction, 154 | onClose, 155 | }, 156 | state, 157 | ref, 158 | ); 159 | 160 | // Handle focus events so we can apply highlighted 161 | // style to the focused menu item 162 | const isFocused = state.selectionManager.focusedKey === item.key; 163 | 164 | return ( 165 |
  • 173 | {item.rendered} 174 |
  • 175 | ); 176 | } 177 | -------------------------------------------------------------------------------- /src/pages/review.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import { useRouter } from "next/router"; 3 | import { useEffect, useMemo, useState } from "react"; 4 | 5 | import { Block } from "../components/block"; 6 | import { Button } from "../components/button"; 7 | import { Finished } from "../components/finished"; 8 | import { LinkButton } from "../components/linkButton"; 9 | import { Options } from "../components/options"; 10 | import { Reviewer } from "../components/reviewer"; 11 | import { MastodonProvider, useMastodon } from "../helpers/mastodonContext"; 12 | import { 13 | fetchFollowings, 14 | fetchLists, 15 | markAsFinished, 16 | resetState, 17 | } from "../store/actions"; 18 | import { 19 | useAccountId, 20 | useAccountUsername, 21 | useFilteredFollowings, 22 | useIsFinished, 23 | useKeptIds, 24 | useStartCount, 25 | useUnfollowedIds, 26 | } from "../store/selectors"; 27 | 28 | const Review: NextPage = () => { 29 | const [hasMounted, setHasMounted] = useState(false); 30 | 31 | useEffect(() => { 32 | setHasMounted(true); 33 | }, []); 34 | 35 | if (!hasMounted) { 36 | return null; 37 | } 38 | 39 | return ( 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | const ReviewContent = () => { 47 | const [isReviewing, setIsReviewing] = useState(false); 48 | const router = useRouter(); 49 | 50 | const { client } = useMastodon(); 51 | const accountId = useAccountId(); 52 | const accountUsername = useAccountUsername(); 53 | const keptIds = useKeptIds(); 54 | const unfollowedIds = useUnfollowedIds(); 55 | const startCount = useStartCount(); 56 | 57 | const hasProgress = useMemo( 58 | () => 59 | Boolean( 60 | (unfollowedIds && unfollowedIds.length) || (keptIds && keptIds.length), 61 | ), 62 | [keptIds, unfollowedIds], 63 | ); 64 | const isFinished = useIsFinished(); 65 | const filteredFollowings = useFilteredFollowings(); 66 | 67 | useEffect(() => { 68 | if (!isReviewing || !client || !accountId) { 69 | return; 70 | } 71 | 72 | fetchFollowings(accountId, client); 73 | fetchLists(client); 74 | }, [accountId, client, isReviewing]); 75 | 76 | if (isFinished) { 77 | return ; 78 | } 79 | 80 | if (!accountUsername || !accountId) { 81 | return ( 82 | 83 |

    Loading...

    84 |
    85 | ); 86 | } 87 | 88 | if (isReviewing) { 89 | return ( 90 | <> 91 | { 93 | setIsReviewing(false); 94 | }} 95 | position="southeast" 96 | > 97 | Options 98 | 99 | { 101 | markAsFinished(); 102 | }} 103 | /> 104 | 105 | ); 106 | } 107 | 108 | return ( 109 | <> 110 | { 113 | resetState(); 114 | router.push("/"); 115 | }} 116 | > 117 | Log out 118 | 119 | 120 |

    121 | {hasProgress ? "Hello again," : "Hello"} @{accountUsername}! 122 | Let's go through those {startCount} accounts you are following 😤 123 | {hasProgress && ( 124 | <> 125 |
    126 | {filteredFollowings.length} to go! 127 | 128 | )} 129 |

    130 |

    131 | You can't be expected to do this all at once, do not feel bad if 132 | you need to take a break. Progress will be saved as you go! 133 |

    134 | {hasProgress && ( 135 |

    136 | Keep at it! You started with {startCount} follows. We loaded your 137 | progress from last time when you kept {(keptIds || []).length}{" "} 138 | accounts that mattered to you. 139 |
    140 |
    141 | 142 | Let's get started on the {filteredFollowings.length} accounts 143 | you have left! 144 | 145 |

    146 | )} 147 | 148 | 149 | 150 | 158 |
    159 | 160 |
    161 |

    162 | Based off{" "} 163 | Tokimeki Unfollow{" "} 164 | by Julius Tarng. 165 |
    166 | Made by Damien Erambert. Find me 167 | at{" "} 168 | 169 | eramdam@erambert.me 170 | 171 | ! 172 |

    173 |
    174 |
    175 | 176 | ); 177 | }; 178 | 179 | export default Review; 180 | -------------------------------------------------------------------------------- /src/components/reviewerButtons.tsx: -------------------------------------------------------------------------------- 1 | import { isString } from "lodash-es"; 2 | import { useState } from "react"; 3 | import { Item, Section } from "react-stately"; 4 | 5 | import { useMastodon } from "../helpers/mastodonContext"; 6 | import { createList } from "../store/actions"; 7 | import { useCurrentAccountListIds, useLists } from "../store/selectors"; 8 | import { Button, SmallButton } from "./button"; 9 | import { MenuButton } from "./menu"; 10 | import { PopoverButton } from "./popover"; 11 | import { TextInput } from "./textField"; 12 | 13 | enum ItemKeysEnum { 14 | CREATE_LIST = "create-list", 15 | CANCEL = "cancel", 16 | } 17 | 18 | interface ReviewerButtonsProps { 19 | onUnfollowClick: () => void; 20 | onKeepClick: () => void; 21 | onUndoClick: () => void; 22 | onNextClick: () => void; 23 | onAddToList: (listId: string) => void; 24 | isVisible: boolean; 25 | shouldSkipConfirmation: boolean; 26 | isFetching: boolean; 27 | } 28 | export function ReviewerButtons(props: ReviewerButtonsProps) { 29 | const { 30 | isVisible, 31 | onKeepClick, 32 | onNextClick, 33 | onUndoClick, 34 | onUnfollowClick, 35 | onAddToList, 36 | shouldSkipConfirmation, 37 | isFetching, 38 | } = props; 39 | const lists = useLists(); 40 | const currentAccountListIds = useCurrentAccountListIds(); 41 | const [isCreatingList, setIsCreatingList] = useState(false); 42 | const [isAddingToList, setIsAddingTolist] = useState(false); 43 | const [listName, setListName] = useState(""); 44 | const { client } = useMastodon(); 45 | 46 | const renderMenuButton = () => { 47 | if (isCreatingList) { 48 | return ( 49 | 50 |
    51 | 57 | { 61 | if (!client) { 62 | return; 63 | } 64 | await createList(client, listName); 65 | setIsCreatingList(false); 66 | setIsAddingTolist(true); 67 | setListName(""); 68 | }} 69 | > 70 | Create list 71 | 72 | { 76 | setIsCreatingList(false); 77 | setIsAddingTolist(true); 78 | setListName(""); 79 | }} 80 | > 81 | Cancel 82 | 83 |
    84 |
    85 | ); 86 | } 87 | 88 | return ( 89 | { 92 | if (key === ItemKeysEnum.CANCEL) { 93 | return; 94 | } 95 | if (key === ItemKeysEnum.CREATE_LIST) { 96 | setIsCreatingList(true); 97 | } else if (isString(key)) { 98 | onAddToList(key); 99 | setIsAddingTolist(false); 100 | } 101 | }} 102 | onOpenChange={setIsAddingTolist} 103 | isOpen={isAddingToList} 104 | > 105 |
    106 | {lists.map((list) => { 107 | return ( 108 | 109 | {list.title} 110 | {currentAccountListIds?.includes(list.id) && ( 111 | 112 | )} 113 | 114 | ); 115 | })} 116 |
    117 |
    118 | Create new list... 119 | Cancel 120 |
    121 |
    122 | ); 123 | }; 124 | 125 | function renderContent() { 126 | if (isVisible) { 127 | return ( 128 | <> 129 | 136 | {renderMenuButton()} 137 | 144 | 145 | ); 146 | } 147 | 148 | return ( 149 | <> 150 | 157 | 164 | 165 | ); 166 | } 167 | if (shouldSkipConfirmation && !isVisible) { 168 | return null; 169 | } 170 | 171 | return ( 172 |
    173 | {renderContent()} 174 |
    175 | ); 176 | } 177 | -------------------------------------------------------------------------------- /src/components/finished.tsx: -------------------------------------------------------------------------------- 1 | import { pick } from "lodash-es"; 2 | import { useRouter } from "next/router"; 3 | import { useMemo, useState } from "react"; 4 | import useSWR from "swr"; 5 | 6 | import { useMastodon } from "../helpers/mastodonContext"; 7 | import { resetState } from "../store/actions"; 8 | import { 9 | useAccountId, 10 | useInstanceUrl, 11 | useKeptIds, 12 | useStartCount, 13 | } from "../store/selectors"; 14 | import { Block } from "./block"; 15 | import { Button } from "./button"; 16 | 17 | export function Finished() { 18 | const [maybeReset, setMaybeReset] = useState(false); 19 | const router = useRouter(); 20 | const keptIdsFromStorage = useKeptIds(); 21 | const keptIds = useMemo(() => keptIdsFromStorage || [], [keptIdsFromStorage]); 22 | const startCount = useStartCount(); 23 | const instanceUrl = useInstanceUrl(); 24 | const accountId = useAccountId(); 25 | 26 | const { client } = useMastodon(); 27 | const { data: avatarsData } = useSWR("pics", async () => { 28 | if (!client || !accountId) { 29 | return []; 30 | } 31 | 32 | const accounts = await client.v1.accounts 33 | .$select(accountId) 34 | .following.list({ 35 | limit: 80, 36 | }); 37 | return accounts.map((a) => pick(a, ["id", "avatar", "displayName"])); 38 | }); 39 | 40 | const keptPicsRenders = useMemo(() => { 41 | if (!avatarsData) { 42 | return null; 43 | } 44 | return avatarsData.map((pic) => { 45 | const delay = Math.random() * 10; 46 | return ( 47 |
    0.5 ? 1 : -1}`, 54 | }} 55 | > 56 | {pic.displayName} 57 |
    58 | ); 59 | }); 60 | }, [avatarsData]); 61 | 62 | const renderFinishedFooter = () => { 63 | if (maybeReset) { 64 | return ( 65 | 66 |

    67 | Wanna do it again with your current follows?
    68 |
    69 | This will reset your progress data and start over. Then, it will log 70 | you out so you can log in again and start over fresh! 71 |

    72 |
    73 | 81 | 90 |
    91 |
    92 | ); 93 | } 94 | 95 | return ( 96 | 97 |

    98 | Wow, you've done it — amazing! Hope you enjoy your new feed. Come 99 | back if you ever feel like it's getting out of control again.{" "} 100 |
    101 |
    @Eramdam 102 |

    103 |
    104 | 121 | 129 |
    130 |
    131 | ); 132 | }; 133 | 134 | return ( 135 |
    136 | {keptPicsRenders} 137 |
    138 | 139 |

    Tokimeki Complete!

    140 | 141 |
    142 |
    Results
    143 |
    144 | Starting follows 145 | {startCount} 146 |
    147 |
    148 | Unfollowed 149 | 150 | {keptIds.length - startCount} 151 | 152 |
    153 |
    154 |
    155 | Now following 156 | {keptIds.length} 157 |
    158 |
    159 |
    160 |
    161 | {renderFinishedFooter()} 162 |
    163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /src/components/status.tsx: -------------------------------------------------------------------------------- 1 | import "@github/relative-time-element"; 2 | 3 | import clsx from "clsx"; 4 | import { compact } from "lodash-es"; 5 | import type { mastodon } from "masto"; 6 | import { useRef, useState } from "react"; 7 | import { useButton } from "react-aria"; 8 | 9 | import { isElement } from "../helpers/domHelpers"; 10 | import { makeAccountName } from "../helpers/mastodonHelpers"; 11 | import { BlurhashImage } from "./blurhashImage"; 12 | import { SmallButton } from "./button"; 13 | import { renderWithEmoji } from "./emojify"; 14 | import { HtmlRenderer } from "./htmlRendered"; 15 | 16 | interface StatusProps { 17 | status: mastodon.v1.Status; 18 | booster?: mastodon.v1.Account; 19 | } 20 | 21 | export function Status(props: StatusProps) { 22 | const { booster, status } = props; 23 | 24 | const initialIsCollapsed = !!status.spoilerText; 25 | const [isCollapsed, setIsCollapsed] = useState(initialIsCollapsed); 26 | const [areMediaHidden, setAreMediaHidden] = useState( 27 | status.sensitive || initialIsCollapsed, 28 | ); 29 | const mediaWrapperRef = useRef(null); 30 | const { buttonProps } = useButton( 31 | { 32 | onPress: () => { 33 | setAreMediaHidden(false); 34 | }, 35 | }, 36 | mediaWrapperRef, 37 | ); 38 | const hasUncachedMedia = status.mediaAttachments.some( 39 | (m) => m.type === "unknown", 40 | ); 41 | const renderMediaText = () => { 42 | if (hasUncachedMedia) { 43 | return "Not available"; 44 | } 45 | 46 | return areMediaHidden ? "Sensitive content" : "Hide"; 47 | }; 48 | 49 | const eligibleMedia = status.mediaAttachments.filter((m) => m.blurhash); 50 | const mediaRenders = compact( 51 | eligibleMedia.map((m) => { 52 | if (!m.blurhash) { 53 | return null; 54 | } 55 | 56 | const isUncached = m.type === "unknown"; 57 | 58 | return ( 59 |
    63 | 74 |
    75 | ); 76 | }), 77 | ); 78 | 79 | return ( 80 |
    { 83 | if (!status.url) { 84 | return; 85 | } 86 | 87 | if (isElement(e.target)) { 88 | if (e.target.matches("img")) { 89 | return; 90 | } 91 | if (e.target.closest("button, a")) { 92 | return; 93 | } 94 | 95 | window.open(status.url, "_blank", "noopener,noreferrer"); 96 | } 97 | }} 98 | className="relative cursor-pointer border-b-[1px] border-neutral-300 p-2 font-sans no-underline last:border-b-0 hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-white/5" 99 | > 100 | {booster && ( 101 |
    102 | 🔁 {makeAccountName(booster)} boosted 103 |
    104 | )} 105 |
    106 |
    107 | {makeAccountName(status.account)} 112 | {booster && ( 113 | {makeAccountName(booster)} 118 | )} 119 |
    120 | 121 |
    122 | 123 | {renderWithEmoji( 124 | status.account.emojis, 125 | makeAccountName(status.account), 126 | )} 127 | 128 | 129 | {status.account.acct} 130 | 131 |
    132 | 133 |
    134 | 135 |
    136 | 144 |
    145 |
    146 | {initialIsCollapsed && ( 147 | { 151 | setIsCollapsed((p) => !p); 152 | }} 153 | > 154 | {status.spoilerText} {isCollapsed ? "➕" : "➖"} 155 | 156 | )} 157 | {(!isCollapsed && ( 158 |
    159 | 160 |
    161 | )) || 162 | null} 163 | {(mediaRenders.length && ( 164 |
    169 | { 178 | if (hasUncachedMedia) { 179 | return; 180 | } 181 | setAreMediaHidden((p) => !p); 182 | }} 183 | > 184 | {renderMediaText()} 185 | 186 | {mediaRenders} 187 |
    188 | )) || 189 | null} 190 |
    191 | ); 192 | } 193 | -------------------------------------------------------------------------------- /src/components/reviewer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { compact } from "lodash-es"; 3 | import { useState } from "react"; 4 | 5 | import { delayAsync } from "../helpers/asyncHelpers"; 6 | import { useMastodon } from "../helpers/mastodonContext"; 7 | import { 8 | goToNextAccount, 9 | keepAccount, 10 | unfollowAccount, 11 | } from "../store/actions"; 12 | import { 13 | useCurrentAccount, 14 | useCurrentAccountRelationship, 15 | useFilteredFollowings, 16 | useFollowingIds, 17 | useSettings, 18 | } from "../store/selectors"; 19 | import { Block } from "./block"; 20 | import { FeedWidget } from "./feedWidget"; 21 | import { FollowingsLoadingIndicator } from "./followingsLoadingIndicator"; 22 | import { ReviewerButtons } from "./reviewerButtons"; 23 | import { ReviewerFooter } from "./reviewerFooter"; 24 | import { ReviewerPrompt } from "./reviewerPrompt"; 25 | 26 | export enum AnimationState { 27 | Idle, 28 | Unfollow, 29 | Keep, 30 | Hidden, 31 | } 32 | 33 | interface ReviewerProps { 34 | onFinished: () => void; 35 | } 36 | 37 | export function Reviewer(props: ReviewerProps) { 38 | const currentAccount = useCurrentAccount(); 39 | const currentAccountRelationship = useCurrentAccountRelationship(); 40 | const filteredFollowings = useFilteredFollowings(); 41 | const followings = useFollowingIds(); 42 | const { client } = useMastodon(); 43 | 44 | const [animationState, setAnimated] = useState(AnimationState.Idle); 45 | const isVisible = animationState === AnimationState.Idle; 46 | const { skipConfirmation } = useSettings(); 47 | const [isFetching, setIsFetching] = useState(false); 48 | const [addedToListId, setAddedToListId] = useState( 49 | undefined, 50 | ); 51 | 52 | const onNextClick = async ({ 53 | forceUnfollow, 54 | dontHide, 55 | }: { 56 | forceUnfollow?: boolean; 57 | dontHide?: boolean; 58 | }) => { 59 | if (!client || !currentAccount || isFetching) { 60 | return; 61 | } 62 | 63 | setIsFetching(true); 64 | 65 | const shouldUnfollow = 66 | forceUnfollow ?? animationState === AnimationState.Unfollow; 67 | if (currentAccount) { 68 | if (shouldUnfollow) { 69 | console.log("Will unfollow", currentAccount.acct); 70 | if (process.env.NODE_ENV !== "development") { 71 | await client.v1.accounts.$select(currentAccount.id).unfollow(); 72 | } 73 | unfollowAccount(currentAccount.id); 74 | } else { 75 | keepAccount(currentAccount.id); 76 | } 77 | } 78 | 79 | if (!dontHide) { 80 | setAnimated(AnimationState.Hidden); 81 | } 82 | 83 | if ( 84 | filteredFollowings.length < 1 || 85 | (currentAccount && 86 | currentAccount.id === followings[followings.length - 1]) 87 | ) { 88 | setIsFetching(false); 89 | props.onFinished(); 90 | return; 91 | } 92 | 93 | setAddedToListId(undefined); 94 | setAnimated(AnimationState.Idle); 95 | await goToNextAccount(client, currentAccount); 96 | setIsFetching(false); 97 | }; 98 | 99 | const onUndoClick = () => setAnimated(AnimationState.Idle); 100 | const onUnfollowClick = async () => { 101 | if (skipConfirmation) { 102 | setAnimated(AnimationState.Hidden); 103 | await delayAsync(100); 104 | onNextClick({ forceUnfollow: true, dontHide: true }); 105 | } else { 106 | setAnimated(AnimationState.Unfollow); 107 | } 108 | }; 109 | const onKeepClick = async () => { 110 | if (skipConfirmation) { 111 | setAnimated(AnimationState.Hidden); 112 | await delayAsync(100); 113 | onNextClick({ forceUnfollow: false, dontHide: true }); 114 | } else { 115 | setAnimated(AnimationState.Keep); 116 | } 117 | }; 118 | const onAddToList = async (listId: string) => { 119 | if (!client) { 120 | return; 121 | } 122 | await client.v1.lists.$select(listId).accounts.create({ 123 | accountIds: compact([currentAccount?.id ?? ""]), 124 | }); 125 | setAddedToListId(listId); 126 | }; 127 | 128 | const { showBio: initialShowBio, showNote: initialShowNote } = useSettings(); 129 | const [showBio, setShowBio] = useState(initialShowBio); 130 | const [showNote, setShowNote] = useState(initialShowNote); 131 | 132 | if (followings?.length === 0) { 133 | return ; 134 | } 135 | 136 | const loadingRender = ( 137 | Loading... 138 | ); 139 | 140 | return ( 141 |
    142 | 156 | 160 | 161 | 162 | 167 | {currentAccount && currentAccountRelationship ? ( 168 | <> 169 | {isVisible && ( 170 | 179 | )} 180 | 185 | onNextClick({})} 191 | isVisible={isVisible} 192 | shouldSkipConfirmation={skipConfirmation} 193 | isFetching={isFetching} 194 | /> 195 | 196 | ) : ( 197 | loadingRender 198 | )} 199 | 200 |
    201 | ); 202 | } 203 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ValidationState } from "@react-types/shared"; 2 | import { createRestAPIClient } from "masto"; 3 | import { type NextPage } from "next"; 4 | import Head from "next/head"; 5 | import { useRouter } from "next/router"; 6 | import { useCallback, useEffect, useMemo, useState } from "react"; 7 | 8 | import packageJson from "../../package.json"; 9 | import { Block } from "../components/block"; 10 | import { Button } from "../components/button"; 11 | import { TextInput } from "../components/textField"; 12 | import { 13 | getAccessToken, 14 | getAuthURL, 15 | registerApplication, 16 | } from "../helpers/authHelpers"; 17 | import { saveAfterOAuthCode, saveLoginCredentials } from "../store/actions"; 18 | import { useAccountId, useOAuthCodeDependencies } from "../store/selectors"; 19 | 20 | const Home: NextPage = () => { 21 | const [localInstanceUrl, setInstanceDomain] = useState(""); 22 | const [isLoading, setIsLoading] = useState(false); 23 | const router = useRouter(); 24 | const { 25 | clientId, 26 | clientSecret, 27 | instanceUrl: storedInstanceUrl, 28 | } = useOAuthCodeDependencies(); 29 | 30 | const isInstanceValid: ValidationState | undefined = useMemo(() => { 31 | try { 32 | new URL(localInstanceUrl); 33 | return "valid"; 34 | } catch (_e) { 35 | return "invalid"; 36 | } 37 | }, [localInstanceUrl]); 38 | 39 | const onCode = useCallback( 40 | async (code: string) => { 41 | setIsLoading(true); 42 | 43 | if (!clientId || !clientSecret || !storedInstanceUrl) { 44 | return; 45 | } 46 | 47 | const accessTokenResponse = await getAccessToken({ 48 | clientId, 49 | clientSecret, 50 | code, 51 | instanceUrl: storedInstanceUrl, 52 | }); 53 | 54 | if (!accessTokenResponse) { 55 | return; 56 | } 57 | 58 | const { access_token } = accessTokenResponse; 59 | 60 | if (!access_token) { 61 | return; 62 | } 63 | 64 | const masto = createRestAPIClient({ 65 | url: storedInstanceUrl, 66 | accessToken: access_token, 67 | timeout: 30_000, 68 | }); 69 | const account = await masto.v1.accounts.verifyCredentials(); 70 | saveAfterOAuthCode({ 71 | accessToken: access_token, 72 | account, 73 | }); 74 | router.push("/review"); 75 | }, 76 | [clientId, clientSecret, router, storedInstanceUrl], 77 | ); 78 | 79 | const account = useAccountId(); 80 | 81 | useEffect(() => { 82 | if (account) { 83 | router.push("/review"); 84 | return; 85 | } 86 | const code = new URLSearchParams(window.location.search).get("code"); 87 | 88 | if (!code) { 89 | return; 90 | } 91 | 92 | onCode(code); 93 | }, [account, onCode, router]); 94 | 95 | const onLogin = async () => { 96 | if (!isInstanceValid || isLoading) { 97 | return; 98 | } 99 | 100 | setIsLoading(true); 101 | 102 | try { 103 | const { clientId, clientSecret } = await registerApplication( 104 | localInstanceUrl.replace(/\/$/, ""), 105 | window.location.origin, 106 | ); 107 | 108 | if (clientId && clientSecret) { 109 | saveLoginCredentials({ 110 | instanceUrl: localInstanceUrl.replace(/\/$/, ""), 111 | clientId, 112 | clientSecret, 113 | }); 114 | 115 | location.href = getAuthURL({ 116 | instanceUrl: localInstanceUrl.replace(/\/$/, ""), 117 | clientId, 118 | }); 119 | } 120 | } catch (e) { 121 | console.error(e); 122 | } 123 | }; 124 | 125 | return ( 126 | <> 127 | 128 | 129 | Tokimeki Mastodon 130 | 131 | 132 | 133 |

    134 | ✨ Welcome to Tokimeki Mastodon ✨ 135 |

    136 |

    137 | Following too many accounts? You're in the right place! 138 |

    139 |

    140 | If you're like me, you have followed a lot of accounts over the 141 | years on Mastodon. Some of them date from years ago, you were a 142 | different human being! Some of them you feel like you *have* to keep 143 | following, and others you may have outgrown, but you never had the 144 | energy to clean up your follows. 145 |

    146 |
    { 148 | e.preventDefault(); 149 | onLogin(); 150 | }} 151 | className="my-10 mb-2 flex w-full max-w-lg flex-col gap-5" 152 | > 153 | { 160 | setInstanceDomain( 161 | value.startsWith("https://") ? value : `https://${value}`, 162 | ); 163 | }} 164 | validationState={isInstanceValid || "valid"} 165 | isDisabled={isLoading} 166 | > 167 |
    168 | 178 |
    179 |
    180 |

    181 | This tool uses your Mastodon's account authorization to fetch 182 | your followings, their toots and unfollow accounts. 183 |

    184 |
    185 | 186 |
    187 |

    188 | P.S. Tokimeki is the original word that was translated to 189 | "spark joy" in English. "Spark joy" doesn't 190 | fully capture the meaning, which is why there are all these caveats 191 | whenever someone explains it, like I'm doing here. Please treat 192 | this term as inclusive of anything you enjoy or feel is important to 193 | you. Also, KonMari (TM) is trademark of Marie Kondo, and this tool 194 | is not affiliated with, nor does it profit off the use of the brand. 195 |

    196 |

    197 | P.P.S. This tool uses your browser's local storage (not 198 | cookies) to store your progress. The code is{" "} 199 | 200 | open source and hosted on GitHub. 201 | 202 |

    203 |

    204 | Based off{" "} 205 | Tokimeki Unfollow{" "} 206 | by Julius Tarng. 207 |
    208 | Made by Damien Erambert. Find me 209 | at{" "} 210 | 211 | eramdam@erambert.me 212 | 213 | ! 214 |

    215 | 216 | Version {packageJson.version} 217 | 218 |
    219 |
    220 | 221 | ); 222 | }; 223 | 224 | export default Home; 225 | -------------------------------------------------------------------------------- /src/store/actions.ts: -------------------------------------------------------------------------------- 1 | import { compact, pick, uniq } from "lodash-es"; 2 | import type { mastodon } from "masto"; 3 | 4 | import type { SortOrders, TokimekiAccount, TokimekiState } from "."; 5 | import { pickTokimekiAccount } from "."; 6 | import { initialPersistedState, usePersistedStore } from "."; 7 | import { filterFollowingIds, sortFollowings } from "./selectors"; 8 | 9 | export function resetState() { 10 | return usePersistedStore.setState(() => { 11 | return initialPersistedState; 12 | }, true); 13 | } 14 | 15 | export function saveLoginCredentials(payload: { 16 | clientId: string; 17 | clientSecret: string; 18 | instanceUrl: string; 19 | }): void { 20 | usePersistedStore.setState({ 21 | clientId: payload.clientId, 22 | clientSecret: payload.clientSecret, 23 | instanceUrl: payload.instanceUrl, 24 | }); 25 | } 26 | 27 | export function saveAfterOAuthCode(payload: { 28 | accessToken: string; 29 | account: mastodon.v1.AccountCredentials; 30 | }): void { 31 | usePersistedStore.setState({ 32 | accessToken: payload.accessToken, 33 | accountId: payload.account.id, 34 | accountUsername: payload.account.username, 35 | startCount: payload.account.followingCount, 36 | }); 37 | } 38 | 39 | export function updateSettings( 40 | payload: Partial, 41 | ): void { 42 | usePersistedStore.setState((state) => ({ 43 | settings: { 44 | ...state.settings, 45 | ...payload, 46 | }, 47 | })); 48 | } 49 | export function unfollowAccount(accountId: string): void { 50 | usePersistedStore.setState((state) => ({ 51 | unfollowedIds: uniq([...(state.unfollowedIds || []), accountId]), 52 | })); 53 | } 54 | export function keepAccount(accountId: string): void { 55 | usePersistedStore.setState((state) => ({ 56 | keptIds: uniq([...(state.keptIds || []), accountId]), 57 | })); 58 | } 59 | export async function fetchFollowings( 60 | accountId: string, 61 | client: mastodon.rest.Client, 62 | ) { 63 | usePersistedStore.setState({ isFetching: true }); 64 | const persistedState = usePersistedStore.getState(); 65 | 66 | if (persistedState.baseFollowingIds.length) { 67 | const sortedFollowings = sortFollowings( 68 | filterFollowingIds( 69 | persistedState.baseFollowingIds, 70 | persistedState.keptIds, 71 | persistedState.unfollowedIds, 72 | ), 73 | usePersistedStore.getState().settings.sortOrder, 74 | ); 75 | usePersistedStore.setState({ 76 | currentAccount: undefined, 77 | currentAccountListIds: undefined, 78 | currentRelationship: undefined, 79 | nextAccount: undefined, 80 | nextRelationship: undefined, 81 | nextAccountListIds: undefined, 82 | followingIds: sortedFollowings, 83 | }); 84 | 85 | const [firstAccountId, secondAccountId] = sortedFollowings; 86 | const accountIdsToFetch = compact([firstAccountId, secondAccountId]); 87 | const accountPromises = accountIdsToFetch.map((id) => { 88 | return client.v1.accounts.$select(id).fetch(); 89 | }); 90 | const relationshipsPromises = client.v1.accounts.relationships.fetch({ 91 | id: accountIdsToFetch, 92 | }); 93 | const [currentAccount, nextAccount] = await Promise.all(accountPromises); 94 | const [currentRelationship, nextRelationship] = await relationshipsPromises; 95 | const listPromises = accountIdsToFetch.map((id) => { 96 | return client.v1.accounts.$select(id).lists.list(); 97 | }); 98 | const [currentAccountListIds, nextAccountListIds] = ( 99 | await Promise.all(listPromises) 100 | ).map((listsList) => listsList.map((l) => l.id)); 101 | 102 | usePersistedStore.setState({ 103 | currentAccount, 104 | currentRelationship, 105 | currentAccountListIds, 106 | nextAccount, 107 | nextRelationship, 108 | nextAccountListIds, 109 | }); 110 | 111 | return; 112 | } 113 | 114 | const accounts: mastodon.v1.Account[] = []; 115 | 116 | if (accounts.length === 0) { 117 | for await (const followings of client.v1.accounts 118 | .$select(accountId) 119 | .following.list({ 120 | limit: 80, 121 | })) { 122 | accounts.push(...followings); 123 | } 124 | } 125 | 126 | const accountIds = accounts.map((a) => a.id); 127 | const sortedFollowings = sortFollowings( 128 | accountIds, 129 | usePersistedStore.getState().settings.sortOrder, 130 | ); 131 | 132 | const firstId = sortedFollowings[0] || ""; 133 | const firstAccount = accounts.find((a) => a.id === firstId); 134 | const secondId = sortedFollowings[1] || ""; 135 | const secondAccount = accounts.find((a) => a.id === secondId); 136 | const listPromises = [firstId, secondId].map((id) => { 137 | return client.v1.accounts.$select(id).lists.list(); 138 | }); 139 | const [currentAccountListIds, nextAccountListIds] = ( 140 | await Promise.all(listPromises) 141 | ).map((listsList) => listsList.map((l) => l.id)); 142 | 143 | usePersistedStore.setState({ 144 | baseFollowingIds: accountIds, 145 | followingIds: sortedFollowings, 146 | currentAccount: 147 | (firstAccount && pickTokimekiAccount(firstAccount)) || undefined, 148 | currentAccountListIds, 149 | nextAccount: 150 | (secondAccount && pickTokimekiAccount(secondAccount)) || undefined, 151 | nextAccountListIds, 152 | }); 153 | 154 | const relationships = await client.v1.accounts.relationships.fetch({ 155 | id: compact([firstAccount?.id]), 156 | }); 157 | const currentRelationship = relationships[0] 158 | ? pick(relationships[0], ["followedBy", "note", "showingReblogs"]) 159 | : undefined; 160 | 161 | usePersistedStore.setState({ 162 | isFetching: false, 163 | currentRelationship, 164 | }); 165 | } 166 | export function reorderFollowings(order: SortOrders): void { 167 | usePersistedStore.setState((state) => ({ 168 | followingIds: sortFollowings(state.baseFollowingIds, order), 169 | })); 170 | } 171 | export async function setCurrentAccountEmpty() { 172 | usePersistedStore.setState({ 173 | currentAccount: undefined, 174 | }); 175 | } 176 | export async function goToNextAccount( 177 | client: mastodon.rest.Client, 178 | currentAccount: TokimekiAccount, 179 | ) { 180 | const { followingIds, nextAccount, nextRelationship } = 181 | usePersistedStore.getState(); 182 | 183 | const currentIndex = followingIds.indexOf(currentAccount.id); 184 | const newAccountId = 185 | nextAccount?.id ?? (followingIds[currentIndex + 1] || followingIds[0]); 186 | const newAccount = 187 | nextAccount ?? 188 | (await client.v1.accounts.$select(newAccountId || "").fetch()); 189 | const relationships = nextRelationship 190 | ? [nextRelationship] 191 | : await client.v1.accounts.relationships.fetch({ 192 | id: compact([newAccountId]), 193 | }); 194 | const currentRelationship = relationships[0] 195 | ? pick(relationships[0], ["followedBy", "note", "showingReblogs"]) 196 | : undefined; 197 | 198 | const currentAccountListIds = ( 199 | await client.v1.accounts.$select(newAccount.id).lists.list() 200 | ).map((l) => l.id); 201 | 202 | usePersistedStore.setState({ 203 | currentAccount: pickTokimekiAccount(newAccount), 204 | currentRelationship, 205 | currentAccountListIds, 206 | }); 207 | 208 | const nextAccountId = followingIds[currentIndex + 2]; 209 | 210 | if (!nextAccountId) { 211 | return; 212 | } 213 | 214 | client.v1.accounts 215 | .$select(nextAccountId) 216 | .fetch() 217 | .then((newNextAccount) => { 218 | usePersistedStore.setState({ 219 | nextAccount: pickTokimekiAccount(newNextAccount), 220 | }); 221 | }); 222 | client.v1.accounts.relationships 223 | .fetch({ id: [nextAccountId] }) 224 | .then((newNextRelationship) => { 225 | const newNextRelationshipPicked = newNextRelationship[0] 226 | ? pick(newNextRelationship[0], ["followedBy", "note", "showingReblogs"]) 227 | : undefined; 228 | usePersistedStore.setState({ 229 | nextRelationship: newNextRelationshipPicked, 230 | }); 231 | }); 232 | } 233 | export function markAsFinished(): void { 234 | usePersistedStore.setState({ isFinished: true }); 235 | } 236 | 237 | export async function fetchLists(client: mastodon.rest.Client) { 238 | const lists = await client.v1.lists.list(); 239 | usePersistedStore.setState({ lists }); 240 | } 241 | 242 | export async function createList(client: mastodon.rest.Client, name: string) { 243 | const newList = await client.v1.lists.create({ 244 | title: name, 245 | }); 246 | const currentLists = usePersistedStore.getState().lists; 247 | usePersistedStore.setState({ 248 | lists: [...currentLists, newList], 249 | }); 250 | } 251 | --------------------------------------------------------------------------------