🚀 implement batch search feature

This commit is contained in:
kawamataryo
2024-01-19 10:18:19 +09:00
parent b9c51dd758
commit e7852bcc9a
31 changed files with 20418 additions and 546 deletions

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
import { sendToBackground } from "@plasmohq/messaging";
export type BskyLoginParams = {
@@ -45,7 +46,7 @@ export class BskyServiceWorkerClient {
});
if (error) throw new Error(error.message);
return actors;
return actors as ProfileView[];
};
public follow = async (subjectDid: string) => {

View File

@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react";
import AlertError from "./AlertError";
const meta: Meta<typeof AlertError> = {
title: "CSUI/AlertError",
component: AlertError,
};
export default meta;
type Story = StoryObj<typeof AlertError>;
export const Default: Story = {
args: {
children: "Error!",
},
};
export const WithRestartButton: Story = {
args: {
children: "Rate limit Error!",
retryAction: () => {
alert("restart!");
},
},
};

View File

@@ -0,0 +1,45 @@
import type React from "react";
type props = {
children: React.ReactNode;
retryAction?: () => void;
};
const AlertError = ({ children, retryAction }: props) => (
<div className="flex gap-2 items-center text-red-600 border border-red-600 p-2 rounded-md justify-between">
<div className="flex gap-2 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current flex-shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
stroke-linejoin="round"
strokeWidth="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>
{children}
{!!retryAction && " Wait 3 minutes and press the restart button."}
</span>
</div>
{!!retryAction && (
<div>
<button
type="button"
className="btn btn-sm btn-outline"
onClick={() => {
retryAction();
}}
>
Restart
</button>
</div>
)}
</div>
);
export default AlertError;

View File

@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import AlertSuccess from "./AlertSuccess";
const meta: Meta<typeof AlertSuccess> = {
title: "CSUI/AlertSuccess",
component: AlertSuccess,
};
export default meta;
type Story = StoryObj<typeof AlertSuccess>;
export const Default: Story = {
args: {
children: "Success!",
},
};

View File

@@ -0,0 +1,26 @@
import type React from "react";
type props = {
children: React.ReactNode;
};
const AlertSuccess = ({ children }: props) => (
<div className="flex gap-2 items-center text-green-600 border border-green-600 p-2 rounded-md">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current flex-shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{children}</span>
</div>
);
export default AlertSuccess;

View File

@@ -0,0 +1,14 @@
const AvatarFallbackSvg = () => (
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="none">
<circle cx="12" cy="12" r="12" fill="#0070ff" />
<circle cx="12" cy="9.5" r="3.5" fill="#fff" />
<path
strokeLinecap="round"
strokeLinejoin="round"
fill="#fff"
d="M 12.058 22.784 C 9.422 22.784 7.007 21.836 5.137 20.262 C 5.667 17.988 8.534 16.25 11.99 16.25 C 15.494 16.25 18.391 18.036 18.864 20.357 C 17.01 21.874 14.64 22.784 12.058 22.784 Z"
/>
</svg>
);
export default AvatarFallbackSvg;

View File

@@ -0,0 +1,39 @@
function BlueskyIconSvg() {
return (
<svg
version="1.0"
id="katman_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 591 595.3"
xmlSpace="preserve"
>
<style>{".st0{fill:url(#SVGID_1_);}"}</style>
<g>
<linearGradient
id="SVGID_1_"
gradientUnits="userSpaceOnUse"
x1="5.2935"
y1="595.3044"
x2="5.2935"
y2="591.9684"
gradientTransform="matrix(92 0 0 -81.2664 -66.0551 48427.1992)"
>
<stop offset="0" style={{ stopColor: "#0A7AFF" }} />
<stop offset="1" style={{ stopColor: "#59B9FF" }} />
</linearGradient>
<path
className="st0"
d="M334,67.2c35.2,26.5,73,80.2,86.9,109.1v76.2c0-1.6-0.6,0.2-2,4.2c-7.3,21.4-35.6,104.8-100.3,38.1
c-34.1-35.1-18.3-70.2,43.8-80.8c-35.5,6.1-75.4-4-86.4-43.2c-3.2-11.3-8.5-80.9-8.5-90.3C267.5,33.3,308.6,48,334,67.2z
M507.9,67.2c-35.2,26.5-73,80.2-86.9,109.1v76.2c0-1.6,0.6,0.2,2,4.2c7.3,21.4,35.6,104.8,100.3,38.1
c34.1-35.1,18.3-70.2-43.8-80.8c35.5,6.1,75.4-4,86.4-43.2c3.2-11.3,8.5-80.9,8.5-90.3C574.4,33.3,533.3,48,507.9,67.2z"
/>
</g>
</svg>
);
}
export default BlueskyIconSvg;

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BSKY_USER_MATCH_TYPE } from "../constants";
import MatchTypeFilter from "./MatchTypeFilter";
const meta: Meta<typeof MatchTypeFilter> = {
title: "CSUI/MatchTypeFilter",
component: MatchTypeFilter,
};
export default meta;
type Story = StoryObj<typeof MatchTypeFilter>;
export const Default: Story = {
args: {
value: {
[BSKY_USER_MATCH_TYPE.HANDLE]: true,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: false,
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: true,
},
onChange: () => {},
},
};

