diff --git a/package-lock.json b/package-lock.json index adce764..1c43697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@changesets/cli": "^2.27.1", "@plasmohq/messaging": "^0.6.2", "@plasmohq/storage": "^1.9.1", + "framer-motion": "^11.11.11", "plasmo": "^0.84.2", "react": "18.2.0", "react-dom": "18.2.0", @@ -15188,6 +15189,30 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "11.11.11", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.11.11.tgz", + "integrity": "sha512-tuDH23ptJAKUHGydJQII9PhABNJBpB+z0P1bmgKK9QFIssHGlfPd6kxMq00LSKwE27WFsb2z0ovY0bpUyMvfRw==", + "dependencies": { + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -33710,6 +33735,14 @@ "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", "dev": true }, + "framer-motion": { + "version": "11.11.11", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.11.11.tgz", + "integrity": "sha512-tuDH23ptJAKUHGydJQII9PhABNJBpB+z0P1bmgKK9QFIssHGlfPd6kxMq00LSKwE27WFsb2z0ovY0bpUyMvfRw==", + "requires": { + "tslib": "^2.4.0" + } + }, "fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", diff --git a/package.json b/package.json index 95f2881..c0a1f84 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "author": "kawamataryou", "scripts": { "dev": "plasmo dev", + "dev:firefox": "plasmo dev --target=firefox-mv3", "build": "plasmo build", "build:firefox": "plasmo build --target=firefox-mv3", "package": "plasmo package", @@ -22,6 +23,7 @@ "@changesets/cli": "^2.27.1", "@plasmohq/messaging": "^0.6.2", "@plasmohq/storage": "^1.9.1", + "framer-motion": "^11.11.11", "plasmo": "^0.84.2", "react": "18.2.0", "react-dom": "18.2.0", diff --git a/src/background/messages/openOptionPage.ts b/src/background/messages/openOptionPage.ts new file mode 100644 index 0000000..4e8efe7 --- /dev/null +++ b/src/background/messages/openOptionPage.ts @@ -0,0 +1,11 @@ +import type { PlasmoMessaging } from "@plasmohq/messaging"; + +const handler: PlasmoMessaging.MessageHandler = async (req, res) => { + chrome.runtime.openOptionsPage(() => { + if (chrome.runtime.lastError) { + console.error(chrome.runtime.lastError); + } + }); +}; + +export default handler; diff --git a/src/contents/App.tsx b/src/contents/App.tsx index 2d85b53..c95a6e9 100644 --- a/src/contents/App.tsx +++ b/src/contents/App.tsx @@ -1,8 +1,12 @@ import cssText from "data-text:~style.content.css"; +import type { AtpSessionData } from "@atproto/api"; +import { sendToBackground } from "@plasmohq/messaging"; +import { getPort } from "@plasmohq/messaging/port"; import type { PlasmoCSConfig } from "plasmo"; import React from "react"; import AlertError from "~lib/components/AlertError"; import AlertSuccess from "~lib/components/AlertSuccess"; +import LoadingCards from "~lib/components/LoadingCards"; import MatchTypeFilter from "~lib/components/MatchTypeFilter"; import Modal from "~lib/components/Modal"; import UserCard from "~lib/components/UserCard"; @@ -28,40 +32,21 @@ const App = () => { modalRef, users, loading, - handleClickAction, - actionMode, - errorMessage, - restart, - isRateLimitError, - isSucceeded, - matchTypeFilter, - changeMatchTypeFilter, - filteredUsers, stopRetrieveLoop, + restart, + isBottomReached, } = useRetrieveBskyUsers(); React.useEffect(() => { const messageHandler = ( message: { name: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES]; - body: { - identifier: string; - password: string; - authFactorToken?: string; - }; }, _sender: chrome.runtime.MessageSender, sendResponse: (response?: Record) => void, ) => { if (Object.values(MESSAGE_NAMES).includes(message.name)) { - initialize({ - identifier: message.body.identifier, - password: message.body.password, - messageName: message.name, - ...(message.body.authFactorToken && { - authFactorToken: message.body.authFactorToken, - }), - }) + initialize() .then(() => { sendResponse({ hasError: false }); }) @@ -80,51 +65,67 @@ const App = () => { }; }, [initialize]); + const openOptionPage = () => { + sendToBackground({ name: "openOptionPage" }); + }; + + const stopAndShowDetectedUsers = async () => { + stopRetrieveLoop(); + await chrome.storage.local.set({ users: JSON.stringify(users) }); + openOptionPage(); + }; + return ( <> -
-
-

Find Bluesky Users

-
- {loading && ( -

- )} -

Detected:

-

{users.length}

+
+ {loading && ( +

+ Scanning 𝕏 users to find bsky users... +

+ )} +

+ Detected {users.length} users +

+ {loading && ( + <> + + + + )} + {!loading && !isBottomReached && ( + + )} + {!loading && isBottomReached && ( +
+ +
-
- - {isSucceeded && ( - - {users.length} Bluesky accounts - detected. - )} - {errorMessage && ( - - {errorMessage} - - )} -
- {filteredUsers.length > 0 ? ( -
- {filteredUsers.map((user) => ( - - ))} -
- ) : ( - loading && - )} -
diff --git a/src/lib/bskyServiceWorkerClient.ts b/src/lib/bskyServiceWorkerClient.ts index b99b4bc..6a72c15 100644 --- a/src/lib/bskyServiceWorkerClient.ts +++ b/src/lib/bskyServiceWorkerClient.ts @@ -1,5 +1,7 @@ +import type { AtpSessionData } from "@atproto/api"; import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { sendToBackground } from "@plasmohq/messaging"; +import { STORAGE_KEYS } from "./constants"; export type BskyLoginParams = { identifier: string; @@ -8,16 +10,17 @@ export type BskyLoginParams = { }; export class BskyServiceWorkerClient { - private session = {}; + private session = {} as AtpSessionData; - private constructor() {} + constructor(session: AtpSessionData) { + this.session = session; + } - public static async createAgent({ + public static async createAgentFromLoginParams({ identifier, password, authFactorToken, }: BskyLoginParams): Promise { - const client = new BskyServiceWorkerClient(); const { session, error } = await sendToBackground({ name: "login", body: { @@ -28,8 +31,11 @@ export class BskyServiceWorkerClient { }); if (error) throw new Error(error.message); - client.session = session; - return client; + chrome.storage.local.set({ + [STORAGE_KEYS.BSKY_CLIENT_SESSION]: session, + }); + + return new BskyServiceWorkerClient(session); } public searchUser = async ({ diff --git a/src/lib/components/AsyncButton.tsx b/src/lib/components/AsyncButton.tsx new file mode 100644 index 0000000..306081a --- /dev/null +++ b/src/lib/components/AsyncButton.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +type Props = { + onClick: () => Promise; + label: string; +}; + +const AsyncButton = ({ onClick, label }: Props) => { + const [loading, setLoading] = React.useState(false); + + const handleClick = async () => { + setLoading(true); + await onClick(); + setLoading(false); + }; + + return ( + + ); +}; + +export default AsyncButton; diff --git a/src/lib/components/Header.stories.tsx b/src/lib/components/Header.stories.tsx new file mode 100644 index 0000000..0f0bc2d --- /dev/null +++ b/src/lib/components/Header.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import Header from "./Header"; + +const meta = { + title: "Header", + component: Header, + parameters: { + layout: "fullscreen", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/lib/components/Header.tsx b/src/lib/components/Header.tsx new file mode 100644 index 0000000..c6c9b00 --- /dev/null +++ b/src/lib/components/Header.tsx @@ -0,0 +1,49 @@ +const Header = () => { + return ( +
+
+ + + + + + + Sky Follower Bridge +
+ +
+ ); +}; + +export default Header; diff --git a/src/lib/components/LoadingCards.stories.tsx b/src/lib/components/LoadingCards.stories.tsx new file mode 100644 index 0000000..36c8449 --- /dev/null +++ b/src/lib/components/LoadingCards.stories.tsx @@ -0,0 +1,15 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import LoadingCards from "./LoadingCards"; + +const meta = { + title: "Components/LoadingCards", + component: LoadingCards, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/src/lib/components/LoadingCards.tsx b/src/lib/components/LoadingCards.tsx new file mode 100644 index 0000000..2555b4c --- /dev/null +++ b/src/lib/components/LoadingCards.tsx @@ -0,0 +1,65 @@ +import { motion } from "framer-motion"; +import React from "react"; +import UserCardSkeleton from "./UserCardSkeleton"; + +const CARD_COLORS = ["#266678", "#cb7c7a", " #36a18b", "#cda35f", "#747474"]; +const CARD_OFFSET = 10; +const SCALE_FACTOR = 0.06; + +const moveArrayItem = (arr: string[], fromIndex: number, toIndex: number) => { + const newArray = [...arr]; + const [movedItem] = newArray.splice(fromIndex, 1); + newArray.splice(toIndex, 0, movedItem); + return newArray; +}; + +const LoadingCards = () => { + const [cards, setCards] = React.useState(CARD_COLORS); + + const moveToEnd = (from: number) => { + setCards((prevCards) => + moveArrayItem(prevCards, from, prevCards.length - 1), + ); + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: + React.useEffect(() => { + const interval = setInterval(() => { + moveToEnd(0); + }, 3000); + + return () => clearInterval(interval); + }, []); + + return ( +
+
    + {cards.map((color, index) => { + return ( + + + + ); + })} +
+
+ ); +}; + +export default React.memo(LoadingCards); diff --git a/src/lib/components/Modal.tsx b/src/lib/components/Modal.tsx index 9bcbd27..4b0440f 100644 --- a/src/lib/components/Modal.tsx +++ b/src/lib/components/Modal.tsx @@ -24,7 +24,7 @@ const Modal = ({ children, anchorRef, open = false, onClose }: Props) => { return ( <> -
+
{children}
diff --git a/src/lib/components/Sidebar.stories.tsx b/src/lib/components/Sidebar.stories.tsx new file mode 100644 index 0000000..abb71fb --- /dev/null +++ b/src/lib/components/Sidebar.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BSKY_USER_MATCH_TYPE } from "../constants"; +import Sidebar from "./Sidebar"; + +const meta = { + title: "Components/Sidebar", + component: Sidebar, + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + detectedCount: 42, + filterValue: { + [BSKY_USER_MATCH_TYPE.HANDLE]: true, + [BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: false, + [BSKY_USER_MATCH_TYPE.DESCRIPTION]: true, + [BSKY_USER_MATCH_TYPE.FOLLOWING]: true, + }, + onChangeFilter: (key) => { + console.log(`Filter changed: ${key}`); + }, + }, +}; + +export const NoDetections: Story = { + args: { + detectedCount: 0, + filterValue: { + [BSKY_USER_MATCH_TYPE.HANDLE]: false, + [BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: false, + [BSKY_USER_MATCH_TYPE.DESCRIPTION]: false, + [BSKY_USER_MATCH_TYPE.FOLLOWING]: false, + }, + onChangeFilter: (key) => { + console.log(`Filter changed: ${key}`); + }, + }, +}; diff --git a/src/lib/components/Sidebar.tsx b/src/lib/components/Sidebar.tsx new file mode 100644 index 0000000..4909cf0 --- /dev/null +++ b/src/lib/components/Sidebar.tsx @@ -0,0 +1,163 @@ +import React from "react"; +import type { MatchType, MatchTypeFilterValue } from "../../types"; +import { + ACTION_MODE, + BSKY_USER_MATCH_TYPE, + MATCH_TYPE_LABEL_AND_COLOR, +} from "../constants"; +import AsyncButton from "./AsyncButton"; +import SocialLinks from "./SocialLinks"; + +type Props = { + detectedCount: number; + filterValue: MatchTypeFilterValue; + onChangeFilter: (key: MatchType) => void; + actionAll: () => Promise; + actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE]; + matchTypeStats: Record; +}; + +const Sidebar = ({ + detectedCount, + filterValue, + onChangeFilter, + actionAll, + actionMode, + matchTypeStats, +}: Props) => { + return ( + + ); +}; + +export default Sidebar; diff --git a/src/lib/components/SocialLinks.tsx b/src/lib/components/SocialLinks.tsx new file mode 100644 index 0000000..f092670 --- /dev/null +++ b/src/lib/components/SocialLinks.tsx @@ -0,0 +1,72 @@ +const SocialLinks = () => { + return ( + + ); +}; + +export default SocialLinks; diff --git a/src/lib/components/UserCardSkeleton.tsx b/src/lib/components/UserCardSkeleton.tsx index 2a6a385..ac4d94b 100644 --- a/src/lib/components/UserCardSkeleton.tsx +++ b/src/lib/components/UserCardSkeleton.tsx @@ -7,14 +7,26 @@ const UserCardSkeleton = () => {
-
+
- - +

- - + + +

diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 694b0e0..82e6e1a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -33,6 +33,9 @@ export const STORAGE_KEYS = { BSKY_USER_ID: `${STORAGE_PREFIX}_bsky_password`, BSKY_PASSWORD: `${STORAGE_PREFIX}_bsky_user`, BSKY_SHOW_AUTH_FACTOR_TOKEN_INPUT: `${STORAGE_PREFIX}_bsky_show_auth_factor_token_input`, + BSKY_CLIENT_SESSION: `${STORAGE_PREFIX}_bsky_client_session`, + BSKY_MESSAGE_NAME: `${STORAGE_PREFIX}_bsky_message_name`, + DETECTED_BSKY_USERS: `${STORAGE_PREFIX}_detected_bsky_users`, } as const; export const TARGET_URLS_REGEX = { @@ -55,6 +58,7 @@ export const BSKY_USER_MATCH_TYPE = { HANDLE: "handle", DISPLAY_NAME: "display_name", DESCRIPTION: "description", + FOLLOWING: "following", NONE: "none", } as const; @@ -73,6 +77,10 @@ export const MATCH_TYPE_LABEL_AND_COLOR = { label: "Included handle name in description", color: "secondary", }, + [BSKY_USER_MATCH_TYPE.FOLLOWING]: { + label: "Followed users", + color: "success", + }, }; export const AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE = diff --git a/src/lib/hooks/useBskyUserManager.ts b/src/lib/hooks/useBskyUserManager.ts new file mode 100644 index 0000000..3048703 --- /dev/null +++ b/src/lib/hooks/useBskyUserManager.ts @@ -0,0 +1,209 @@ +import React from "react"; +import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient"; +import { + ACTION_MODE, + BSKY_USER_MATCH_TYPE, + MESSAGE_NAME_TO_ACTION_MODE_MAP, + STORAGE_KEYS, +} from "~lib/constants"; +import { wait } from "~lib/utils"; +import type { BskyUser, MatchType } from "~types"; + +export const useBskyUserManager = ({ + users, + setUsers, +}: { + users: BskyUser[]; + setUsers: React.Dispatch>; +}) => { + const bskyClient = React.useRef(null); + const [actionMode, setActionMode] = React.useState< + (typeof ACTION_MODE)[keyof typeof ACTION_MODE] + >(ACTION_MODE.FOLLOW); + const [matchTypeFilter, setMatchTypeFilter] = React.useState({ + [BSKY_USER_MATCH_TYPE.FOLLOWING]: true, + [BSKY_USER_MATCH_TYPE.HANDLE]: true, + [BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: true, + [BSKY_USER_MATCH_TYPE.DESCRIPTION]: true, + }); + + const handleClickAction = React.useCallback( + async (user: (typeof users)[0]) => { + if (!bskyClient.current) return; + let resultUri: string | null = null; + + // follow + if (actionMode === ACTION_MODE.FOLLOW) { + if (user.isFollowing) { + await bskyClient.current.unfollow(user.followingUri); + } else { + const result = await bskyClient.current.follow(user.did); + resultUri = result.uri; + } + setUsers((prev) => + prev.map((prevUser) => { + if (prevUser.did === user.did) { + return { + ...prevUser, + isFollowing: !prevUser.isFollowing, + followingUri: resultUri ?? prevUser.followingUri, + }; + } + return prevUser; + }), + ); + } + + // block + if (actionMode === ACTION_MODE.BLOCK) { + if (user.isBlocking) { + await bskyClient.current.unblock(user.blockingUri); + } else { + const result = await bskyClient.current.block(user.did); + resultUri = result.uri; + } + setUsers((prev) => + prev.map((prevUser) => { + if (prevUser.did === user.did) { + return { + ...prevUser, + isBlocking: !prevUser.isBlocking, + blockingUri: resultUri ?? prevUser.blockingUri, + }; + } + return prevUser; + }), + ); + } + }, + [actionMode, setUsers], + ); + + const changeMatchTypeFilter = React.useCallback( + ( + matchType: (typeof BSKY_USER_MATCH_TYPE)[keyof typeof BSKY_USER_MATCH_TYPE], + ) => { + setMatchTypeFilter((prev) => { + return { + ...prev, + [matchType]: !prev[matchType], + }; + }); + }, + [], + ); + + const filteredUsers = React.useMemo(() => { + return users.filter((user) => { + if ( + !matchTypeFilter[BSKY_USER_MATCH_TYPE.FOLLOWING] && + user.isFollowing + ) { + return false; + } + if ( + !matchTypeFilter[BSKY_USER_MATCH_TYPE.FOLLOWING] && + actionMode === ACTION_MODE.BLOCK && + user.isBlocking + ) { + return false; + } + return matchTypeFilter[user.matchType]; + }); + }, [users, matchTypeFilter, actionMode]); + + const actionAll = React.useCallback(async () => { + if (!bskyClient.current) return; + let actionCount = 0; + + for (const user of filteredUsers) { + let resultUri: string | null = null; + // follow + if (actionMode === ACTION_MODE.FOLLOW) { + if (user.isFollowing) { + continue; + } + const result = await bskyClient.current.follow(user.did); + resultUri = result.uri; + setUsers((prev) => + prev.map((prevUser) => { + if (prevUser.did === user.did) { + return { + ...prevUser, + isFollowing: !prevUser.isFollowing, + followingUri: resultUri ?? prevUser.followingUri, + }; + } + return prevUser; + }), + ); + await wait(300); + actionCount++; + } + + // block + if (actionMode === ACTION_MODE.BLOCK) { + if (user.isBlocking) { + continue; + } + const result = await bskyClient.current.block(user.did); + resultUri = result.uri; + } + setUsers((prev) => + prev.map((prevUser) => { + if (prevUser.did === user.did) { + return { + ...prevUser, + isBlocking: !prevUser.isBlocking, + blockingUri: resultUri ?? prevUser.blockingUri, + }; + } + return prevUser; + }), + ); + await wait(300); + actionCount++; + } + return actionCount; + }, [filteredUsers, actionMode, setUsers]); + + React.useEffect(() => { + chrome.storage.local.get( + [STORAGE_KEYS.BSKY_CLIENT_SESSION, STORAGE_KEYS.BSKY_MESSAGE_NAME], + (result) => { + const session = result[STORAGE_KEYS.BSKY_CLIENT_SESSION]; + bskyClient.current = new BskyServiceWorkerClient(session); + setActionMode( + MESSAGE_NAME_TO_ACTION_MODE_MAP[ + result[STORAGE_KEYS.BSKY_MESSAGE_NAME] + ], + ); + }, + ); + }, []); + + const matchTypeStats = React.useMemo(() => { + return Object.keys(matchTypeFilter).reduce( + (acc, key) => { + if (key === BSKY_USER_MATCH_TYPE.FOLLOWING) { + return acc; + } + const count = users.filter((user) => user.matchType === key).length; + acc[key] = count; + return acc; + }, + {} as Record, + ); + }, [users, matchTypeFilter]); + + return { + handleClickAction, + users, + actionMode, + matchTypeFilter, + changeMatchTypeFilter, + filteredUsers, + actionAll, + matchTypeStats, + }; +}; diff --git a/src/lib/hooks/useRetrieveBskyUsers.ts b/src/lib/hooks/useRetrieveBskyUsers.ts index 126c299..f171b21 100644 --- a/src/lib/hooks/useRetrieveBskyUsers.ts +++ b/src/lib/hooks/useRetrieveBskyUsers.ts @@ -1,18 +1,15 @@ +import type { AtpSessionData } from "@atproto/api"; import React from "react"; import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient"; import { - ACTION_MODE, - BSKY_USER_MATCH_TYPE, type MESSAGE_NAMES, - MESSAGE_NAME_TO_ACTION_MODE_MAP, MESSAGE_NAME_TO_QUERY_PARAM_MAP, + STORAGE_KEYS, } from "~lib/constants"; import { getAccountNameAndDisplayName, getUserCells } from "~lib/domHelpers"; import { searchBskyUser } from "~lib/searchBskyUsers"; import { wait } from "~lib/utils"; - -export type MatchType = - (typeof BSKY_USER_MATCH_TYPE)[keyof typeof BSKY_USER_MATCH_TYPE]; +import type { MatchType } from "~types"; export type BskyUser = { did: string; @@ -39,24 +36,16 @@ const detectXUsers = (userCellQueryParam: string) => { export const useRetrieveBskyUsers = () => { const bskyClient = React.useRef(null); - const [actionMode, setActionMode] = React.useState< - (typeof ACTION_MODE)[keyof typeof ACTION_MODE] - >(ACTION_MODE.FOLLOW); const [detectedXUsers, setDetectedXUsers] = React.useState< ReturnType >([]); const [users, setUsers] = React.useState([]); const [loading, setLoading] = React.useState(true); const [errorMessage, setErrorMessage] = React.useState(""); - const [matchTypeFilter, setMatchTypeFilter] = React.useState({ - [BSKY_USER_MATCH_TYPE.HANDLE]: true, - [BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: true, - [BSKY_USER_MATCH_TYPE.DESCRIPTION]: true, - }); + const [isBottomReached, setIsBottomReached] = React.useState(false); const [retrievalParams, setRetrievalParams] = React.useState(null); @@ -65,58 +54,6 @@ export const useRetrieveBskyUsers = () => { modalRef.current?.showModal(); }; - const handleClickAction = React.useCallback( - async (user: (typeof users)[0]) => { - if (!bskyClient.current) return; - let resultUri: string | null = null; - - // follow - if (actionMode === ACTION_MODE.FOLLOW) { - if (user.isFollowing) { - await bskyClient.current.unfollow(user.followingUri); - } else { - const result = await bskyClient.current.follow(user.did); - resultUri = result.uri; - } - setUsers((prev) => - prev.map((prevUser) => { - if (prevUser.did === user.did) { - return { - ...prevUser, - isFollowing: !prevUser.isFollowing, - followingUri: resultUri ?? prevUser.followingUri, - }; - } - return prevUser; - }), - ); - } - - // block - if (actionMode === ACTION_MODE.BLOCK) { - if (user.isBlocking) { - await bskyClient.current.unblock(user.blockingUri); - } else { - const result = await bskyClient.current.block(user.did); - resultUri = result.uri; - } - setUsers((prev) => - prev.map((prevUser) => { - if (prevUser.did === user.did) { - return { - ...prevUser, - isBlocking: !prevUser.isBlocking, - blockingUri: resultUri ?? prevUser.blockingUri, - }; - } - return prevUser; - }), - ); - } - }, - [actionMode], - ); - const retrieveBskyUsers = React.useCallback( async (usersData: ReturnType[]) => { for (const userData of usersData) { @@ -157,7 +94,6 @@ export const useRetrieveBskyUsers = () => { abortControllerRef.current = new AbortController(); const signal = abortControllerRef.current.signal; - let isBottomReached = false; let index = 0; while (!isBottomReached) { @@ -185,7 +121,7 @@ export const useRetrieveBskyUsers = () => { documentElement.scrollTop + documentElement.clientHeight >= documentElement.scrollHeight ) { - isBottomReached = true; + setIsBottomReached(true); setLoading(false); } @@ -196,55 +132,51 @@ export const useRetrieveBskyUsers = () => { } } }, - [retrieveBskyUsers, detectedXUsers], + [retrieveBskyUsers, detectedXUsers, isBottomReached], ); - const stopRetrieveLoop = () => { + React.useEffect(() => { + chrome.storage.local.set({ users: JSON.stringify(users) }); + }, [users]); + + const stopRetrieveLoop = React.useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); + setLoading(false); } - }; + }, []); - const initialize = React.useCallback( - async ({ - identifier, - password, + // biome-ignore lint/correctness/useExhaustiveDependencies: + const initialize = React.useCallback(async () => { + const storage = await chrome.storage.local.get([ + STORAGE_KEYS.BSKY_CLIENT_SESSION, + STORAGE_KEYS.BSKY_MESSAGE_NAME, + ]); + const messageName = storage[STORAGE_KEYS.BSKY_MESSAGE_NAME]; + const session = storage[STORAGE_KEYS.BSKY_CLIENT_SESSION]; + + setRetrievalParams({ + session, messageName, - authFactorToken, - }: { - identifier: string; - password: string; - messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES]; - authFactorToken?: string; - }) => { - setRetrievalParams({ - identifier, - password, - messageName, - }); + }); - bskyClient.current = await BskyServiceWorkerClient.createAgent({ - identifier, - password, - ...(authFactorToken && { authFactorToken: authFactorToken }), - }); + bskyClient.current = new BskyServiceWorkerClient(session); - setActionMode(MESSAGE_NAME_TO_ACTION_MODE_MAP[messageName]); - startRetrieveLoop(MESSAGE_NAME_TO_QUERY_PARAM_MAP[messageName]).catch( - (e) => { - setErrorMessage(e.message); - setLoading(false); - }, - ); - setLoading(true); - showModal(); - }, - // biome-ignore lint/correctness/useExhaustiveDependencies: todo - [startRetrieveLoop, showModal], - ); + startRetrieveLoop(MESSAGE_NAME_TO_QUERY_PARAM_MAP[messageName]).catch( + (e) => { + console.error(e); + setErrorMessage(e.message); + setLoading(false); + }, + ); + setLoading(true); + showModal(); + }, []); const restart = React.useCallback(() => { - startRetrieveLoop(retrievalParams.messageName).catch((e) => { + startRetrieveLoop( + MESSAGE_NAME_TO_QUERY_PARAM_MAP[retrievalParams.messageName], + ).catch((e) => { setErrorMessage(e.message); setLoading(false); }); @@ -261,42 +193,17 @@ export const useRetrieveBskyUsers = () => { [loading, errorMessage, users.length], ); - const changeMatchTypeFilter = React.useCallback( - ( - matchType: (typeof BSKY_USER_MATCH_TYPE)[keyof typeof BSKY_USER_MATCH_TYPE], - ) => { - setMatchTypeFilter((prev) => { - return { - ...prev, - [matchType]: !prev[matchType], - }; - }); - }, - [], - ); - - // biome-ignore lint/correctness/useExhaustiveDependencies: todo - const filteredUsers = React.useMemo(() => { - return users.filter((user) => { - return matchTypeFilter[user.matchType]; - }); - }, [users, matchTypeFilter]); - return { modalRef, showModal, initialize, - handleClickAction, users, loading, - actionMode, errorMessage, isRateLimitError, restart, isSucceeded, - matchTypeFilter, - changeMatchTypeFilter, - filteredUsers, + isBottomReached, stopRetrieveLoop, }; }; diff --git a/src/options.tsx b/src/options.tsx new file mode 100644 index 0000000..a1d800a --- /dev/null +++ b/src/options.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from "react"; +import UserCard from "~lib/components/UserCard"; +import { useBskyUserManager } from "~lib/hooks/useBskyUserManager"; +import type { BskyUser } from "~lib/hooks/useRetrieveBskyUsers"; +import "./style.css"; +import Sidebar from "~lib/components/Sidebar"; + +const Option = () => { + const [users, setUsers] = useState([]); + const { + filteredUsers, + matchTypeFilter, + changeMatchTypeFilter, + handleClickAction, + actionMode, + actionAll, + matchTypeStats, + } = useBskyUserManager({ + users, + setUsers, + }); + useEffect(() => { + chrome.storage.local.get("users", (result) => { + setUsers(JSON.parse(result.users || "[]")); + }); + + const getUsers = () => { + chrome.storage.local.get("users", (result) => { + const _users = JSON.parse(result.users || "[]") as BskyUser[]; + setUsers((prev) => { + const newUsers = _users.filter( + (u) => !prev.some((p) => p.did === u.did), + ); + return [...prev, ...newUsers]; + }); + }); + }; + const interval = setInterval(getUsers, 2000); + return () => clearInterval(interval); + }, []); + + const handleActionAll = async () => { + if ( + !window.confirm( + "User detection is not perfect and may include false positives. Do you still want to proceed?", + ) + ) { + return; + } + + await actionAll(); + }; + return ( + <> +
+
+ +
+
+
+
+
+ {filteredUsers.map((user) => ( + + ))} +
+
+
+
+
+ + ); +}; + +export default Option; diff --git a/src/popup.tsx b/src/popup.tsx index f7792f0..dd0138c 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -3,7 +3,7 @@ import { P, match } from "ts-pattern"; import "./style.css"; -import { sendToContentScript } from "@plasmohq/messaging"; +import { sendToBackground, sendToContentScript } from "@plasmohq/messaging"; import { AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE, @@ -105,6 +105,10 @@ function IndexPopup() { ) .run(); + chrome.storage.local.set({ + [STORAGE_KEYS.BSKY_MESSAGE_NAME]: messageName, + }); + setMessage(null); setIsLoading(true); @@ -112,21 +116,26 @@ function IndexPopup() { identifier.includes(".") ? identifier : `${identifier}.bsky.social` ).replace(/^@/, ""); try { - const res: { hasError: boolean; message: string } = - await sendToContentScript({ - name: messageName, - body: { - identifier: formattedIdentifier, - password, - ...(authFactorToken && { authFactorToken: authFactorToken.trim() }), - }, - }); - if (res.hasError) { - if (res.message.includes(AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE)) { + const { session, error } = await sendToBackground({ + name: "login", + body: { + identifier, + password, + ...(authFactorToken && { authFactorToken: authFactorToken }), + }, + }); + chrome.storage.local.set({ + [STORAGE_KEYS.BSKY_CLIENT_SESSION]: session, + }); + await sendToContentScript({ + name: messageName, + }); + if (error) { + if (error.message.includes(AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE)) { setIsShowAuthFactorTokenInput(true); saveShowAuthFactorTokenInputToStorage(true); } else { - setErrorMessage(res.message); + setErrorMessage(error.message); } } else { saveShowAuthFactorTokenInputToStorage(false); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d6ffefd --- /dev/null +++ b/src/types.ts @@ -0,0 +1,24 @@ +import type { BSKY_USER_MATCH_TYPE } from "~lib/constants"; + +export type MatchType = + (typeof BSKY_USER_MATCH_TYPE)[keyof typeof BSKY_USER_MATCH_TYPE]; + +export type BskyUser = { + did: string; + avatar: string; + displayName: string; + handle: string; + description: string; + matchType: MatchType; + isFollowing: boolean; + followingUri: string | null; + isBlocking: boolean; + blockingUri: string | null; +}; + +export type MatchTypeFilterValue = { + [BSKY_USER_MATCH_TYPE.DESCRIPTION]: boolean; + [BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: boolean; + [BSKY_USER_MATCH_TYPE.HANDLE]: boolean; + [BSKY_USER_MATCH_TYPE.FOLLOWING]: boolean; +}; diff --git a/tailwind.config.js b/tailwind.config.js index b5e0999..a2edce4 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -26,4 +26,25 @@ module.exports = { } ], }, + theme: { + extend: { + keyframes: { + drillDown: { + '0%': { + transform: 'translateY(-20px) scale(0.95)', + opacity: '0', + zIndex: '0' + }, + '100%': { + transform: 'translateY(0) scale(1)', + opacity: '1', + zIndex: '10' + } + } + }, + animation: { + 'drill-down': 'drillDown 0.5s ease-in-out forwards' + } + } + } }