mirror of
https://github.com/snachodog/tok-to-insta-follower-bridge.git
synced 2025-04-03 10:41:25 -06:00
🚀 implement batch search feature
This commit is contained in:
parent
b9c51dd758
commit
e7852bcc9a
19
.storybook/main.ts
Normal file
19
.storybook/main.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-onboarding",
|
||||
"@storybook/addon-interactions",
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
};
|
||||
export default config;
|
16
.storybook/preview.ts
Normal file
16
.storybook/preview.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import '../src/style.content.css';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -2,5 +2,6 @@
|
||||
"cSpell.words": [
|
||||
"Bluesky",
|
||||
"BSKY"
|
||||
]
|
||||
],
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
|
19053
package-lock.json
generated
19053
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -11,7 +11,9 @@
|
||||
"package": "plasmo package",
|
||||
"package:firefox": "plasmo package --target=firefox-mv3",
|
||||
"run-client": "ts-node --project tsconfig.script.json scripts/client.ts",
|
||||
"check": "npx @biomejs/biome check --apply-unsafe ./src"
|
||||
"check": "npx @biomejs/biome check --apply-unsafe ./src",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.7.4",
|
||||
@ -27,6 +29,14 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.5.1",
|
||||
"@plasmohq/prettier-plugin-sort-imports": "4.0.1",
|
||||
"@storybook/addon-essentials": "^7.6.8",
|
||||
"@storybook/addon-interactions": "^7.6.8",
|
||||
"@storybook/addon-links": "^7.6.8",
|
||||
"@storybook/addon-onboarding": "^1.0.10",
|
||||
"@storybook/blocks": "^7.6.8",
|
||||
"@storybook/react": "^7.6.8",
|
||||
"@storybook/react-vite": "^7.6.8",
|
||||
"@storybook/test": "^7.6.8",
|
||||
"@types/chrome": "0.0.254",
|
||||
"@types/node": "20.10.6",
|
||||
"@types/react": "18.2.46",
|
||||
@ -35,6 +45,7 @@
|
||||
"daisyui": "^4.4.24",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "3.1.1",
|
||||
"storybook": "^7.6.8",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
|
@ -1,89 +0,0 @@
|
||||
import type { PlasmoCSConfig } from "plasmo";
|
||||
import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
|
||||
import { MESSAGE_NAMES, VIEWER_STATE } from "~lib/constants";
|
||||
import { searchAndInsertBskyUsers } from "~lib/searchAndInsertBskyUsers";
|
||||
import { type BskyLoginParams } from "./lib/bskyClient";
|
||||
import "./style.content.css";
|
||||
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: ["https://twitter.com/*", "https://x.com/*"],
|
||||
all_frames: true,
|
||||
};
|
||||
|
||||
const searchAndShowBskyUsers = async ({
|
||||
identifier,
|
||||
password,
|
||||
messageName,
|
||||
}: BskyLoginParams & { messageName: string }) => {
|
||||
const agent = await BskyServiceWorkerClient.createAgent({
|
||||
identifier,
|
||||
password,
|
||||
});
|
||||
|
||||
switch (messageName) {
|
||||
case MESSAGE_NAMES.SEARCH_BSKY_USER_ON_FOLLOW_PAGE:
|
||||
await searchAndInsertBskyUsers({
|
||||
agent,
|
||||
btnLabel: {
|
||||
add: "Follow",
|
||||
remove: "Unfollow",
|
||||
progressive: "Following",
|
||||
},
|
||||
statusKey: VIEWER_STATE.FOLLOWING,
|
||||
userCellQueryParam:
|
||||
'[data-testid="primaryColumn"] [data-testid="UserCell"]',
|
||||
addQuery: async (arg: string) => await agent.follow(arg),
|
||||
removeQuery: async (arg: string) => await agent.unfollow(arg),
|
||||
});
|
||||
break;
|
||||
case MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE:
|
||||
await searchAndInsertBskyUsers({
|
||||
agent,
|
||||
btnLabel: {
|
||||
add: "Follow",
|
||||
remove: "Unfollow",
|
||||
progressive: "Following",
|
||||
},
|
||||
statusKey: VIEWER_STATE.FOLLOWING,
|
||||
userCellQueryParam:
|
||||
'[data-testid="cellInnerDiv"] [data-testid="UserCell"]',
|
||||
addQuery: async (arg: string) => await agent.follow(arg),
|
||||
removeQuery: async (arg: string) => await agent.unfollow(arg),
|
||||
});
|
||||
break;
|
||||
case MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE:
|
||||
// TODO: If already blocked, don't show blocking state. because blocking user can't find.
|
||||
await searchAndInsertBskyUsers({
|
||||
agent,
|
||||
btnLabel: {
|
||||
add: "Block",
|
||||
remove: "Unblock",
|
||||
progressive: "Blocking",
|
||||
},
|
||||
statusKey: VIEWER_STATE.BLOCKING,
|
||||
userCellQueryParam: '[data-testid="UserCell"]',
|
||||
addQuery: async (arg: string) => await agent.block(arg),
|
||||
removeQuery: async (arg: string) => await agent.unblock(arg),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
||||
if (Object.values(MESSAGE_NAMES).includes(message.name)) {
|
||||
searchAndShowBskyUsers({
|
||||
identifier: message.body.userId,
|
||||
password: message.body.password,
|
||||
messageName: message.name,
|
||||
})
|
||||
.then(() => {
|
||||
sendResponse({ hasError: false });
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
sendResponse({ hasError: true, message: e.toString() });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
129
src/contents/App.tsx
Normal file
129
src/contents/App.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import type { PlasmoCSConfig } from "plasmo";
|
||||
import React from "react";
|
||||
import AlertError from "~lib/components/AlertError";
|
||||
import AlertSuccess from "~lib/components/AlertSuccess";
|
||||
import MatchTypeFilter from "~lib/components/MatchTypeFilter";
|
||||
import Modal from "~lib/components/Modal";
|
||||
import UserCard from "~lib/components/UserCard";
|
||||
import UserCardSkeleton from "~lib/components/UserCardSkeleton";
|
||||
import { MESSAGE_NAMES } from "~lib/constants";
|
||||
import { useRetrieveBskyUsers } from "~lib/hooks/useRetrieveBskyUsers";
|
||||
import cssText from "data-text:~style.content.css";
|
||||
|
||||
export const config: PlasmoCSConfig = {
|
||||
matches: ["https://twitter.com/*", "https://x.com/*"],
|
||||
all_frames: true,
|
||||
};
|
||||
|
||||
export const getStyle = () => {
|
||||
const style = document.createElement("style");
|
||||
// patch for shadow dom
|
||||
style.textContent = cssText.replaceAll(":root", ":host");
|
||||
return style;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
initialize,
|
||||
modalRef,
|
||||
users,
|
||||
loading,
|
||||
handleClickAction,
|
||||
actionMode,
|
||||
errorMessage,
|
||||
restart,
|
||||
isRateLimitError,
|
||||
isSucceeded,
|
||||
matchTypeFilter,
|
||||
changeMatchTypeFilter,
|
||||
filteredUsers,
|
||||
} = useRetrieveBskyUsers();
|
||||
|
||||
React.useEffect(() => {
|
||||
const messageHandler = (
|
||||
message: {
|
||||
name: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
|
||||
body: {
|
||||
userId: string;
|
||||
password: string;
|
||||
};
|
||||
},
|
||||
_sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: Record<string, unknown>) => void,
|
||||
) => {
|
||||
if (Object.values(MESSAGE_NAMES).includes(message.name)) {
|
||||
initialize({
|
||||
identifier: message.body.userId,
|
||||
password: message.body.password,
|
||||
messageName: message.name,
|
||||
})
|
||||
.then(() => {
|
||||
sendResponse({ hasError: false });
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
sendResponse({ hasError: true, message: e.toString() });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
chrome.runtime.onMessage.addListener(messageHandler);
|
||||
return () => {
|
||||
chrome.runtime.onMessage.removeListener(messageHandler);
|
||||
};
|
||||
}, [initialize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal anchorRef={modalRef}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-2xl font-bold">Find Bluesky Users</h1>
|
||||
<div className="flex gap-3 items-center">
|
||||
{loading && (
|
||||
<p className="loading loading-spinner loading-md text-primary" />
|
||||
)}
|
||||
<p className="text-sm">Detected:</p>
|
||||
<p className="font-bold text-xl">{users.length}</p>
|
||||
</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>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
90
src/contents/content.ts
Normal file
90
src/contents/content.ts
Normal file
@ -0,0 +1,90 @@
|
||||
// TODO: Remove this file. This is for legacy code.
|
||||
// import type { PlasmoCSConfig } from "plasmo";
|
||||
// import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
|
||||
// import { MESSAGE_NAMES, VIEWER_STATE } from "~lib/constants";
|
||||
// import { searchAndInsertBskyUsers } from "~lib/searchAndInsertBskyUsers";
|
||||
// import { type BskyLoginParams } from "../lib/bskyClient";
|
||||
// import "../style.content.legacy.css";
|
||||
|
||||
// export const config: PlasmoCSConfig = {
|
||||
// matches: ["https://twitter.com/*", "https://x.com/*"],
|
||||
// all_frames: true,
|
||||
// };
|
||||
|
||||
// const searchAndShowBskyUsers = async ({
|
||||
// identifier,
|
||||
// password,
|
||||
// messageName,
|
||||
// }: BskyLoginParams & { messageName: string }) => {
|
||||
// const agent = await BskyServiceWorkerClient.createAgent({
|
||||
// identifier,
|
||||
// password,
|
||||
// });
|
||||
|
||||
// switch (messageName) {
|
||||
// case MESSAGE_NAMES.SEARCH_BSKY_USER_ON_FOLLOW_PAGE:
|
||||
// await searchAndInsertBskyUsers({
|
||||
// agent,
|
||||
// btnLabel: {
|
||||
// add: "Follow",
|
||||
// remove: "Unfollow",
|
||||
// progressive: "Following",
|
||||
// },
|
||||
// statusKey: VIEWER_STATE.FOLLOWING,
|
||||
// userCellQueryParam:
|
||||
// '[data-testid="primaryColumn"] [data-testid="UserCell"]',
|
||||
// addQuery: async (arg: string) => await agent.follow(arg),
|
||||
// removeQuery: async (arg: string) => await agent.unfollow(arg),
|
||||
// });
|
||||
// break;
|
||||
// case MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE:
|
||||
// await searchAndInsertBskyUsers({
|
||||
// agent,
|
||||
// btnLabel: {
|
||||
// add: "Follow",
|
||||
// remove: "Unfollow",
|
||||
// progressive: "Following",
|
||||
// },
|
||||
// statusKey: VIEWER_STATE.FOLLOWING,
|
||||
// userCellQueryParam:
|
||||
// '[data-testid="cellInnerDiv"] [data-testid="UserCell"]',
|
||||
// addQuery: async (arg: string) => await agent.follow(arg),
|
||||
// removeQuery: async (arg: string) => await agent.unfollow(arg),
|
||||
// });
|
||||
// break;
|
||||
// case MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE:
|
||||
// // TODO: If already blocked, don't show blocking state. because blocking user can't find.
|
||||
// await searchAndInsertBskyUsers({
|
||||
// agent,
|
||||
// btnLabel: {
|
||||
// add: "Block",
|
||||
// remove: "Unblock",
|
||||
// progressive: "Blocking",
|
||||
// },
|
||||
// statusKey: VIEWER_STATE.BLOCKING,
|
||||
// userCellQueryParam: '[data-testid="UserCell"]',
|
||||
// addQuery: async (arg: string) => await agent.block(arg),
|
||||
// removeQuery: async (arg: string) => await agent.unblock(arg),
|
||||
// });
|
||||
// break;
|
||||
// }
|
||||
// };
|
||||
|
||||
// chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
|
||||
// if (Object.values(MESSAGE_NAMES).includes(message.name)) {
|
||||
// searchAndShowBskyUsers({
|
||||
// identifier: message.body.userId,
|
||||
// password: message.body.password,
|
||||
// messageName: message.name,
|
||||
// })
|
||||
// .then(() => {
|
||||
// sendResponse({ hasError: false });
|
||||
// })
|
||||
// .catch((e) => {
|
||||
// console.error(e);
|
||||
// sendResponse({ hasError: true, message: e.toString() });
|
||||
// });
|
||||
// return true;
|
||||
// }
|
||||
// return false;
|
||||
// });
|
@ -1,3 +1,4 @@
|
||||
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
|
||||
import { sendToBackground } from "@plasmohq/messaging";
|
||||
|
||||
export type BskyLoginParams = {
|
||||
@ -45,7 +46,7 @@ export class BskyServiceWorkerClient {
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
return actors;
|
||||
return actors as ProfileView[];
|
||||
};
|
||||
|
||||
public follow = async (subjectDid: string) => {
|
||||
|
26
src/lib/components/AlertError.stories.tsx
Normal file
26
src/lib/components/AlertError.stories.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import AlertError from "./AlertError";
|
||||
|
||||
const meta: Meta<typeof AlertError> = {
|
||||
title: "CSUI/AlertError",
|
||||
component: AlertError,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AlertError>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Error!",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRestartButton: Story = {
|
||||
args: {
|
||||
children: "Rate limit Error!",
|
||||
retryAction: () => {
|
||||
alert("restart!");
|
||||
},
|
||||
},
|
||||
};
|
45
src/lib/components/AlertError.tsx
Normal file
45
src/lib/components/AlertError.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import type React from "react";
|
||||
|
||||
type props = {
|
||||
children: React.ReactNode;
|
||||
retryAction?: () => void;
|
||||
};
|
||||
|
||||
const AlertError = ({ children, retryAction }: props) => (
|
||||
<div className="flex gap-2 items-center text-red-600 border border-red-600 p-2 rounded-md justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="stroke-current flex-shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{children}
|
||||
{!!retryAction && " Wait 3 minutes and press the restart button."}
|
||||
</span>
|
||||
</div>
|
||||
{!!retryAction && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => {
|
||||
retryAction();
|
||||
}}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AlertError;
|
17
src/lib/components/AlertSuccess.stories.tsx
Normal file
17
src/lib/components/AlertSuccess.stories.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import AlertSuccess from "./AlertSuccess";
|
||||
|
||||
const meta: Meta<typeof AlertSuccess> = {
|
||||
title: "CSUI/AlertSuccess",
|
||||
component: AlertSuccess,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof AlertSuccess>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Success!",
|
||||
},
|
||||
};
|
26
src/lib/components/AlertSuccess.tsx
Normal file
26
src/lib/components/AlertSuccess.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import type React from "react";
|
||||
|
||||
type props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const AlertSuccess = ({ children }: props) => (
|
||||
<div className="flex gap-2 items-center text-green-600 border border-green-600 p-2 rounded-md">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="stroke-current flex-shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AlertSuccess;
|
14
src/lib/components/Icons/AvatarFallbackSvg.tsx
Normal file
14
src/lib/components/Icons/AvatarFallbackSvg.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
const AvatarFallbackSvg = () => (
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="none">
|
||||
<circle cx="12" cy="12" r="12" fill="#0070ff" />
|
||||
<circle cx="12" cy="9.5" r="3.5" fill="#fff" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#fff"
|
||||
d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default AvatarFallbackSvg;
|
39
src/lib/components/Icons/BlueskyIconSvg.tsx
Normal file
39
src/lib/components/Icons/BlueskyIconSvg.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
function BlueskyIconSvg() {
|
||||
return (
|
||||
<svg
|
||||
version="1.0"
|
||||
id="katman_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 591 595.3"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<style>{".st0{fill:url(#SVGID_1_);}"}</style>
|
||||
<g>
|
||||
<linearGradient
|
||||
id="SVGID_1_"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="5.2935"
|
||||
y1="595.3044"
|
||||
x2="5.2935"
|
||||
y2="591.9684"
|
||||
gradientTransform="matrix(92 0 0 -81.2664 -66.0551 48427.1992)"
|
||||
>
|
||||
<stop offset="0" style={{ stopColor: "#0A7AFF" }} />
|
||||
<stop offset="1" style={{ stopColor: "#59B9FF" }} />
|
||||
</linearGradient>
|
||||
<path
|
||||
className="st0"
|
||||
d="M334,67.2c35.2,26.5,73,80.2,86.9,109.1v76.2c0-1.6-0.6,0.2-2,4.2c-7.3,21.4-35.6,104.8-100.3,38.1
|
||||
c-34.1-35.1-18.3-70.2,43.8-80.8c-35.5,6.1-75.4-4-86.4-43.2c-3.2-11.3-8.5-80.9-8.5-90.3C267.5,33.3,308.6,48,334,67.2z
|
||||
M507.9,67.2c-35.2,26.5-73,80.2-86.9,109.1v76.2c0-1.6,0.6,0.2,2,4.2c7.3,21.4,35.6,104.8,100.3,38.1
|
||||
c34.1-35.1,18.3-70.2-43.8-80.8c35.5,6.1,75.4-4,86.4-43.2c3.2-11.3,8.5-80.9,8.5-90.3C574.4,33.3,533.3,48,507.9,67.2z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlueskyIconSvg;
|
23
src/lib/components/MatchTypeFilter.stories.tsx
Normal file
23
src/lib/components/MatchTypeFilter.stories.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import { BSKY_USER_MATCH_TYPE } from "../constants";
|
||||
import MatchTypeFilter from "./MatchTypeFilter";
|
||||
|
||||
const meta: Meta<typeof MatchTypeFilter> = {
|
||||
title: "CSUI/MatchTypeFilter",
|
||||
component: MatchTypeFilter,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof MatchTypeFilter>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: {
|
||||
[BSKY_USER_MATCH_TYPE.HANDLE]: true,
|
||||
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: false,
|
||||
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: true,
|
||||
},
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
42
src/lib/components/MatchTypeFilter.tsx
Normal file
42
src/lib/components/MatchTypeFilter.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { BSKY_USER_MATCH_TYPE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
|
||||
import type { MatchType } from "../hooks/useRetrieveBskyUsers";
|
||||
|
||||
export type MatchTypeFilterValue = {
|
||||
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: boolean;
|
||||
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: boolean;
|
||||
[BSKY_USER_MATCH_TYPE.HANDLE]: boolean;
|
||||
};
|
||||
|
||||
export type props = {
|
||||
value: MatchTypeFilterValue;
|
||||
onChange: (key: MatchType) => void;
|
||||
};
|
||||
|
||||
const MatchTypeFilter = ({ value, onChange }: props) => {
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
{Object.keys(value).map((key: MatchType) => (
|
||||
<div className="form-control" key={key}>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className={`badge badge-${
|
||||
MATCH_TYPE_LABEL_AND_COLOR[key].color
|
||||
} gap-1 cursor-pointer py-3 ${value[key] ? "" : "badge-outline"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={key}
|
||||
checked={value[key]}
|
||||
onChange={() => onChange(key)}
|
||||
className="checkbox checkbox-xs"
|
||||
/>
|
||||
<span className="">{MATCH_TYPE_LABEL_AND_COLOR[key].label}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MatchTypeFilter;
|
75
src/lib/components/Modal.stories.tsx
Normal file
75
src/lib/components/Modal.stories.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import { useRef } from "react";
|
||||
import BlueskyIconSvg from "./Icons/BlueskyIconSvg";
|
||||
import Modal from "./Modal";
|
||||
import UserCard, { type Props as UserCardProps } from "./UserCard";
|
||||
|
||||
const meta: Meta<typeof UserCard> = {
|
||||
title: "CSUI/Modal",
|
||||
component: UserCard,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<{ items: UserCardProps["user"][] }>;
|
||||
|
||||
const DefaultTemplate: Story = {
|
||||
render: () => {
|
||||
const modalRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => modalRef.current?.showModal()}
|
||||
>
|
||||
open
|
||||
</button>
|
||||
<Modal anchorRef={modalRef}>
|
||||
<p>Modal content</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const ShowModalTemplate: Story = {
|
||||
render: () => {
|
||||
const modalRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => modalRef.current?.showModal()}
|
||||
>
|
||||
open
|
||||
</button>
|
||||
<Modal anchorRef={modalRef} open>
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-xl font-bold">🔎 Find Bluesky Users</h1>
|
||||
<div className="text-xl">34 / 160</div>
|
||||
</div>
|
||||
<div className="flex gap-1 items-center mt-3">
|
||||
<p className="">Match type: </p>
|
||||
<div className="badge badge-info">Same handle name</div>
|
||||
<div className="badge badge-warning">Same display name</div>
|
||||
<div className="badge badge-secondary">
|
||||
Included handle name in description
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
...DefaultTemplate,
|
||||
};
|
||||
|
||||
export const ShowModal = {
|
||||
...ShowModalTemplate,
|
||||
};
|
24
src/lib/components/Modal.tsx
Normal file
24
src/lib/components/Modal.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
export type Props = {
|
||||
children: React.ReactNode;
|
||||
anchorRef: React.RefObject<HTMLDialogElement>;
|
||||
open?: boolean;
|
||||
};
|
||||
|
||||
const Modal = ({ children, anchorRef, open = false }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<dialog className="modal" ref={anchorRef} open={open}>
|
||||
<div className="modal-box p-10 bg-white w-[750px] max-w-none text-gray-800">
|
||||
{children}
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="submit">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
109
src/lib/components/UserCard.stories.tsx
Normal file
109
src/lib/components/UserCard.stories.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import { ACTION_MODE, BSKY_USER_MATCH_TYPE } from "../constants";
|
||||
import UserCard, { type Props } from "./UserCard";
|
||||
|
||||
const meta: Meta<typeof UserCard> = {
|
||||
title: "CSUI/UserCard",
|
||||
component: UserCard,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<{
|
||||
items: {
|
||||
user: Props["user"];
|
||||
action: Props["clickAction"];
|
||||
}[];
|
||||
}>;
|
||||
|
||||
const demoUser: Props["user"] = {
|
||||
did: "",
|
||||
handle: "kawamataryo.bsky.social",
|
||||
displayName: "KawamataRyo",
|
||||
description: `
|
||||
Frontend engineer @lapras-inc/ TypeScript / Vue.js / Firebase / ex-FireFighter 🔥
|
||||
Developer of Sky Follower Bridge.
|
||||
|
||||
Twitter: twitter.com/KawamataRyo
|
||||
GitHub: github.com/kawamataryo
|
||||
Zenn: zenn.dev/ryo_kawamata`,
|
||||
avatar:
|
||||
"https://cdn.bsky.app/img/avatar/plain/did:plc:hcp53er6pefwijpdceo5x4bp/bafkreibm42fe6ionzntt2oryzv2coulgiwh5ejman4vf53bpkdtotszpp4@jpeg",
|
||||
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
|
||||
isFollowing: false,
|
||||
followingUri: "",
|
||||
isBlocking: false,
|
||||
blockingUri: "",
|
||||
};
|
||||
|
||||
const mockAction: Props["clickAction"] = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
};
|
||||
|
||||
const CardTemplate = {
|
||||
render: (args: Story["args"]["items"][0]) => (
|
||||
<UserCard
|
||||
user={args.user}
|
||||
clickAction={args.action}
|
||||
actionMode={ACTION_MODE.FOLLOW}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const CardsTemplate: Story = {
|
||||
render: (args) => (
|
||||
<div className="divide-y divide-gray-400 border-y border-gray-400">
|
||||
{args.items.map((arg, i) => (
|
||||
<UserCard
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
|
||||
key={i}
|
||||
user={arg.user}
|
||||
clickAction={arg.action}
|
||||
actionMode={ACTION_MODE.FOLLOW}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
...CardTemplate,
|
||||
args: {
|
||||
action: mockAction,
|
||||
user: {
|
||||
...demoUser,
|
||||
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Cards = {
|
||||
...CardsTemplate,
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
action: mockAction,
|
||||
user: {
|
||||
...demoUser,
|
||||
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
|
||||
isFollowing: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: mockAction,
|
||||
user: {
|
||||
...demoUser,
|
||||
matchType: BSKY_USER_MATCH_TYPE.DESCRIPTION,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: mockAction,
|
||||
user: {
|
||||
...demoUser,
|
||||
matchType: BSKY_USER_MATCH_TYPE.DISPLAY_NAME,
|
||||
inFollowing: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
155
src/lib/components/UserCard.tsx
Normal file
155
src/lib/components/UserCard.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { ACTION_MODE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
|
||||
import type { BskyUser } from "../hooks/useRetrieveBskyUsers";
|
||||
import AvatarFallbackSvg from "./Icons/AvatarFallbackSvg";
|
||||
|
||||
export type Props = {
|
||||
user: BskyUser;
|
||||
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
|
||||
clickAction: (user: BskyUser) => Promise<void>;
|
||||
};
|
||||
|
||||
const UserCard = ({ user, actionMode, clickAction }: Props) => {
|
||||
const [isBtnHovered, setIsBtnHovered] = React.useState(false);
|
||||
const [isJustClicked, setIsJustClicked] = React.useState(false);
|
||||
const actionBtnLabelAndClass = React.useMemo(
|
||||
() =>
|
||||
match(actionMode)
|
||||
.with(ACTION_MODE.FOLLOW, () => {
|
||||
const follow = {
|
||||
label: "Follow on Bluesky",
|
||||
class: "btn-primary",
|
||||
};
|
||||
const following = {
|
||||
label: "Following on Bluesky",
|
||||
class:
|
||||
"btn-outline hover:bg-transparent hover:border hover:bg-transparent hover:text-base-content",
|
||||
};
|
||||
const unfollow = {
|
||||
label: "Unfollow on Bluesky",
|
||||
class:
|
||||
"text-red-500 hover:bg-transparent hover:border hover:border-red-500",
|
||||
};
|
||||
if (!isBtnHovered) {
|
||||
return user.isFollowing ? following : follow;
|
||||
}
|
||||
if (user.isFollowing) {
|
||||
return isJustClicked ? following : unfollow;
|
||||
}
|
||||
return follow;
|
||||
})
|
||||
.with(ACTION_MODE.BLOCK, () => {
|
||||
const block = {
|
||||
label: "block on Bluesky",
|
||||
class: "btn-primary",
|
||||
};
|
||||
const blocking = {
|
||||
label: "Blocking on Bluesky",
|
||||
class:
|
||||
"btn-outline hover:bg-transparent hover:border hover:bg-transparent hover:text-base-content",
|
||||
};
|
||||
const unblock = {
|
||||
label: "Unblock on Bluesky",
|
||||
class:
|
||||
"text-red-500 hover:bg-transparent hover:border hover:border-red-500",
|
||||
};
|
||||
if (!isBtnHovered) {
|
||||
return user.isBlocking ? blocking : block;
|
||||
}
|
||||
if (user.isBlocking) {
|
||||
return isJustClicked ? blocking : unblock;
|
||||
}
|
||||
return block;
|
||||
})
|
||||
.run(),
|
||||
[
|
||||
user.isFollowing,
|
||||
user.isBlocking,
|
||||
actionMode,
|
||||
isBtnHovered,
|
||||
isJustClicked,
|
||||
],
|
||||
);
|
||||
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const handleActionButtonClick = async () => {
|
||||
setLoading(true);
|
||||
await clickAction(user);
|
||||
setLoading(false);
|
||||
setIsJustClicked(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-base-100 w-full relative">
|
||||
<div
|
||||
className={`border-l-8 border-${
|
||||
MATCH_TYPE_LABEL_AND_COLOR[user.matchType].color
|
||||
} card-body relative py-3 px-4 rounded-sm grid grid-cols-[70px_1fr]`}
|
||||
>
|
||||
<div>
|
||||
<div className="avatar">
|
||||
<div className="w-14 rounded-full border border-white ">
|
||||
<a
|
||||
href={`https://bsky.app/profile/${user.handle}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt="" />
|
||||
) : (
|
||||
<AvatarFallbackSvg />
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<div>
|
||||
<h2 className="card-title break-all">
|
||||
<a
|
||||
href={`https://bsky.app/profile/${user.handle}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{user.displayName}
|
||||
</a>
|
||||
</h2>
|
||||
<p className="whitespace-nowrap w-fit break-all text-gray-500 text-sm">
|
||||
<a
|
||||
href={`https://bsky.app/profile/${user.handle}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@{user.handle}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm rounded-3xl ${
|
||||
loading ? "" : actionBtnLabelAndClass.class
|
||||
}`}
|
||||
onClick={handleActionButtonClick}
|
||||
onMouseEnter={() => setIsBtnHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
setIsBtnHovered(false);
|
||||
setIsJustClicked(false);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Processing..." : actionBtnLabelAndClass.label}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm break-all">{user.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserCard;
|
25
src/lib/components/UserCardSkeleton.tsx
Normal file
25
src/lib/components/UserCardSkeleton.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
const UserCardSkeleton = () => {
|
||||
return (
|
||||
<div className="w-full animate-pulse">
|
||||
<div className="card-body relative py-5 px-5 rounded-sm grid grid-cols-[70px_1fr]">
|
||||
<div>
|
||||
<div className="avatar">
|
||||
<div className="w-14 h-14 rounded-full bg-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<span className="rounded-xl bg-gray-400 w-40 h-4 block" />
|
||||
<span className="rounded-xl bg-gray-400 w-20 h-2 mt-1 block" />
|
||||
</div>
|
||||
<p className="flex flex-col gap-1 mt-2">
|
||||
<span className="rounded-xl bg-gray-400 w-[90%] h-2 block" />
|
||||
<span className="rounded-xl bg-gray-400 w-[80%] h-2 block" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserCardSkeleton;
|
13
src/lib/components/UserCardSkelton.stories.tsx
Normal file
13
src/lib/components/UserCardSkelton.stories.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import UserCardSkeleton from "./UserCardSkeleton";
|
||||
|
||||
const meta: Meta<typeof UserCardSkeleton> = {
|
||||
title: "CSUI/UserCardSkeleton",
|
||||
component: UserCardSkeleton,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof UserCardSkeleton>;
|
||||
|
||||
export const Default: Story = {};
|
@ -3,6 +3,29 @@ export const MESSAGE_NAMES = {
|
||||
SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE:
|
||||
"search_bsky_user_on_list_members_page",
|
||||
SEARCH_BSKY_USER_ON_BLOCK_PAGE: "search_bsky_user_on_block_page",
|
||||
} as const;
|
||||
|
||||
export const QUERY_PARAMS = {
|
||||
FOLLOW: '[data-testid="primaryColumn"] [data-testid="UserCell"]',
|
||||
BLOCK: '[data-testid="UserCell"]',
|
||||
LIST: '[data-testid="cellInnerDiv"] [data-testid="UserCell"]',
|
||||
} as const;
|
||||
|
||||
export const MESSAGE_NAME_TO_QUERY_PARAM_MAP = {
|
||||
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_FOLLOW_PAGE]: QUERY_PARAMS.FOLLOW,
|
||||
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE]: QUERY_PARAMS.LIST,
|
||||
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE]: QUERY_PARAMS.BLOCK,
|
||||
};
|
||||
|
||||
export const ACTION_MODE = {
|
||||
FOLLOW: "follow",
|
||||
BLOCK: "block",
|
||||
};
|
||||
|
||||
export const MESSAGE_NAME_TO_ACTION_MODE_MAP = {
|
||||
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_FOLLOW_PAGE]: ACTION_MODE.FOLLOW,
|
||||
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE]: ACTION_MODE.FOLLOW,
|
||||
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE]: ACTION_MODE.BLOCK,
|
||||
};
|
||||
|
||||
const STORAGE_PREFIX = "sky_follower_bridge_storage";
|
||||
@ -35,3 +58,18 @@ export const BSKY_USER_MATCH_TYPE = {
|
||||
} as const;
|
||||
|
||||
export const MAX_RELOAD_COUNT = 1;
|
||||
|
||||
export const MATCH_TYPE_LABEL_AND_COLOR = {
|
||||
[BSKY_USER_MATCH_TYPE.HANDLE]: {
|
||||
label: "Same handle name",
|
||||
color: "info",
|
||||
},
|
||||
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: {
|
||||
label: "Same display name",
|
||||
color: "warning",
|
||||
},
|
||||
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: {
|
||||
label: "Included handle name in description",
|
||||
color: "neutral",
|
||||
},
|
||||
};
|
||||
|
282
src/lib/hooks/useRetrieveBskyUsers.ts
Normal file
282
src/lib/hooks/useRetrieveBskyUsers.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import React from "react";
|
||||
import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
|
||||
import {
|
||||
ACTION_MODE,
|
||||
BSKY_USER_MATCH_TYPE,
|
||||
MESSAGE_NAMES,
|
||||
MESSAGE_NAME_TO_ACTION_MODE_MAP,
|
||||
MESSAGE_NAME_TO_QUERY_PARAM_MAP,
|
||||
} 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];
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const detectXUsers = (userCellQueryParam: string) => {
|
||||
const userCells = getUserCells({
|
||||
queryParam: userCellQueryParam,
|
||||
filterInsertedElement: true,
|
||||
});
|
||||
return userCells.map((userCell) => {
|
||||
return getAccountNameAndDisplayName(userCell);
|
||||
});
|
||||
};
|
||||
|
||||
export const useRetrieveBskyUsers = () => {
|
||||
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<
|
||||
ReturnType<typeof detectXUsers>
|
||||
>([]);
|
||||
const [users, setUsers] = React.useState<BskyUser[]>([]);
|
||||
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 [retrievalParams, setRetrievalParams] = React.useState<null | {
|
||||
identifier: string;
|
||||
password: string;
|
||||
messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
|
||||
}>(null);
|
||||
|
||||
const modalRef = React.useRef<HTMLDialogElement>(null);
|
||||
const 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(
|
||||
async (usersData: ReturnType<typeof getAccountNameAndDisplayName>[]) => {
|
||||
for (const userData of usersData) {
|
||||
const searchResult = await searchBskyUser({
|
||||
client: bskyClient.current,
|
||||
userData,
|
||||
});
|
||||
if (searchResult) {
|
||||
setUsers((prev) => {
|
||||
if (prev.some((u) => u.did === searchResult.bskyProfile.did)) {
|
||||
return prev;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
did: searchResult.bskyProfile.did,
|
||||
avatar: searchResult.bskyProfile.avatar,
|
||||
displayName: searchResult.bskyProfile.displayName,
|
||||
handle: searchResult.bskyProfile.handle,
|
||||
description: searchResult.bskyProfile.description,
|
||||
matchType: searchResult.matchType,
|
||||
isFollowing: !!searchResult.bskyProfile.viewer?.following,
|
||||
followingUri: searchResult.bskyProfile.viewer?.following,
|
||||
isBlocking: !!searchResult.bskyProfile.viewer?.blocking,
|
||||
blockingUri: searchResult.bskyProfile.viewer?.blocking,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const startRetrieveLoop = React.useCallback(
|
||||
async (queryParam: string) => {
|
||||
let isBottomReached = false;
|
||||
let index = 0;
|
||||
|
||||
while (!isBottomReached) {
|
||||
const data = detectXUsers(queryParam).filter((u) => {
|
||||
return !detectedXUsers.some(
|
||||
(t) => t.twAccountName === u.twAccountName,
|
||||
);
|
||||
});
|
||||
setDetectedXUsers((prev) => [...prev, ...data]);
|
||||
await retrieveBskyUsers(data);
|
||||
|
||||
// scroll to bottom
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
|
||||
// wait for fetching data by x
|
||||
await wait(3000);
|
||||
|
||||
// break if bottom is reached
|
||||
const documentElement = document.documentElement;
|
||||
if (
|
||||
documentElement.scrollTop + documentElement.clientHeight >=
|
||||
documentElement.scrollHeight
|
||||
) {
|
||||
isBottomReached = true;
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
index++;
|
||||
if (process.env.NODE_ENV === "development" && index > 5) {
|
||||
setLoading(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[retrieveBskyUsers, detectedXUsers],
|
||||
);
|
||||
|
||||
const initialize = React.useCallback(
|
||||
async ({
|
||||
identifier,
|
||||
password,
|
||||
messageName,
|
||||
}: {
|
||||
identifier: string;
|
||||
password: string;
|
||||
messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
|
||||
}) => {
|
||||
setRetrievalParams({
|
||||
identifier,
|
||||
password,
|
||||
messageName,
|
||||
});
|
||||
|
||||
bskyClient.current = await BskyServiceWorkerClient.createAgent({
|
||||
identifier,
|
||||
password,
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
[startRetrieveLoop, showModal],
|
||||
);
|
||||
|
||||
const restart = React.useCallback(() => {
|
||||
startRetrieveLoop(retrievalParams.messageName).catch((e) => {
|
||||
setErrorMessage(e.message);
|
||||
setLoading(false);
|
||||
});
|
||||
setLoading(true);
|
||||
}, [retrievalParams, startRetrieveLoop]);
|
||||
|
||||
const isRateLimitError = React.useMemo(() => {
|
||||
// TODO: improve this logic
|
||||
return errorMessage.toLowerCase().replace(" ", "").includes("ratelimit");
|
||||
}, [errorMessage]);
|
||||
|
||||
const isSucceeded = React.useMemo(
|
||||
() => !loading && !errorMessage && users.length > 0,
|
||||
[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],
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
147
src/lib/searchBskyUsers.ts
Normal file
147
src/lib/searchBskyUsers.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
|
||||
import { isSimilarUser } from "~lib/bskyHelpers";
|
||||
import {
|
||||
getAccountNameAndDisplayName,
|
||||
getUserCells,
|
||||
insertBskyProfileEl,
|
||||
insertNotFoundEl,
|
||||
insertReloadEl,
|
||||
} from "~lib/domHelpers";
|
||||
import { debugLog, isOneSymbol } from "~lib/utils";
|
||||
import type { BskyClient } from "./bskyClient";
|
||||
import type { BskyServiceWorkerClient } from "./bskyServiceWorkerClient";
|
||||
|
||||
const notFoundUserCache = new Set<string>();
|
||||
|
||||
const bskyUserUrlMap = new Map<string, string>();
|
||||
|
||||
export const searchBskyUser = async ({
|
||||
client,
|
||||
userData,
|
||||
}: {
|
||||
client: BskyServiceWorkerClient;
|
||||
userData: ReturnType<typeof getAccountNameAndDisplayName>;
|
||||
}) => {
|
||||
const searchTerms = [
|
||||
userData.twAccountNameRemoveUnderscore,
|
||||
userData.twDisplayName,
|
||||
];
|
||||
|
||||
for (const term of searchTerms) {
|
||||
// one symbol is not a valid search term for bsky
|
||||
if (!term || isOneSymbol(term)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const searchResults = await client.searchUser({
|
||||
term: term,
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
for (const searchResult of searchResults) {
|
||||
const { isSimilar: isUserFound, type } = isSimilarUser(
|
||||
// TODO: simplify
|
||||
{
|
||||
accountName: userData.twAccountName,
|
||||
accountNameRemoveUnderscore: userData.twAccountNameRemoveUnderscore,
|
||||
displayName: userData.twDisplayName,
|
||||
},
|
||||
searchResult,
|
||||
);
|
||||
|
||||
if (isUserFound) {
|
||||
return {
|
||||
bskyProfile: searchResult,
|
||||
matchType: type,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const searchBskyUsers = async ({
|
||||
agent,
|
||||
userCellQueryParam,
|
||||
}: {
|
||||
agent: BskyServiceWorkerClient | BskyClient;
|
||||
userCellQueryParam: string;
|
||||
}) => {
|
||||
const userCells = getUserCells({
|
||||
queryParam: userCellQueryParam,
|
||||
filterInsertedElement: true,
|
||||
});
|
||||
debugLog(`userCells length: ${userCells.length}`);
|
||||
|
||||
let index = 0;
|
||||
|
||||
const targetAccounts = [] as ProfileView[];
|
||||
|
||||
// loop over twitter user profile cells and search and insert bsky user
|
||||
for (const userCell of userCells) {
|
||||
const { twAccountName, twDisplayName, twAccountNameRemoveUnderscore } =
|
||||
getAccountNameAndDisplayName(userCell);
|
||||
|
||||
if (notFoundUserCache.has(twAccountName)) {
|
||||
insertNotFoundEl(userCell);
|
||||
continue;
|
||||
}
|
||||
|
||||
const searchTerms = [twAccountNameRemoveUnderscore, twDisplayName];
|
||||
|
||||
let targetAccount = null;
|
||||
let matchType = null;
|
||||
|
||||
// Loop over search parameters and break if a user is found
|
||||
searchLoop: for (const term of searchTerms) {
|
||||
// one symbol is not a valid search term for bsky
|
||||
if (!term || isOneSymbol(term)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const searchResults = await agent.searchUser({
|
||||
term: term,
|
||||
limit: 3,
|
||||
});
|
||||
|
||||
for (const searchResult of searchResults) {
|
||||
const { isSimilar: isUserFound, type } = isSimilarUser(
|
||||
{
|
||||
accountName: twAccountName,
|
||||
accountNameRemoveUnderscore: twAccountNameRemoveUnderscore,
|
||||
displayName: twDisplayName,
|
||||
},
|
||||
searchResult,
|
||||
);
|
||||
|
||||
if (isUserFound) {
|
||||
targetAccount = searchResult;
|
||||
matchType = type;
|
||||
break searchLoop; // Stop searching when a user is found
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
// insert bsky profile or not found element
|
||||
if (targetAccount) {
|
||||
targetAccounts.push(targetAccount);
|
||||
} else {
|
||||
notFoundUserCache.add(twAccountName);
|
||||
}
|
||||
|
||||
index++;
|
||||
|
||||
// if (process.env.NODE_ENV === "development" && index > 5) {
|
||||
// break
|
||||
// }
|
||||
}
|
||||
|
||||
return targetAccounts;
|
||||
};
|
@ -7,3 +7,7 @@ export const debugLog = (message: string) => {
|
||||
export const isOneSymbol = (str: string) => {
|
||||
return /^[^\w\s]$/.test(str);
|
||||
};
|
||||
|
||||
export const wait = (ms: number) => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { type FormEvent, useCallback, useEffect, useState } from "react";
|
||||
import { P, match } from "ts-pattern";
|
||||
|
||||
import "./style.css";
|
||||
@ -45,7 +45,7 @@ function IndexPopup() {
|
||||
});
|
||||
};
|
||||
|
||||
const loadCredentialsFromStorage = async () => {
|
||||
const loadCredentialsFromStorage = useCallback(async () => {
|
||||
chrome.storage.local.get(
|
||||
[STORAGE_KEYS.BSKY_PASSWORD, STORAGE_KEYS.BSKY_USER_ID],
|
||||
(result) => {
|
||||
@ -53,7 +53,7 @@ function IndexPopup() {
|
||||
setUserId(result[STORAGE_KEYS.BSKY_USER_ID] || "");
|
||||
},
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const searchBskyUser = async (e?: FormEvent) => {
|
||||
if (e) {
|
||||
@ -103,10 +103,7 @@ function IndexPopup() {
|
||||
if (res.hasError) {
|
||||
setErrorMessage(res.message);
|
||||
} else {
|
||||
setMessage({
|
||||
type: MESSAGE_TYPE.SUCCESS,
|
||||
message: "Completed. Try again if no results found.”",
|
||||
});
|
||||
window.close();
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
|
@ -1,210 +1,3 @@
|
||||
/* ----------------- */
|
||||
/* base */
|
||||
/* ----------------- */
|
||||
:root {
|
||||
--bsky-primary-color: rgb(29, 78, 216);
|
||||
--bsky-primary-hover-color: #2563eb;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper {
|
||||
font-family: "TwitterChirp",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper .match-type {
|
||||
color: var(--bsky-primary-color);
|
||||
padding: 2px 14px 2px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-radius: 10px 10px 0px 0px;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper .match-type.match-type__handle {
|
||||
background-color: #ffd700;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper .match-type.match-type__display-name {
|
||||
background-color: #FFA07A;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper .match-type.match-type__description {
|
||||
background-color: #D3D3D3;
|
||||
}
|
||||
|
||||
.bsky-user-content {
|
||||
background: rgb(2,0,36);
|
||||
background: var(--bsky-primary-color);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.bsky-user-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ----------------- */
|
||||
/* icon */
|
||||
/* ----------------- */
|
||||
.bsky-user-content .icon-section {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
.bsky-user-content .icon-section a{
|
||||
text-decoration: none;
|
||||
width: 40px;
|
||||
}
|
||||
.bsky-user-content .icon-section img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.bsky-user-content .icon-section .no-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #ccc;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ----------------- */
|
||||
/* card content */
|
||||
/* ----------------- */
|
||||
.bsky-user-content .content {
|
||||
width: 100%;
|
||||
}
|
||||
.bsky-user-content .content .display-name a {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.bsky-user-content .content .display-name a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bsky-user-content .content .handle {
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
}
|
||||
.bsky-user-content .content .description {
|
||||
margin-top: 5px;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.name-and-controller {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button {
|
||||
border: 1px solid #fff;
|
||||
padding: 6px 30px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #fff;
|
||||
background: #fff;
|
||||
color: var(--bsky-primary-color);
|
||||
border-radius: 27px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button__being {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button__being:hover {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
color: red;
|
||||
border: 1px solid red;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button__being.action-button__just-applied:hover {
|
||||
background: transparent !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid #fff !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button__processing {
|
||||
background: rgb(255,255,255, 0.3) !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid #fff !important;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* ----------------- */
|
||||
/* not found card */
|
||||
/* ----------------- */
|
||||
.bsky-user-content__not-found {
|
||||
font-family: "TwitterChirp",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;;
|
||||
padding: 4px 16px;
|
||||
background: #333;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.bsky-user-content__not-found svg {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
/* ----------------- */
|
||||
/* reload btn */
|
||||
/* ----------------- */
|
||||
.bsky-reload-btn-wrapper {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bsky-reload-btn {
|
||||
padding: 8px 16px;
|
||||
width: 280px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background-color: var(--bsky-primary-color);
|
||||
color: #fff;
|
||||
top: 60px;
|
||||
cursor: pointer;
|
||||
animation: btnwrapanime 1.5s infinite;
|
||||
box-shadow: 0 0 0 0 rgb(0, 53, 188);
|
||||
}
|
||||
|
||||
.bsky-reload-btn:hover {
|
||||
animation: none;
|
||||
background-color: var(--bsky-primary-hover-color);
|
||||
}
|
||||
|
||||
.bsky-reload-btn__processing {
|
||||
background-color: var(--bsky-primary-hover-color);
|
||||
animation: none;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
@keyframes btnwrapanime {
|
||||
70% {
|
||||
box-shadow: 0 0 0 20px rgba(233, 30, 99, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(233, 30, 99, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.bsky-fade-in {
|
||||
animation: fade-in 0.5s ease-in-out;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
210
src/style.content.legacy.css
Normal file
210
src/style.content.legacy.css
Normal file
@ -0,0 +1,210 @@
|
||||
/* ----------------- */
|
||||
/* base */
|
||||
/* ----------------- */
|
||||
:root {
|
||||
--bsky-primary-color: rgb(29, 78, 216);
|
||||
--bsky-primary-hover-color: #2563eb;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper {
|
||||
font-family: "TwitterChirp",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper .match-type {
|
||||
color: var(--bsky-primary-color);
|
||||
padding: 2px 14px 2px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-radius: 10px 10px 0px 0px;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper .match-type.match-type__handle {
|
||||
background-color: #ffd700;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper .match-type.match-type__display-name {
|
||||
background-color: #FFA07A;
|
||||
}
|
||||
|
||||
.bsky-user-content-wrapper .match-type.match-type__description {
|
||||
background-color: #D3D3D3;
|
||||
}
|
||||
|
||||
.bsky-user-content {
|
||||
background: rgb(2,0,36);
|
||||
background: var(--bsky-primary-color);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.bsky-user-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ----------------- */
|
||||
/* icon */
|
||||
/* ----------------- */
|
||||
.bsky-user-content .icon-section {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
.bsky-user-content .icon-section a{
|
||||
text-decoration: none;
|
||||
width: 40px;
|
||||
}
|
||||
.bsky-user-content .icon-section img {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.bsky-user-content .icon-section .no-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #ccc;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ----------------- */
|
||||
/* card content */
|
||||
/* ----------------- */
|
||||
.bsky-user-content .content {
|
||||
width: 100%;
|
||||
}
|
||||
.bsky-user-content .content .display-name a {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.bsky-user-content .content .display-name a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bsky-user-content .content .handle {
|
||||
font-size: 14px;
|
||||
color: #ccc;
|
||||
}
|
||||
.bsky-user-content .content .description {
|
||||
margin-top: 5px;
|
||||
font-size: 14px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.name-and-controller {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button {
|
||||
border: 1px solid #fff;
|
||||
padding: 6px 30px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #fff;
|
||||
background: #fff;
|
||||
color: var(--bsky-primary-color);
|
||||
border-radius: 27px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button__being {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button__being:hover {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
color: red;
|
||||
border: 1px solid red;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button__being.action-button__just-applied:hover {
|
||||
background: transparent !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid #fff !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name-and-controller .action-button__processing {
|
||||
background: rgb(255,255,255, 0.3) !important;
|
||||
color: #fff !important;
|
||||
border: 1px solid #fff !important;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* ----------------- */
|
||||
/* not found card */
|
||||
/* ----------------- */
|
||||
.bsky-user-content__not-found {
|
||||
font-family: "TwitterChirp",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;;
|
||||
padding: 4px 16px;
|
||||
background: #333;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.bsky-user-content__not-found svg {
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
/* ----------------- */
|
||||
/* reload btn */
|
||||
/* ----------------- */
|
||||
.bsky-reload-btn-wrapper {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bsky-reload-btn {
|
||||
padding: 8px 16px;
|
||||
width: 280px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background-color: var(--bsky-primary-color);
|
||||
color: #fff;
|
||||
top: 60px;
|
||||
cursor: pointer;
|
||||
animation: btnwrapanime 1.5s infinite;
|
||||
box-shadow: 0 0 0 0 rgb(0, 53, 188);
|
||||
}
|
||||
|
||||
.bsky-reload-btn:hover {
|
||||
animation: none;
|
||||
background-color: var(--bsky-primary-hover-color);
|
||||
}
|
||||
|
||||
.bsky-reload-btn__processing {
|
||||
background-color: var(--bsky-primary-hover-color);
|
||||
animation: none;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
@keyframes btnwrapanime {
|
||||
70% {
|
||||
box-shadow: 0 0 0 20px rgba(233, 30, 99, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(233, 30, 99, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.bsky-fade-in {
|
||||
animation: fade-in 0.5s ease-in-out;
|
||||
}
|
@ -1,19 +1,24 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
mode: "jit",
|
||||
darkMode: "class",
|
||||
content: ["./src/**/*.tsx"],
|
||||
plugins: [
|
||||
require("daisyui")
|
||||
],
|
||||
safelist: [
|
||||
{
|
||||
pattern: /(badge|border)-(info|warning|secondary|neutral|success)/,
|
||||
}
|
||||
],
|
||||
daisyui: {
|
||||
themes: [
|
||||
{
|
||||
winter: {
|
||||
...require("daisyui/src/theming/themes")["winter"],
|
||||
...require("daisyui/src/theming/themes").winter,
|
||||
primary: "#1D4ED8"
|
||||
}
|
||||
},
|
||||
]
|
||||
],
|
||||
},
|
||||
darkMode: ['class', '[data-theme="night"]']
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user