View File

@@ -0,0 +1,42 @@
import React from "react";
import { BSKY_USER_MATCH_TYPE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
import type { MatchType } from "../hooks/useRetrieveBskyUsers";
export type MatchTypeFilterValue = {
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: boolean;
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: boolean;
[BSKY_USER_MATCH_TYPE.HANDLE]: boolean;
};
export type props = {
value: MatchTypeFilterValue;
onChange: (key: MatchType) => void;
};
const MatchTypeFilter = ({ value, onChange }: props) => {
return (
<div className="flex gap-2 items-center">
{Object.keys(value).map((key: MatchType) => (
<div className="form-control" key={key}>
<label
htmlFor={key}
className={`badge badge-${
MATCH_TYPE_LABEL_AND_COLOR[key].color
} gap-1 cursor-pointer py-3 ${value[key] ? "" : "badge-outline"}`}
>
<input
type="checkbox"
id={key}
checked={value[key]}
onChange={() => onChange(key)}
className="checkbox checkbox-xs"
/>
<span className="">{MATCH_TYPE_LABEL_AND_COLOR[key].label}</span>
</label>
</div>
))}
</div>
);
};
export default MatchTypeFilter;

View File

@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useRef } from "react";
import BlueskyIconSvg from "./Icons/BlueskyIconSvg";
import Modal from "./Modal";
import UserCard, { type Props as UserCardProps } from "./UserCard";
const meta: Meta<typeof UserCard> = {
title: "CSUI/Modal",
component: UserCard,
};
export default meta;
type Story = StoryObj<{ items: UserCardProps["user"][] }>;
const DefaultTemplate: Story = {
render: () => {
const modalRef = useRef<HTMLDialogElement>(null);
return (
<>
<button
type="button"
className="btn btn-primary"
onClick={() => modalRef.current?.showModal()}
>
open
</button>
<Modal anchorRef={modalRef}>
<p>Modal content</p>
</Modal>
</>
);
},
};
const ShowModalTemplate: Story = {
render: () => {
const modalRef = useRef<HTMLDialogElement>(null);
return (
<>
<button
type="button"
className="btn btn-primary"
onClick={() => modalRef.current?.showModal()}
>
open
</button>
<Modal anchorRef={modalRef} open>
<div className="flex justify-between">
<h1 className="text-xl font-bold">🔎 Find Bluesky Users</h1>
<div className="text-xl">34 / 160</div>
</div>
<div className="flex gap-1 items-center mt-3">
<p className="">Match type: </p>
<div className="badge badge-info">Same handle name</div>
<div className="badge badge-warning">Same display name</div>
<div className="badge badge-secondary">
Included handle name in description
</div>
</div>
</Modal>
</>
);
},
};
export const Default = {
...DefaultTemplate,
};
export const ShowModal = {
...ShowModalTemplate,
};

View File

