Merge pull request #34 from kawamataryo/feature/add-bulk-search

🚀 implement batch search feature
This commit is contained in:
ryo 2024-01-21 19:55:20 +09:00 committed by GitHub
commit 5a5ea839ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 20419 additions and 549 deletions

19
.storybook/main.ts Normal file
View 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
View 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;

View File

@ -2,5 +2,6 @@
"cSpell.words": [
"Bluesky",
"BSKY"
]
],
"editor.defaultFormatter": "biomejs.biome"
}

View File

@ -6,7 +6,7 @@
Instantly find and follow the same users from your X(Twitter) follows on Bluesky.
https://github.com/kawamataryo/sky-follower-bridge/assets/11070996/0c87f9b9-573f-48c3-b7ba-f54e0e30a7e7
https://github.com/kawamataryo/sky-follower-bridge/assets/11070996/67bdd228-dc67-4d0a-ac18-f3a3e0c7adf9
## 📦 Installation
@ -18,8 +18,8 @@ https://github.com/kawamataryo/sky-follower-bridge/assets/11070996/0c87f9b9-573f
1. On Twitter open Your [Following](https://twitter.com/following), [Followers](https://twitter.com/followers), or [Blocked](https://twitter.com/settings/blocked/all) users list, or the Members page of a public List.
2. Use the `Alt + B` shortcut or click on the toolbar icon to launch the Sky Follower Bridge extension.
3. Input your Bluesky login email or handle and an [app password](https://bsky.app/settings/app-passwords).
4. Execute the user search.
5. Bluesky users will appear in the Follower list.
4. Press the `Finding Bluesky Users` btn.
5. Bluesky users will appear in the Modal.
6. Click the "Follow" button to follow them on Bluesky.
## 🔧 Troubleshooting
@ -29,6 +29,4 @@ https://github.com/kawamataryo/sky-follower-bridge/assets/11070996/0c87f9b9-573f
## 🚨 Limitations
Note that due to Twitters limitations, its not possible to search all Followers at once. If you want to search for more, click the "Find More" button that appears on the screen.
![](https://github.com/kawamataryo/sky-follower-bridge/assets/11070996/98759f94-9d24-4ddc-bf7f-9ea94f577ede)
- User search may fail due to late limit in Bluesky's API. In this case, please wait for 2 to 3 minutes and execute the search again.

19053
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
},

View File

@ -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
View 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
View 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;
// });

View File

@ -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) => {

View 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!");
},
},
};

View 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;

View 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!",
},
};

View 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;

View 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;

View 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;

View 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: () => {},
},
};

View 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;

View 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,
};

View 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;

View 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,
},
},
],
},
};

View 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;

View 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;

View 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 = {};

View File

@ -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",
},
};

View 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
View 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;
};

View File

@ -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));
};

View File

@ -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 (

View File

@ -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;

View 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;
}

View File

@ -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"]']
}