mirror of
https://github.com/snachodog/tok-to-insta-follower-bridge.git
synced 2025-04-09 13:41:23 -06:00
Merge pull request #34 from kawamataryo/feature/add-bulk-search
🚀 implement batch search feature
This commit is contained in:
commit
5a5ea839ed
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"
|
||||
}
|
||||
|
10
README.md
10
README.md
@ -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 Twitter’s limitations, it’s 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.
|
||||
|
||||

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