@@ -0,0 +1,24 @@
import React from "react";
export type Props = {
children: React.ReactNode;
anchorRef: React.RefObject<HTMLDialogElement>;
open?: boolean;
};
const Modal = ({ children, anchorRef, open = false }: Props) => {
return (
<>
<dialog className="modal" ref={anchorRef} open={open}>
<div className="modal-box p-10 bg-white w-[750px] max-w-none text-gray-800">
{children}
</div>
<form method="dialog" className="modal-backdrop">
<button type="submit">close</button>
</form>
</dialog>
</>
);
};
export default Modal;

View File

@@ -0,0 +1,109 @@
import type { Meta, StoryObj } from "@storybook/react";
import { ACTION_MODE, BSKY_USER_MATCH_TYPE } from "../constants";
import UserCard, { type Props } from "./UserCard";
const meta: Meta<typeof UserCard> = {
title: "CSUI/UserCard",
component: UserCard,
};
export default meta;
type Story = StoryObj<{
items: {
user: Props["user"];
action: Props["clickAction"];
}[];
}>;
const demoUser: Props["user"] = {
did: "",
handle: "kawamataryo.bsky.social",
displayName: "KawamataRyo",
description: `
Frontend engineer @lapras-inc/ TypeScript / Vue.js / Firebase / ex-FireFighter 🔥
Developer of Sky Follower Bridge.
Twitter: twitter.com/KawamataRyo
GitHub: github.com/kawamataryo
Zenn: zenn.dev/ryo_kawamata`,
avatar:
"https://cdn.bsky.app/img/avatar/plain/did:plc:hcp53er6pefwijpdceo5x4bp/bafkreibm42fe6ionzntt2oryzv2coulgiwh5ejman4vf53bpkdtotszpp4@jpeg",
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
isFollowing: false,
followingUri: "",
isBlocking: false,
blockingUri: "",
};
const mockAction: Props["clickAction"] = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
};
const CardTemplate = {
render: (args: Story["args"]["items"][0]) => (
<UserCard
user={args.user}
clickAction={args.action}
actionMode={ACTION_MODE.FOLLOW}
/>
),
};
const CardsTemplate: Story = {
render: (args) => (
<div className="divide-y divide-gray-400 border-y border-gray-400">
{args.items.map((arg, i) => (
<UserCard
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={i}
user={arg.user}
clickAction={arg.action}
actionMode={ACTION_MODE.FOLLOW}
/>
))}
</div>
),
};
export const Default = {
...CardTemplate,
args: {
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
},
},
};
export const Cards = {
...CardsTemplate,
args: {
items: [
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
isFollowing: true,
},
},
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.DESCRIPTION,
},
},
{
action: mockAction,
user: {
...demoUser,
matchType: BSKY_USER_MATCH_TYPE.DISPLAY_NAME,
inFollowing: true,
},
},
],
},
};

View File

