mirror of
https://github.com/snachodog/tok-to-insta-follower-bridge.git
synced 2025-09-12 23:23:31 -06:00
🚀 implement batch search feature
This commit is contained in:
@@ -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));
|
||||
};
|
||||
|
Reference in New Issue
Block a user