feat: add search again button

This commit is contained in:
kawamataryo
2024-11-30 20:44:12 +09:00
committed by kawamataryo
parent 4383913ed5
commit 29cf178791
14 changed files with 442 additions and 106 deletions

View File

@@ -1,5 +1,5 @@
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
import { BSKY_USER_MATCH_TYPE } from "./constants";
import { BSKY_PROFILE_LABEL, BSKY_USER_MATCH_TYPE } from "./constants";
type xUserInfo = {
bskyHandleInDescription: string;
@@ -80,3 +80,9 @@ export const isSimilarUser = (
type: BSKY_USER_MATCH_TYPE.NONE,
};
};
export const isImpersonationUser = (user: ProfileView) => {
return user.labels.some(
(label) => label.val === BSKY_PROFILE_LABEL.IMPERSONATION,
);
};

View File

@@ -0,0 +1,35 @@
import React from "react";
type ActionButtonProps = {
loading: boolean;
actionBtnLabelAndClass: { label: string; class: string };
handleActionButtonClick: () => void;
setIsBtnHovered: (value: boolean) => void;
setIsJustClicked: (value: boolean) => void;
};
export const ActionButton = ({
loading,
actionBtnLabelAndClass,
handleActionButtonClick,
setIsBtnHovered,
setIsJustClicked,
}: ActionButtonProps) => (
<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>
);
export default ActionButton;

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
import React from "react";
import { match } from "ts-pattern";
import type { BskyUser } from "~types";
@@ -8,9 +9,19 @@ export type Props = {
user: BskyUser;
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
clickAction: (user: BskyUser) => Promise<void>;
reSearch: (user: {
sourceDid: string;
accountName: string;
displayName: string;
}) => Promise<void>;
};
const DetectedUserListItem = ({ user, actionMode, clickAction }: Props) => {
const DetectedUserListItem = ({
user,
actionMode,
clickAction,
reSearch,
}: Props) => {
const [isBtnHovered, setIsBtnHovered] = React.useState(false);
const [isJustClicked, setIsJustClicked] = React.useState(false);
const actionBtnLabelAndClass = React.useMemo(
@@ -81,6 +92,14 @@ const DetectedUserListItem = ({ user, actionMode, clickAction }: Props) => {
setIsJustClicked(true);
};
const handleReSearchClick = () => {
reSearch({
sourceDid: user.did,
accountName: user.originalHandle,
displayName: user.originalDisplayName,
});
};
return (
<div className="bg-base-100 w-full relative grid grid-cols-[22%_1fr] gap-5">
<DetectedUserSource user={user} />
@@ -91,6 +110,7 @@ const DetectedUserListItem = ({ user, actionMode, clickAction }: Props) => {
handleActionButtonClick={handleActionButtonClick}
setIsBtnHovered={setIsBtnHovered}
setIsJustClicked={setIsJustClicked}
handleReSearchClick={handleReSearchClick}
/>
</div>
);

View File

@@ -1,7 +1,9 @@
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
import React from "react";
import type { BskyUser } from "~types";
import { MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
import { UserInfo, UserProfile } from "./UserCard";
import UserInfo from "./UserInfo";
import UserProfile from "./UserProfile";
type DetectedUserSourceProps = {
user: BskyUser;

View File

@@ -5,9 +5,10 @@ export type Props = {
children: React.ReactNode;
open: boolean;
onClose?: () => void;
width?: number;
};
const Modal = ({ children, open = false, onClose }: Props) => {
const Modal = ({ children, open = false, onClose, width = 500 }: Props) => {
const anchorRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
@@ -25,7 +26,10 @@ const Modal = ({ children, open = false, onClose }: Props) => {
return (
<>
<dialog className="modal" ref={anchorRef} open={open}>
<div className="modal-box p-10 bg-base-100 w-[500px] max-w-none text-base-content">
<div
className="modal-box p-10 bg-base-100 max-w-none text-base-content"
style={{ width }}
>
{children}
</div>
<form method="dialog" className="modal-backdrop">

View File

@@ -1,81 +1,46 @@
import React from "react";
import type { BskyUser } from "~types";
import AvatarFallbackSvg from "./Icons/AvatarFallbackSvg";
import ActionButton from "./ActionButton";
import UserInfo from "./UserInfo";
import UserProfile from "./UserProfile";
type UserProfileProps = {
avatar: string;
url: string;
};
export const UserProfile = ({ avatar, url }: UserProfileProps) => (
<div className="avatar">
<div className="w-10 h-10 rounded-full border border-white">
<a href={url} target="_blank" rel="noreferrer">
{avatar ? <img src={avatar} alt="" /> : <AvatarFallbackSvg />}
</a>
</div>
</div>
);
type UserInfoProps = {
handle: string;
displayName: string;
url: string;
};
export const UserInfo = ({ handle, displayName, url }: UserInfoProps) => (
<div>
<h2 className="card-title break-all text-[1.1rem] font-bold">
<a href={url} target="_blank" rel="noreferrer">
{displayName}
</a>
</h2>
<p className="w-fit break-all text-gray-500 dark:text-gray-400 text-sm">
<a href={url} target="_blank" rel="noreferrer" className="break-all">
@{handle}
</a>
</p>
</div>
);
type ActionButtonProps = {
loading: boolean;
actionBtnLabelAndClass: { label: string; class: string };
handleActionButtonClick: () => void;
setIsBtnHovered: (value: boolean) => void;
setIsJustClicked: (value: boolean) => void;
};
export const ActionButton = ({
loading,
actionBtnLabelAndClass,
handleActionButtonClick,
setIsBtnHovered,
setIsJustClicked,
}: ActionButtonProps) => (
<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>
);
export type UserCardProps = {
user: BskyUser;
user: Pick<BskyUser, "avatar" | "handle" | "displayName" | "description">;
loading: boolean;
actionBtnLabelAndClass: { label: string; class: string };
handleActionButtonClick: () => void;
setIsBtnHovered: (value: boolean) => void;
setIsJustClicked: (value: boolean) => void;
handleReSearchClick: () => void;
};
const ReSearchButton = ({
onClick,
}: {
onClick: () => void;
}) => {
return (
<button
type="button"
className="btn-outline w-7 h-7 border rounded-full flex items-center justify-center"
onClick={onClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-4 w-4"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</button>
);
};
const UserCard = ({
@@ -85,32 +50,38 @@ const UserCard = ({
handleActionButtonClick,
setIsBtnHovered,
setIsJustClicked,
}: UserCardProps) => (
<div className="relative py-3 pl-0 pr-2 grid grid-cols-[50px_1fr]">
<UserProfile
avatar={user.avatar}
url={`https://bsky.app/profile/${user.handle}`}
/>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<UserInfo
handle={user.handle}
displayName={user.displayName}
url={`https://bsky.app/profile/${user.handle}`}
/>
<div className="card-actions">
<ActionButton
loading={loading}
actionBtnLabelAndClass={actionBtnLabelAndClass}
handleActionButtonClick={handleActionButtonClick}
setIsBtnHovered={setIsBtnHovered}
setIsJustClicked={setIsJustClicked}
/>
handleReSearchClick,
}: UserCardProps) => {
return (
<div className="relative py-3 pl-0 pr-2 grid grid-cols-[50px_1fr]">
<UserProfile
avatar={user.avatar}
url={`https://bsky.app/profile/${user.handle}`}
/>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<div className="flex items-start gap-4">
<UserInfo
handle={user.handle}
displayName={user.displayName}
url={`https://bsky.app/profile/${user.handle}`}
/>
<ReSearchButton onClick={handleReSearchClick} />
</div>
<div className="card-actions flex items-center gap-4">
<ActionButton
loading={loading}
actionBtnLabelAndClass={actionBtnLabelAndClass}
handleActionButtonClick={handleActionButtonClick}
setIsBtnHovered={setIsBtnHovered}
setIsJustClicked={setIsJustClicked}
/>
</div>
</div>
<p className="text-sm break-all">{user.description}</p>
</div>
<p className="text-sm break-all">{user.description}</p>
</div>
</div>
);
);
};
export default UserCard;

View File

@@ -0,0 +1,31 @@
import React from "react";
import type { BskyUser } from "~types";
import UserInfo from "./UserInfo";
import UserProfile from "./UserProfile";
export type UserCardWithoutActionButtonProps = {
user: Pick<BskyUser, "avatar" | "handle" | "displayName" | "description">;
onClick: () => void;
};
const UserCardWithoutActionButton = ({
user,
onClick,
}: UserCardWithoutActionButtonProps) => (
<button
type="button"
className="relative py-3 pl-0 pr-2 grid grid-cols-[50px_1fr] w-full"
onClick={onClick}
onKeyUp={onClick}
>
<UserProfile avatar={user.avatar} />
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<UserInfo handle={user.handle} displayName={user.displayName} />
</div>
<p className="text-sm break-all text-left">{user.description}</p>
</div>
</button>
);
export default UserCardWithoutActionButton;

View File

@@ -0,0 +1,28 @@
import React from "react";
type UserInfoProps = {
handle: string;
displayName: string;
url?: string;
};
export const UserInfo = ({ handle, displayName, url }: UserInfoProps) => (
<div>
<h2 className="card-title break-all text-[1.1rem] font-bold">
{url ? (
<a href={url} target="_blank" rel="noreferrer">
{displayName}
</a>
) : (
<>{displayName}</>
)}
</h2>
<p className="w-fit break-all text-gray-500 dark:text-gray-400 text-sm">
<a href={url} target="_blank" rel="noreferrer" className="break-all">
@{handle}
</a>
</p>
</div>
);
export default UserInfo;

View File

@@ -0,0 +1,25 @@
import React from "react";
import AvatarFallbackSvg from "./Icons/AvatarFallbackSvg";
type UserProfileProps = {
avatar: string;
url?: string;
};
export const UserProfile = ({ avatar, url }: UserProfileProps) => (
<div className="avatar">
<div className="w-10 h-10 rounded-full border border-white">
{url ? (
<a href={url} target="_blank" rel="noreferrer">
{avatar ? <img src={avatar} alt="" /> : <AvatarFallbackSvg />}
</a>
) : (
<div className="w-10 h-10 rounded-full border border-white">
{avatar ? <img src={avatar} alt="" /> : <AvatarFallbackSvg />}
</div>
)}
</div>
</div>
);
export default UserProfile;

View File

@@ -1,3 +1,4 @@
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
import { Storage } from "@plasmohq/storage";
import { useStorage } from "@plasmohq/storage/hook";
import React from "react";
@@ -9,6 +10,7 @@ import {
MESSAGE_NAME_TO_ACTION_MODE_MAP,
STORAGE_KEYS,
} from "~lib/constants";
import { reSearchBskyUser } from "~lib/reSearchBskyUsers";
import { wait } from "~lib/utils";
import type { BskyUser, MatchType } from "~types";
@@ -242,6 +244,58 @@ export const useBskyUserManager = () => {
);
}, [users, matchTypeFilter]);
const [reSearchResults, setReSearchResults] = React.useState<{
sourceDid: string;
users: ProfileView[];
}>({ sourceDid: "", users: [] });
const reSearch = React.useCallback(
async ({
sourceDid,
accountName,
displayName,
}: {
sourceDid: string;
accountName: string;
displayName: string;
}) => {
const searchResults = await reSearchBskyUser({
client: bskyClient.current,
userData: {
accountName,
displayName,
},
});
setReSearchResults({ sourceDid, users: searchResults });
},
[],
);
const clearReSearchResults = React.useCallback(() => {
setReSearchResults({
sourceDid: "",
users: [],
});
}, []);
const changeDetectedUser = React.useCallback(
(fromDid: string, toUser: ProfileView) => {
setUsers((prev) =>
prev.map((prevUser) =>
prevUser.did === fromDid
? {
...prevUser,
...toUser,
isFollowing: !!toUser.viewer?.following,
followingUri: toUser.viewer?.following,
isBlocking: !!toUser.viewer?.blocking,
blockingUri: toUser.viewer?.blocking,
}
: prevUser,
),
);
},
[setUsers],
);
return {
handleClickAction,
users,
@@ -253,5 +307,9 @@ export const useBskyUserManager = () => {
importList,
followAll,
blockAll,
reSearch,
reSearchResults,
changeDetectedUser,
clearReSearchResults,
};
};

View File

@@ -0,0 +1,55 @@
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
import { isOneSymbol } from "~lib/utils";
import { isImpersonationUser } from "./bskyHelpers";
import type { BskyServiceWorkerClient } from "./bskyServiceWorkerClient";
export const reSearchBskyUser = async ({
client,
userData,
}: {
client: BskyServiceWorkerClient;
userData: {
accountName: string;
displayName: string;
};
}): Promise<ProfileView[]> => {
const searchTerms = [
userData.accountName,
userData.displayName,
userData.accountName.replaceAll("_", ""),
userData.displayName.replaceAll("_", ""),
];
const uniqueSearchTerms = new Set(searchTerms);
const searchResultDidSet = new Set<string>();
const searchResults: ProfileView[] = [];
for (const term of uniqueSearchTerms) {
// one symbol is not a valid search term for bsky
if (!term || isOneSymbol(term)) {
continue;
}
try {
const results = await client.searchUser({
term,
limit: 3,
});
for (const result of results) {
// skip impersonation users
if (isImpersonationUser(result)) {
continue;
}
if (searchResultDidSet.has(result.did)) {
continue;
}
searchResultDidSet.add(result.did);
searchResults.push(result);
}
} catch (e) {
console.error(e);
}
}
return searchResults;
};

View File

@@ -1,15 +1,8 @@
import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs";
import { isSimilarUser } from "~lib/bskyHelpers";
import { isOneSymbol } from "~lib/utils";
import type { CrawledUserInfo } from "~types";
import { isImpersonationUser } from "./bskyHelpers";
import type { BskyServiceWorkerClient } from "./bskyServiceWorkerClient";
import { BSKY_PROFILE_LABEL } from "./constants";
const isImpersonationUser = (user: ProfileView) => {
return user.labels.some(
(label) => label.val === BSKY_PROFILE_LABEL.IMPERSONATION,
);
};
export const searchBskyUser = async ({
client,