@@ -0,0 +1,155 @@
import React from "react";
import { match } from "ts-pattern";
import { ACTION_MODE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
import type { BskyUser } from "../hooks/useRetrieveBskyUsers";
import AvatarFallbackSvg from "./Icons/AvatarFallbackSvg";
export type Props = {
user: BskyUser;
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
clickAction: (user: BskyUser) => Promise<void>;
};
const UserCard = ({ user, actionMode, clickAction }: Props) => {
const [isBtnHovered, setIsBtnHovered] = React.useState(false);
const [isJustClicked, setIsJustClicked] = React.useState(false);
const actionBtnLabelAndClass = React.useMemo(
() =>
match(actionMode)
.with(ACTION_MODE.FOLLOW, () => {
const follow = {
label: "Follow on Bluesky",
class: "btn-primary",
};
const following = {
label: "Following on Bluesky",
class:
"btn-outline hover:bg-transparent hover:border hover:bg-transparent hover:text-base-content",
};
const unfollow = {
label: "Unfollow on Bluesky",
class:
"text-red-500 hover:bg-transparent hover:border hover:border-red-500",
};
if (!isBtnHovered) {
return user.isFollowing ? following : follow;
}
if (user.isFollowing) {
return isJustClicked ? following : unfollow;
}
return follow;
})
.with(ACTION_MODE.BLOCK, () => {
const block = {
label: "block on Bluesky",
class: "btn-primary",
};
const blocking = {
label: "Blocking on Bluesky",
class:
"btn-outline hover:bg-transparent hover:border hover:bg-transparent hover:text-base-content",
};
const unblock = {
label: "Unblock on Bluesky",
class:
"text-red-500 hover:bg-transparent hover:border hover:border-red-500",
};
if (!isBtnHovered) {
return user.isBlocking ? blocking : block;
}
if (user.isBlocking) {
return isJustClicked ? blocking : unblock;
}
return block;
})
.run(),
[
user.isFollowing,
user.isBlocking,
actionMode,
isBtnHovered,
isJustClicked,
],
);
const [loading, setLoading] = React.useState(false);
const handleActionButtonClick = async () => {
setLoading(true);
await clickAction(user);
setLoading(false);
setIsJustClicked(true);
};
return (
<div className="bg-base-100 w-full relative">
<div
className={`border-l-8 border-${
MATCH_TYPE_LABEL_AND_COLOR[user.matchType].color
} card-body relative py-3 px-4 rounded-sm grid grid-cols-[70px_1fr]`}
>
<div>
<div className="avatar">
<div className="w-14 rounded-full border border-white ">
<a
href={`https://bsky.app/profile/${user.handle}`}
target="_blank"
rel="noreferrer"
>
{user.avatar ? (
<img src={user.avatar} alt="" />
) : (
<AvatarFallbackSvg />
)}
</a>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<div>
<h2 className="card-title break-all">
<a
href={`https://bsky.app/profile/${user.handle}`}
target="_blank"
rel="noreferrer"
>
{user.displayName}
</a>
</h2>
<p className="whitespace-nowrap w-fit break-all text-gray-500 text-sm">
<a
href={`https://bsky.app/profile/${user.handle}`}
target="_blank"
rel="noreferrer"
>
@{user.handle}
</a>
</p>
</div>
<div className="card-actions">
<button
type="button"
className={`btn btn-sm rounded-3xl ${
loading ? "" : actionBtnLabelAndClass.class
}`}
onClick={handleActionButtonClick}
onMouseEnter={() => setIsBtnHovered(true)}
onMouseLeave={() => {
setIsBtnHovered(false);
setIsJustClicked(false);
}}
disabled={loading}
>
{loading ? "Processing..." : actionBtnLabelAndClass.label}
</button>
</div>
</div>
<p className="text-sm break-all">{user.description}</p>
</div>
</div>
</div>
);
};
export default UserCard;

View File

@@ -0,0 +1,25 @@
const UserCardSkeleton = () => {
return (
<div className="w-full animate-pulse">
<div className="card-body relative py-5 px-5 rounded-sm grid grid-cols-[70px_1fr]">
<div>
<div className="avatar">
<div className="w-14 h-14 rounded-full bg-gray-400" />
</div>
</div>
<div className="flex flex-col gap-2">
<div>
<span className="rounded-xl bg-gray-400 w-40 h-4 block" />
<span className="rounded-xl bg-gray-400 w-20 h-2 mt-1 block" />
</div>
<p className="flex flex-col gap-1 mt-2">
<span className="rounded-xl bg-gray-400 w-[90%] h-2 block" />
<span className="rounded-xl bg-gray-400 w-[80%] h-2 block" />
</p>
</div>
</div>
</div>
);
};
export default UserCardSkeleton;

View File

@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react";
import UserCardSkeleton from "./UserCardSkeleton";
const meta: Meta<typeof UserCardSkeleton> = {
title: "CSUI/UserCardSkeleton",
component: UserCardSkeleton,
};
export default meta;
type Story = StoryObj<typeof UserCardSkeleton>;
export const Default: Story = {};

View File

@@ -3,6 +3,29 @@ export const MESSAGE_NAMES = {
SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE:
"search_bsky_user_on_list_members_page",
SEARCH_BSKY_USER_ON_BLOCK_PAGE: "search_bsky_user_on_block_page",
} as const;
export const QUERY_PARAMS = {
FOLLOW: '[data-testid="primaryColumn"] [data-testid="UserCell"]',
BLOCK: '[data-testid="UserCell"]',
LIST: '[data-testid="cellInnerDiv"] [data-testid="UserCell"]',
} as const;
export const MESSAGE_NAME_TO_QUERY_PARAM_MAP = {
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_FOLLOW_PAGE]: QUERY_PARAMS.FOLLOW,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE]: QUERY_PARAMS.LIST,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE]: QUERY_PARAMS.BLOCK,
};
export const ACTION_MODE = {
FOLLOW: "follow",
BLOCK: "block",
};
export const MESSAGE_NAME_TO_ACTION_MODE_MAP = {
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_FOLLOW_PAGE]: ACTION_MODE.FOLLOW,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE]: ACTION_MODE.FOLLOW,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE]: ACTION_MODE.BLOCK,
};
const STORAGE_PREFIX = "sky_follower_bridge_storage";
@@ -35,3 +58,18 @@ export const BSKY_USER_MATCH_TYPE = {
} as const;
export const MAX_RELOAD_COUNT = 1;
export const MATCH_TYPE_LABEL_AND_COLOR = {
[BSKY_USER_MATCH_TYPE.HANDLE]: {
label: "Same handle name",
color: "info",
},
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: {
label: "Same display name",
color: "warning",
},
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: {
label: "Included handle name in description",
color: "neutral",
},
};

