mirror of
https://github.com/snachodog/tok-to-insta-follower-bridge.git
synced 2025-04-09 13:41:23 -06:00
new ui using the options page
This commit is contained in:
parent
cb1c8e605a
commit
58ef98fa70
33
package-lock.json
generated
33
package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@changesets/cli": "^2.27.1",
|
"@changesets/cli": "^2.27.1",
|
||||||
"@plasmohq/messaging": "^0.6.2",
|
"@plasmohq/messaging": "^0.6.2",
|
||||||
"@plasmohq/storage": "^1.9.1",
|
"@plasmohq/storage": "^1.9.1",
|
||||||
|
"framer-motion": "^11.11.11",
|
||||||
"plasmo": "^0.84.2",
|
"plasmo": "^0.84.2",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
@ -15188,6 +15189,30 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
@ -33710,6 +33735,14 @@
|
|||||||
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
|
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
|
||||||
"dev": true
|
"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": {
|
"fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
"author": "kawamataryou",
|
"author": "kawamataryou",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "plasmo dev",
|
"dev": "plasmo dev",
|
||||||
|
"dev:firefox": "plasmo dev --target=firefox-mv3",
|
||||||
"build": "plasmo build",
|
"build": "plasmo build",
|
||||||
"build:firefox": "plasmo build --target=firefox-mv3",
|
"build:firefox": "plasmo build --target=firefox-mv3",
|
||||||
"package": "plasmo package",
|
"package": "plasmo package",
|
||||||
@ -22,6 +23,7 @@
|
|||||||
"@changesets/cli": "^2.27.1",
|
"@changesets/cli": "^2.27.1",
|
||||||
"@plasmohq/messaging": "^0.6.2",
|
"@plasmohq/messaging": "^0.6.2",
|
||||||
"@plasmohq/storage": "^1.9.1",
|
"@plasmohq/storage": "^1.9.1",
|
||||||
|
"framer-motion": "^11.11.11",
|
||||||
"plasmo": "^0.84.2",
|
"plasmo": "^0.84.2",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
11
src/background/messages/openOptionPage.ts
Normal file
11
src/background/messages/openOptionPage.ts
Normal file
@ -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;
|
@ -1,8 +1,12 @@
|
|||||||
import cssText from "data-text:~style.content.css";
|
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 type { PlasmoCSConfig } from "plasmo";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import AlertError from "~lib/components/AlertError";
|
import AlertError from "~lib/components/AlertError";
|
||||||
import AlertSuccess from "~lib/components/AlertSuccess";
|
import AlertSuccess from "~lib/components/AlertSuccess";
|
||||||
|
import LoadingCards from "~lib/components/LoadingCards";
|
||||||
import MatchTypeFilter from "~lib/components/MatchTypeFilter";
|
import MatchTypeFilter from "~lib/components/MatchTypeFilter";
|
||||||
import Modal from "~lib/components/Modal";
|
import Modal from "~lib/components/Modal";
|
||||||
import UserCard from "~lib/components/UserCard";
|
import UserCard from "~lib/components/UserCard";
|
||||||
@ -28,40 +32,21 @@ const App = () => {
|
|||||||
modalRef,
|
modalRef,
|
||||||
users,
|
users,
|
||||||
loading,
|
loading,
|
||||||
handleClickAction,
|
|
||||||
actionMode,
|
|
||||||
errorMessage,
|
|
||||||
restart,
|
|
||||||
isRateLimitError,
|
|
||||||
isSucceeded,
|
|
||||||
matchTypeFilter,
|
|
||||||
changeMatchTypeFilter,
|
|
||||||
filteredUsers,
|
|
||||||
stopRetrieveLoop,
|
stopRetrieveLoop,
|
||||||
|
restart,
|
||||||
|
isBottomReached,
|
||||||
} = useRetrieveBskyUsers();
|
} = useRetrieveBskyUsers();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const messageHandler = (
|
const messageHandler = (
|
||||||
message: {
|
message: {
|
||||||
name: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
|
name: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
|
||||||
body: {
|
|
||||||
identifier: string;
|
|
||||||
password: string;
|
|
||||||
authFactorToken?: string;
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
_sender: chrome.runtime.MessageSender,
|
_sender: chrome.runtime.MessageSender,
|
||||||
sendResponse: (response?: Record<string, unknown>) => void,
|
sendResponse: (response?: Record<string, unknown>) => void,
|
||||||
) => {
|
) => {
|
||||||
if (Object.values(MESSAGE_NAMES).includes(message.name)) {
|
if (Object.values(MESSAGE_NAMES).includes(message.name)) {
|
||||||
initialize({
|
initialize()
|
||||||
identifier: message.body.identifier,
|
|
||||||
password: message.body.password,
|
|
||||||
messageName: message.name,
|
|
||||||
...(message.body.authFactorToken && {
|
|
||||||
authFactorToken: message.body.authFactorToken,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
sendResponse({ hasError: false });
|
sendResponse({ hasError: false });
|
||||||
})
|
})
|
||||||
@ -80,51 +65,67 @@ const App = () => {
|
|||||||
};
|
};
|
||||||
}, [initialize]);
|
}, [initialize]);
|
||||||
|
|
||||||
|
const openOptionPage = () => {
|
||||||
|
sendToBackground({ name: "openOptionPage" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAndShowDetectedUsers = async () => {
|
||||||
|
stopRetrieveLoop();
|
||||||
|
await chrome.storage.local.set({ users: JSON.stringify(users) });
|
||||||
|
openOptionPage();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal anchorRef={modalRef} onClose={stopRetrieveLoop}>
|
<Modal anchorRef={modalRef} onClose={stopRetrieveLoop}>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-2 items-center">
|
||||||
<div className="flex justify-between">
|
{loading && (
|
||||||
<h1 className="text-2xl font-bold">Find Bluesky Users</h1>
|
<p className="text-lg font-bold">
|
||||||
<div className="flex gap-3 items-center">
|
Scanning 𝕏 users to find bsky users...
|
||||||
{loading && (
|
</p>
|
||||||
<p className="loading loading-spinner loading-md text-primary" />
|
)}
|
||||||
)}
|
<p className="text-2xl font-bold">
|
||||||
<p className="text-sm">Detected:</p>
|
Detected <span className="text-4xl">{users.length}</span> users
|
||||||
<p className="font-bold text-xl">{users.length}</p>
|
</p>
|
||||||
|
{loading && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary mt-5 btn-ghost"
|
||||||
|
onClick={stopAndShowDetectedUsers}
|
||||||
|
>
|
||||||
|
Stop Scanning and View Results
|
||||||
|
</button>
|
||||||
|
<LoadingCards />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!loading && !isBottomReached && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary mt-5"
|
||||||
|
onClick={restart}
|
||||||
|
>
|
||||||
|
Resume Scanning
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!loading && isBottomReached && (
|
||||||
|
<div className="flex flex-col gap-2 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary mt-5"
|
||||||
|
onClick={openOptionPage}
|
||||||
|
>
|
||||||
|
View Detected Users
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary mt-5 btn-ghost"
|
||||||
|
onClick={restart}
|
||||||
|
>
|
||||||
|
Resume Scanning
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<MatchTypeFilter
|
|
||||||
value={matchTypeFilter}
|
|
||||||
onChange={changeMatchTypeFilter}
|
|
||||||
/>
|
|
||||||
{isSucceeded && (
|
|
||||||
<AlertSuccess>
|
|
||||||
<span className="font-bold">{users.length}</span> Bluesky accounts
|
|
||||||
detected.
|
|
||||||
</AlertSuccess>
|
|
||||||
)}
|
)}
|
||||||
{errorMessage && (
|
|
||||||
<AlertError retryAction={isRateLimitError ? restart : undefined}>
|
|
||||||
{errorMessage}
|
|
||||||
</AlertError>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-4 overflow-scroll max-h-[60vh]">
|
|
||||||
{filteredUsers.length > 0 ? (
|
|
||||||
<div className="">
|
|
||||||
{filteredUsers.map((user) => (
|
|
||||||
<UserCard
|
|
||||||
key={user.handle}
|
|
||||||
user={user}
|
|
||||||
clickAction={handleClickAction}
|
|
||||||
actionMode={actionMode}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
loading && <UserCardSkeleton />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import type { AtpSessionData } from "@atproto/api";
|
||||||
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
|
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
|
||||||
import { sendToBackground } from "@plasmohq/messaging";
|
import { sendToBackground } from "@plasmohq/messaging";
|
||||||
|
import { STORAGE_KEYS } from "./constants";
|
||||||
|
|
||||||
export type BskyLoginParams = {
|
export type BskyLoginParams = {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@ -8,16 +10,17 @@ export type BskyLoginParams = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class BskyServiceWorkerClient {
|
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,
|
identifier,
|
||||||
password,
|
password,
|
||||||
authFactorToken,
|
authFactorToken,
|
||||||
}: BskyLoginParams): Promise<BskyServiceWorkerClient> {
|
}: BskyLoginParams): Promise<BskyServiceWorkerClient> {
|
||||||
const client = new BskyServiceWorkerClient();
|
|
||||||
const { session, error } = await sendToBackground({
|
const { session, error } = await sendToBackground({
|
||||||
name: "login",
|
name: "login",
|
||||||
body: {
|
body: {
|
||||||
@ -28,8 +31,11 @@ export class BskyServiceWorkerClient {
|
|||||||
});
|
});
|
||||||
if (error) throw new Error(error.message);
|
if (error) throw new Error(error.message);
|
||||||
|
|
||||||
client.session = session;
|
chrome.storage.local.set({
|
||||||
return client;
|
[STORAGE_KEYS.BSKY_CLIENT_SESSION]: session,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new BskyServiceWorkerClient(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
public searchUser = async ({
|
public searchUser = async ({
|
||||||
|
29
src/lib/components/AsyncButton.tsx
Normal file
29
src/lib/components/AsyncButton.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClick: () => Promise<void>;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AsyncButton = ({ onClick, label }: Props) => {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await onClick();
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-wide btn-sm mb-2"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Processing..." : label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AsyncButton;
|
15
src/lib/components/Header.stories.tsx
Normal file
15
src/lib/components/Header.stories.tsx
Normal file
@ -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<typeof Header>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
49
src/lib/components/Header.tsx
Normal file
49
src/lib/components/Header.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
const Header = () => {
|
||||||
|
return (
|
||||||
|
<div className="navbar bg-base-100 border-b border-base-200">
|
||||||
|
<div className="flex-1 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
d="M36 8H13c-3 0-9 2-9 8s6 8 9 8h22c3 0 9 2 9 8s-6 8-9 8H12"
|
||||||
|
/>
|
||||||
|
<path d="M40 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8ZM8 44a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span className="text-2xl font-bold">Sky Follower Bridge</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-none">
|
||||||
|
<a
|
||||||
|
href="https://github.com/kawamataryo/sky-follower-bridge"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="fill-current"
|
||||||
|
>
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
15
src/lib/components/LoadingCards.stories.tsx
Normal file
15
src/lib/components/LoadingCards.stories.tsx
Normal file
@ -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<typeof LoadingCards>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof LoadingCards>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {},
|
||||||
|
};
|
65
src/lib/components/LoadingCards.tsx
Normal file
65
src/lib/components/LoadingCards.tsx
Normal file
@ -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: <explanation>
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
moveToEnd(0);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[100px] w-[400px] mt-[50px]">
|
||||||
|
<ul className="relative">
|
||||||
|
{cards.map((color, index) => {
|
||||||
|
return (
|
||||||
|
<motion.li
|
||||||
|
key={color}
|
||||||
|
className="absolute w-[400px] card bg-base-100 border border-gray-400"
|
||||||
|
layout
|
||||||
|
animate={{
|
||||||
|
top: index * -CARD_OFFSET,
|
||||||
|
scale: 1 - index * SCALE_FACTOR,
|
||||||
|
zIndex: CARD_COLORS.length - index,
|
||||||
|
opacity: 1 - index * 0.2,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.8,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
layout: { duration: 0.8 },
|
||||||
|
opacity: { duration: 0.4 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserCardSkeleton />
|
||||||
|
</motion.li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(LoadingCards);
|
@ -24,7 +24,7 @@ const Modal = ({ children, anchorRef, open = false, onClose }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<dialog className="modal" ref={anchorRef} open={open}>
|
<dialog className="modal" ref={anchorRef} open={open}>
|
||||||
<div className="modal-box p-10 bg-base-100 w-[750px] max-w-none text-base-content">
|
<div className="modal-box p-10 bg-base-100 w-[500px] max-w-none text-base-content">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" className="modal-backdrop">
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
44
src/lib/components/Sidebar.stories.tsx
Normal file
44
src/lib/components/Sidebar.stories.tsx
Normal file
@ -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<typeof Sidebar>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
163
src/lib/components/Sidebar.tsx
Normal file
163
src/lib/components/Sidebar.tsx
Normal file
@ -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<void>;
|
||||||
|
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
|
||||||
|
matchTypeStats: Record<MatchType, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sidebar = ({
|
||||||
|
detectedCount,
|
||||||
|
filterValue,
|
||||||
|
onChangeFilter,
|
||||||
|
actionAll,
|
||||||
|
actionMode,
|
||||||
|
matchTypeStats,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<aside className="bg-base-300 w-80 min-h-screen p-4 border-r border-base-300 flex flex-col">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="4"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
d="M36 8H13c-3 0-9 2-9 8s6 8 9 8h22c3 0 9 2 9 8s-6 8-9 8H12"
|
||||||
|
/>
|
||||||
|
<path d="M40 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8ZM8 44a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span className="text-2xl font-bold">Sky Follower Bridge</span>
|
||||||
|
</div>
|
||||||
|
<div className="divider" />
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<p className="text-xl font-bold">Detected</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end mb mb-2">
|
||||||
|
<div className="flex gap-3 items-center justify-between">
|
||||||
|
<p className="font-bold text-2xl">{detectedCount}</p>
|
||||||
|
<p className="text-sm">users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-col">
|
||||||
|
{Object.keys(matchTypeStats).map((key) => (
|
||||||
|
<div
|
||||||
|
className={`badge badge-outline badge-${MATCH_TYPE_LABEL_AND_COLOR[key].color}`}
|
||||||
|
key={key}
|
||||||
|
>
|
||||||
|
{MATCH_TYPE_LABEL_AND_COLOR[key].label}:
|
||||||
|
<span className="font-bold text-sm ml-1">
|
||||||
|
{matchTypeStats[key]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="divider" />
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-xl font-bold">Filter</p>
|
||||||
|
</div>
|
||||||
|
{Object.keys(filterValue).map((key: MatchType) => (
|
||||||
|
<div className="form-control" key={key}>
|
||||||
|
<label htmlFor={key} className="label cursor-pointer">
|
||||||
|
<span className="text-sm">
|
||||||
|
{key === BSKY_USER_MATCH_TYPE.FOLLOWING &&
|
||||||
|
actionMode === ACTION_MODE.BLOCK
|
||||||
|
? "Blocked users"
|
||||||
|
: MATCH_TYPE_LABEL_AND_COLOR[key].label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={key}
|
||||||
|
checked={filterValue[key]}
|
||||||
|
onChange={() => onChangeFilter(key)}
|
||||||
|
className="checkbox checkbox-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="divider" />
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
stroke="currentColor"
|
||||||
|
className="w-5 h-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58m-.119-8.54a6 6 0 0 0-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 0 0-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 0 1-2.448-2.448 14.9 14.9 0 0 1 .06-.312m-2.24 2.39a4.493 4.493 0 0 0-1.757 4.306 4.493 4.493 0 0 0 4.306-1.758M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-xl font-bold">Action</p>
|
||||||
|
</div>
|
||||||
|
<AsyncButton
|
||||||
|
onClick={actionAll}
|
||||||
|
label={actionMode === ACTION_MODE.FOLLOW ? "Follow All" : "Block All"}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
* User detection is not perfect and may include false positives.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto">
|
||||||
|
<div className="divider" />
|
||||||
|
<SocialLinks />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
72
src/lib/components/SocialLinks.tsx
Normal file
72
src/lib/components/SocialLinks.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
const SocialLinks = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 justify-center">
|
||||||
|
<a
|
||||||
|
href="https://github.com/kawamataryo/sky-follower-bridge"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://bsky.app/profile/kawamataryo.bsky.social"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M5.65 1.83c2.77 2.08 5.75 6.3 6.85 8.56 1.09-2.26 4.07-6.48 6.85-8.56 2-1.5 5.24-2.66 5.24 1.03 0 0.74-0.42 6.2-0.67 7.09-0.86 3.08-4.01 3.87-6.8 3.39 4.89 0.83 6.13 3.59 3.45 6.34-5.1 5.23-7.33-1.31-7.9-2.99-0.1-0.31-0.15-0.45-0.15-0.33 0-0.12-0.05 0.02-0.15 0.33-0.57 1.68-2.8 8.22-7.9 2.99-2.68-2.75-1.44-5.51 3.45-6.34-2.79 0.48-5.94-0.31-6.8-3.39-0.25-0.89-0.67-6.35-0.67-7.09 0-3.69 3.24-2.53 5.24-1.03z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/KawamataRyo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/kawamata"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SocialLinks;
|
@ -7,14 +7,26 @@ const UserCardSkeleton = () => {
|
|||||||
<div className="w-14 h-14 rounded-full bg-gray-400" />
|
<div className="w-14 h-14 rounded-full bg-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<div>
|
<div>
|
||||||
<span className="rounded-xl bg-gray-400 w-40 h-4 block" />
|
<span
|
||||||
<span className="rounded-xl bg-gray-400 w-20 h-2 mt-1 block" />
|
className="rounded-xl bg-gray-400 h-4 block"
|
||||||
|
style={{ width: `${Math.floor(Math.random() * 20 + 30)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex flex-col gap-1 mt-2">
|
<p className="flex flex-col gap-1 mt-2">
|
||||||
<span className="rounded-xl bg-gray-400 w-[90%] h-2 block" />
|
<span
|
||||||
<span className="rounded-xl bg-gray-400 w-[80%] h-2 block" />
|
className="rounded-xl bg-gray-400 h-2 block"
|
||||||
|
style={{ width: `${Math.floor(Math.random() * 30 + 70)}%` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="rounded-xl bg-gray-400 h-2 block"
|
||||||
|
style={{ width: `${Math.floor(Math.random() * 30 + 70)}%` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="rounded-xl bg-gray-400 h-2 block"
|
||||||
|
style={{ width: `${Math.floor(Math.random() * 40 + 50)}%` }}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -33,6 +33,9 @@ export const STORAGE_KEYS = {
|
|||||||
BSKY_USER_ID: `${STORAGE_PREFIX}_bsky_password`,
|
BSKY_USER_ID: `${STORAGE_PREFIX}_bsky_password`,
|
||||||
BSKY_PASSWORD: `${STORAGE_PREFIX}_bsky_user`,
|
BSKY_PASSWORD: `${STORAGE_PREFIX}_bsky_user`,
|
||||||
BSKY_SHOW_AUTH_FACTOR_TOKEN_INPUT: `${STORAGE_PREFIX}_bsky_show_auth_factor_token_input`,
|
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;
|
} as const;
|
||||||
|
|
||||||
export const TARGET_URLS_REGEX = {
|
export const TARGET_URLS_REGEX = {
|
||||||
@ -55,6 +58,7 @@ export const BSKY_USER_MATCH_TYPE = {
|
|||||||
HANDLE: "handle",
|
HANDLE: "handle",
|
||||||
DISPLAY_NAME: "display_name",
|
DISPLAY_NAME: "display_name",
|
||||||
DESCRIPTION: "description",
|
DESCRIPTION: "description",
|
||||||
|
FOLLOWING: "following",
|
||||||
NONE: "none",
|
NONE: "none",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -73,6 +77,10 @@ export const MATCH_TYPE_LABEL_AND_COLOR = {
|
|||||||
label: "Included handle name in description",
|
label: "Included handle name in description",
|
||||||
color: "secondary",
|
color: "secondary",
|
||||||
},
|
},
|
||||||
|
[BSKY_USER_MATCH_TYPE.FOLLOWING]: {
|
||||||
|
label: "Followed users",
|
||||||
|
color: "success",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE =
|
export const AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE =
|
||||||
|
209
src/lib/hooks/useBskyUserManager.ts
Normal file
209
src/lib/hooks/useBskyUserManager.ts
Normal file
@ -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<React.SetStateAction<BskyUser[]>>;
|
||||||
|
}) => {
|
||||||
|
const bskyClient = React.useRef<BskyServiceWorkerClient | null>(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<MatchType, number>,
|
||||||
|
);
|
||||||
|
}, [users, matchTypeFilter]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleClickAction,
|
||||||
|
users,
|
||||||
|
actionMode,
|
||||||
|
matchTypeFilter,
|
||||||
|
changeMatchTypeFilter,
|
||||||
|
filteredUsers,
|
||||||
|
actionAll,
|
||||||
|
matchTypeStats,
|
||||||
|
};
|
||||||
|
};
|
@ -1,18 +1,15 @@
|
|||||||
|
import type { AtpSessionData } from "@atproto/api";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
|
import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
|
||||||
import {
|
import {
|
||||||
ACTION_MODE,
|
|
||||||
BSKY_USER_MATCH_TYPE,
|
|
||||||
type MESSAGE_NAMES,
|
type MESSAGE_NAMES,
|
||||||
MESSAGE_NAME_TO_ACTION_MODE_MAP,
|
|
||||||
MESSAGE_NAME_TO_QUERY_PARAM_MAP,
|
MESSAGE_NAME_TO_QUERY_PARAM_MAP,
|
||||||
|
STORAGE_KEYS,
|
||||||
} from "~lib/constants";
|
} from "~lib/constants";
|
||||||
import { getAccountNameAndDisplayName, getUserCells } from "~lib/domHelpers";
|
import { getAccountNameAndDisplayName, getUserCells } from "~lib/domHelpers";
|
||||||
import { searchBskyUser } from "~lib/searchBskyUsers";
|
import { searchBskyUser } from "~lib/searchBskyUsers";
|
||||||
import { wait } from "~lib/utils";
|
import { wait } from "~lib/utils";
|
||||||
|
import type { MatchType } from "~types";
|
||||||
export type MatchType =
|
|
||||||
(typeof BSKY_USER_MATCH_TYPE)[keyof typeof BSKY_USER_MATCH_TYPE];
|
|
||||||
|
|
||||||
export type BskyUser = {
|
export type BskyUser = {
|
||||||
did: string;
|
did: string;
|
||||||
@ -39,24 +36,16 @@ const detectXUsers = (userCellQueryParam: string) => {
|
|||||||
|
|
||||||
export const useRetrieveBskyUsers = () => {
|
export const useRetrieveBskyUsers = () => {
|
||||||
const bskyClient = React.useRef<BskyServiceWorkerClient | null>(null);
|
const bskyClient = React.useRef<BskyServiceWorkerClient | null>(null);
|
||||||
const [actionMode, setActionMode] = React.useState<
|
|
||||||
(typeof ACTION_MODE)[keyof typeof ACTION_MODE]
|
|
||||||
>(ACTION_MODE.FOLLOW);
|
|
||||||
const [detectedXUsers, setDetectedXUsers] = React.useState<
|
const [detectedXUsers, setDetectedXUsers] = React.useState<
|
||||||
ReturnType<typeof detectXUsers>
|
ReturnType<typeof detectXUsers>
|
||||||
>([]);
|
>([]);
|
||||||
const [users, setUsers] = React.useState<BskyUser[]>([]);
|
const [users, setUsers] = React.useState<BskyUser[]>([]);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [errorMessage, setErrorMessage] = React.useState("");
|
const [errorMessage, setErrorMessage] = React.useState("");
|
||||||
const [matchTypeFilter, setMatchTypeFilter] = React.useState({
|
const [isBottomReached, setIsBottomReached] = React.useState(false);
|
||||||
[BSKY_USER_MATCH_TYPE.HANDLE]: true,
|
|
||||||
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: true,
|
|
||||||
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [retrievalParams, setRetrievalParams] = React.useState<null | {
|
const [retrievalParams, setRetrievalParams] = React.useState<null | {
|
||||||
identifier: string;
|
session: AtpSessionData;
|
||||||
password: string;
|
|
||||||
messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
|
messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
|
||||||
}>(null);
|
}>(null);
|
||||||
|
|
||||||
@ -65,58 +54,6 @@ export const useRetrieveBskyUsers = () => {
|
|||||||
modalRef.current?.showModal();
|
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(
|
const retrieveBskyUsers = React.useCallback(
|
||||||
async (usersData: ReturnType<typeof getAccountNameAndDisplayName>[]) => {
|
async (usersData: ReturnType<typeof getAccountNameAndDisplayName>[]) => {
|
||||||
for (const userData of usersData) {
|
for (const userData of usersData) {
|
||||||
@ -157,7 +94,6 @@ export const useRetrieveBskyUsers = () => {
|
|||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
const signal = abortControllerRef.current.signal;
|
const signal = abortControllerRef.current.signal;
|
||||||
|
|
||||||
let isBottomReached = false;
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
while (!isBottomReached) {
|
while (!isBottomReached) {
|
||||||
@ -185,7 +121,7 @@ export const useRetrieveBskyUsers = () => {
|
|||||||
documentElement.scrollTop + documentElement.clientHeight >=
|
documentElement.scrollTop + documentElement.clientHeight >=
|
||||||
documentElement.scrollHeight
|
documentElement.scrollHeight
|
||||||
) {
|
) {
|
||||||
isBottomReached = true;
|
setIsBottomReached(true);
|
||||||
setLoading(false);
|
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) {
|
if (abortControllerRef.current) {
|
||||||
abortControllerRef.current.abort();
|
abortControllerRef.current.abort();
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const initialize = React.useCallback(
|
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
|
||||||
async ({
|
const initialize = React.useCallback(async () => {
|
||||||
identifier,
|
const storage = await chrome.storage.local.get([
|
||||||
password,
|
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,
|
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({
|
bskyClient.current = new BskyServiceWorkerClient(session);
|
||||||
identifier,
|
|
||||||
password,
|
|
||||||
...(authFactorToken && { authFactorToken: authFactorToken }),
|
|
||||||
});
|
|
||||||
|
|
||||||
setActionMode(MESSAGE_NAME_TO_ACTION_MODE_MAP[messageName]);
|
startRetrieveLoop(MESSAGE_NAME_TO_QUERY_PARAM_MAP[messageName]).catch(
|
||||||
startRetrieveLoop(MESSAGE_NAME_TO_QUERY_PARAM_MAP[messageName]).catch(
|
(e) => {
|
||||||
(e) => {
|
console.error(e);
|
||||||
setErrorMessage(e.message);
|
setErrorMessage(e.message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
showModal();
|
showModal();
|
||||||
},
|
}, []);
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: todo
|
|
||||||
[startRetrieveLoop, showModal],
|
|
||||||
);
|
|
||||||
|
|
||||||
const restart = React.useCallback(() => {
|
const restart = React.useCallback(() => {
|
||||||
startRetrieveLoop(retrievalParams.messageName).catch((e) => {
|
startRetrieveLoop(
|
||||||
|
MESSAGE_NAME_TO_QUERY_PARAM_MAP[retrievalParams.messageName],
|
||||||
|
).catch((e) => {
|
||||||
setErrorMessage(e.message);
|
setErrorMessage(e.message);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
@ -261,42 +193,17 @@ export const useRetrieveBskyUsers = () => {
|
|||||||
[loading, errorMessage, users.length],
|
[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 {
|
return {
|
||||||
modalRef,
|
modalRef,
|
||||||
showModal,
|
showModal,
|
||||||
initialize,
|
initialize,
|
||||||
handleClickAction,
|
|
||||||
users,
|
users,
|
||||||
loading,
|
loading,
|
||||||
actionMode,
|
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
restart,
|
restart,
|
||||||
isSucceeded,
|
isSucceeded,
|
||||||
matchTypeFilter,
|
isBottomReached,
|
||||||
changeMatchTypeFilter,
|
|
||||||
filteredUsers,
|
|
||||||
stopRetrieveLoop,
|
stopRetrieveLoop,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
87
src/options.tsx
Normal file
87
src/options.tsx
Normal file
@ -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<BskyUser[]>([]);
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<div className="fixed top-0 left-0 h-full">
|
||||||
|
<Sidebar
|
||||||
|
detectedCount={users.length}
|
||||||
|
filterValue={matchTypeFilter}
|
||||||
|
onChangeFilter={changeMatchTypeFilter}
|
||||||
|
actionAll={handleActionAll}
|
||||||
|
actionMode={actionMode}
|
||||||
|
matchTypeStats={matchTypeStats}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 ml-80 p-6 overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="divide-y divide-gray-500">
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<UserCard
|
||||||
|
key={user.handle}
|
||||||
|
user={user}
|
||||||
|
clickAction={handleClickAction}
|
||||||
|
actionMode={actionMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Option;
|
@ -3,7 +3,7 @@ import { P, match } from "ts-pattern";
|
|||||||
|
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
import { sendToContentScript } from "@plasmohq/messaging";
|
import { sendToBackground, sendToContentScript } from "@plasmohq/messaging";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE,
|
AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE,
|
||||||
@ -105,6 +105,10 @@ function IndexPopup() {
|
|||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
chrome.storage.local.set({
|
||||||
|
[STORAGE_KEYS.BSKY_MESSAGE_NAME]: messageName,
|
||||||
|
});
|
||||||
|
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
@ -112,21 +116,26 @@ function IndexPopup() {
|
|||||||
identifier.includes(".") ? identifier : `${identifier}.bsky.social`
|
identifier.includes(".") ? identifier : `${identifier}.bsky.social`
|
||||||
).replace(/^@/, "");
|
).replace(/^@/, "");
|
||||||
try {
|
try {
|
||||||
const res: { hasError: boolean; message: string } =
|
const { session, error } = await sendToBackground({
|
||||||
await sendToContentScript({
|
name: "login",
|
||||||
name: messageName,
|
body: {
|
||||||
body: {
|
identifier,
|
||||||
identifier: formattedIdentifier,
|
password,
|
||||||
password,
|
...(authFactorToken && { authFactorToken: authFactorToken }),
|
||||||
...(authFactorToken && { authFactorToken: authFactorToken.trim() }),
|
},
|
||||||
},
|
});
|
||||||
});
|
chrome.storage.local.set({
|
||||||
if (res.hasError) {
|
[STORAGE_KEYS.BSKY_CLIENT_SESSION]: session,
|
||||||
if (res.message.includes(AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE)) {
|
});
|
||||||
|
await sendToContentScript({
|
||||||
|
name: messageName,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
if (error.message.includes(AUTH_FACTOR_TOKEN_REQUIRED_ERROR_MESSAGE)) {
|
||||||
setIsShowAuthFactorTokenInput(true);
|
setIsShowAuthFactorTokenInput(true);
|
||||||
saveShowAuthFactorTokenInputToStorage(true);
|
saveShowAuthFactorTokenInputToStorage(true);
|
||||||
} else {
|
} else {
|
||||||
setErrorMessage(res.message);
|
setErrorMessage(error.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
saveShowAuthFactorTokenInputToStorage(false);
|
saveShowAuthFactorTokenInputToStorage(false);
|
||||||
|
24
src/types.ts
Normal file
24
src/types.ts
Normal file
@ -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;
|
||||||
|
};
|
@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user