View File

@@ -0,0 +1,282 @@
import React from "react";
import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
import {
ACTION_MODE,
BSKY_USER_MATCH_TYPE,
MESSAGE_NAMES,
MESSAGE_NAME_TO_ACTION_MODE_MAP,
MESSAGE_NAME_TO_QUERY_PARAM_MAP,
} from "~lib/constants";
import { getAccountNameAndDisplayName, getUserCells } from "~lib/domHelpers";
import { searchBskyUser } from "~lib/searchBskyUsers";
import { wait } from "~lib/utils";
export type MatchType =
(typeof BSKY_USER_MATCH_TYPE)[keyof typeof BSKY_USER_MATCH_TYPE];
export type BskyUser = {
did: string;
avatar: string;
displayName: string;
handle: string;
description: string;
matchType: MatchType;
isFollowing: boolean;
followingUri: string | null;
isBlocking: boolean;
blockingUri: string | null;
};
const detectXUsers = (userCellQueryParam: string) => {
const userCells = getUserCells({
queryParam: userCellQueryParam,
filterInsertedElement: true,
});
return userCells.map((userCell) => {
return getAccountNameAndDisplayName(userCell);
});
};
export const useRetrieveBskyUsers = () => {
const bskyClient = React.useRef<BskyServiceWorkerClient | null>(null);
const [actionMode, setActionMode] = React.useState<
(typeof ACTION_MODE)[keyof typeof ACTION_MODE]
>(ACTION_MODE.FOLLOW);
const [detectedXUsers, setDetectedXUsers] = React.useState<
ReturnType<typeof detectXUsers>
>([]);
const [users, setUsers] = React.useState<BskyUser[]>([]);
const [loading, setLoading] = React.useState(true);
const [errorMessage, setErrorMessage] = React.useState("");
const [matchTypeFilter, setMatchTypeFilter] = React.useState({
[BSKY_USER_MATCH_TYPE.HANDLE]: true,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: true,
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: true,
});
const [retrievalParams, setRetrievalParams] = React.useState<null | {
identifier: string;
password: string;
messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
}>(null);
const modalRef = React.useRef<HTMLDialogElement>(null);
const showModal = () => {
modalRef.current?.showModal();
};
const handleClickAction = React.useCallback(
async (user: (typeof users)[0]) => {
if (!bskyClient.current) return;
let resultUri: string | null = null;
// follow
if (actionMode === ACTION_MODE.FOLLOW) {
if (user.isFollowing) {
await bskyClient.current.unfollow(user.followingUri);
} else {
const result = await bskyClient.current.follow(user.did);
resultUri = result.uri;
}
setUsers((prev) =>
prev.map((prevUser) => {
if (prevUser.did === user.did) {
return {
...prevUser,
isFollowing: !prevUser.isFollowing,
followingUri: resultUri ?? prevUser.followingUri,
};
}
return prevUser;
}),
);
}
// block
if (actionMode === ACTION_MODE.BLOCK) {
if (user.isBlocking) {
await bskyClient.current.unblock(user.blockingUri);
} else {
const result = await bskyClient.current.block(user.did);
resultUri = result.uri;
}
setUsers((prev) =>
prev.map((prevUser) => {
if (prevUser.did === user.did) {
return {
...prevUser,
isBlocking: !prevUser.isBlocking,
blockingUri: resultUri ?? prevUser.blockingUri,
};
}
return prevUser;
}),
);
}
},
[actionMode],
);
const retrieveBskyUsers = React.useCallback(
async (usersData: ReturnType<typeof getAccountNameAndDisplayName>[]) => {
for (const userData of usersData) {
const searchResult = await searchBskyUser({
client: bskyClient.current,
userData,
});
if (searchResult) {
setUsers((prev) => {
if (prev.some((u) => u.did === searchResult.bskyProfile.did)) {
return prev;
}
return [
...prev,
{
did: searchResult.bskyProfile.did,
avatar: searchResult.bskyProfile.avatar,
displayName: searchResult.bskyProfile.displayName,
handle: searchResult.bskyProfile.handle,
description: searchResult.bskyProfile.description,
matchType: searchResult.matchType,
isFollowing: !!searchResult.bskyProfile.viewer?.following,
followingUri: searchResult.bskyProfile.viewer?.following,
isBlocking: !!searchResult.bskyProfile.viewer?.blocking,
blockingUri: searchResult.bskyProfile.viewer?.blocking,
},
];
});
}
}
},
[],
);
const startRetrieveLoop = React.useCallback(
async (queryParam: string) => {
let isBottomReached = false;
let index = 0;
while (!isBottomReached) {
const data = detectXUsers(queryParam).filter((u) => {
return !detectedXUsers.some(
(t) => t.twAccountName === u.twAccountName,
);
});
setDetectedXUsers((prev) => [...prev, ...data]);
await retrieveBskyUsers(data);
// scroll to bottom
window.scrollTo(0, document.body.scrollHeight);
// wait for fetching data by x
await wait(3000);
// break if bottom is reached
const documentElement = document.documentElement;
if (
documentElement.scrollTop + documentElement.clientHeight >=
documentElement.scrollHeight
) {
isBottomReached = true;
setLoading(false);
}
index++;
if (process.env.NODE_ENV === "development" && index > 5) {
setLoading(false);
break;
}
}
},
[retrieveBskyUsers, detectedXUsers],
);
const initialize = React.useCallback(
async ({
identifier,
password,
messageName,
}: {
identifier: string;
password: string;
messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
}) => {
setRetrievalParams({
identifier,
password,
messageName,
});
bskyClient.current = await BskyServiceWorkerClient.createAgent({
identifier,
password,
});
setActionMode(MESSAGE_NAME_TO_ACTION_MODE_MAP[messageName]);
startRetrieveLoop(MESSAGE_NAME_TO_QUERY_PARAM_MAP[messageName]).catch(
(e) => {
setErrorMessage(e.message);
setLoading(false);
},
);
setLoading(true);
showModal();
},
[startRetrieveLoop, showModal],
);
const restart = React.useCallback(() => {
startRetrieveLoop(retrievalParams.messageName).catch((e) => {
setErrorMessage(e.message);
setLoading(false);
});
setLoading(true);
}, [retrievalParams, startRetrieveLoop]);
const isRateLimitError = React.useMemo(() => {
// TODO: improve this logic
return errorMessage.toLowerCase().replace(" ", "").includes("ratelimit");
}, [errorMessage]);
const isSucceeded = React.useMemo(
() => !loading && !errorMessage && users.length > 0,
[loading, errorMessage, users.length],
);
const changeMatchTypeFilter = React.useCallback(
(
matchType: (typeof BSKY_USER_MATCH_TYPE)[keyof typeof BSKY_USER_MATCH_TYPE],
) => {
setMatchTypeFilter((prev) => {
return {
...prev,
[matchType]: !prev[matchType],
};
});
},
[],
);
const filteredUsers = React.useMemo(() => {
return users.filter((user) => {
return matchTypeFilter[user.matchType];
});
}, [users, matchTypeFilter]);
return {
modalRef,
showModal,
initialize,
handleClickAction,
users,
loading,
actionMode,
errorMessage,
isRateLimitError,
restart,
isSucceeded,
matchTypeFilter,
changeMatchTypeFilter,
filteredUsers,
};
};

147
src/lib/searchBskyUsers.ts Normal file
View File

@@ -0,0 +1,147 @@
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
import { isSimilarUser } from "~lib/bskyHelpers";
import {
getAccountNameAndDisplayName,
getUserCells,
insertBskyProfileEl,
insertNotFoundEl,
insertReloadEl,
} from "~lib/domHelpers";
import { debugLog, isOneSymbol } from "~lib/utils";
import type { BskyClient } from "./bskyClient";
import type { BskyServiceWorkerClient } from "./bskyServiceWorkerClient";
const notFoundUserCache = new Set<string>();
const bskyUserUrlMap = new Map<string, string>();
export const searchBskyUser = async ({
client,
userData,
}: {
client: BskyServiceWorkerClient;
userData: ReturnType<typeof getAccountNameAndDisplayName>;
}) => {
const searchTerms = [
userData.twAccountNameRemoveUnderscore,
userData.twDisplayName,
];
for (const term of searchTerms) {
// one symbol is not a valid search term for bsky
if (!term || isOneSymbol(term)) {
continue;
}
try {
const searchResults = await client.searchUser({
term: term,
limit: 3,
});
for (const searchResult of searchResults) {
const { isSimilar: isUserFound, type } = isSimilarUser(
// TODO: simplify
{
accountName: userData.twAccountName,
accountNameRemoveUnderscore: userData.twAccountNameRemoveUnderscore,
displayName: userData.twDisplayName,
},
searchResult,
);
if (isUserFound) {
return {
bskyProfile: searchResult,
matchType: type,
};
}
}
} catch (e) {
console.error(e);
}
}
return null;
};
export const searchBskyUsers = async ({
agent,
userCellQueryParam,
}: {
agent: BskyServiceWorkerClient | BskyClient;
userCellQueryParam: string;
}) => {
const userCells = getUserCells({
queryParam: userCellQueryParam,
filterInsertedElement: true,
});
debugLog(`userCells length: ${userCells.length}`);
let index = 0;
const targetAccounts = [] as ProfileView[];
// loop over twitter user profile cells and search and insert bsky user
for (const userCell of userCells) {
const { twAccountName, twDisplayName, twAccountNameRemoveUnderscore } =
getAccountNameAndDisplayName(userCell);
if (notFoundUserCache.has(twAccountName)) {
insertNotFoundEl(userCell);
continue;
}
const searchTerms = [twAccountNameRemoveUnderscore, twDisplayName];
let targetAccount = null;
let matchType = null;
// Loop over search parameters and break if a user is found
searchLoop: for (const term of searchTerms) {
// one symbol is not a valid search term for bsky
if (!term || isOneSymbol(term)) {
continue;
}
try {
const searchResults = await agent.searchUser({
term: term,
limit: 3,
});
for (const searchResult of searchResults) {
const { isSimilar: isUserFound, type } = isSimilarUser(
{
accountName: twAccountName,
accountNameRemoveUnderscore: twAccountNameRemoveUnderscore,
displayName: twDisplayName,
},
searchResult,
);
if (isUserFound) {
targetAccount = searchResult;
matchType = type;
break searchLoop; // Stop searching when a user is found
}
}
} catch (e) {
console.error(e);
}
}
// insert bsky profile or not found element
if (targetAccount) {
targetAccounts.push(targetAccount);
} else {
notFoundUserCache.add(twAccountName);
}
index++;
// if (process.env.NODE_ENV === "development" && index > 5) {
// break
// }
}
return targetAccounts;
};

View File

@@ -7,3 +7,7 @@ export const debugLog = (message: string) => {
export const isOneSymbol = (str: string) => {
return /^[^\w\s]$/.test(str);
};
export const wait = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};