diff --git a/src/components/ReSearchModal.tsx b/src/components/ReSearchModal.tsx new file mode 100644 index 0000000..41f62aa --- /dev/null +++ b/src/components/ReSearchModal.tsx @@ -0,0 +1,63 @@ +import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import Modal from "~lib/components/Modal"; +import UserCardWithoutActionButton from "~lib/components/UserCardWithoutActionButton"; + +interface ReSearchModalProps { + open: boolean; + onClose: () => void; + reSearchResults: { + sourceDid: string; + users: ProfileView[]; + }; + handleClickReSearchResult: ({ + sourceDid, + user, + }: { + sourceDid: string; + user: ProfileView; + }) => void; +} + +const ReSearchModal: React.FC = ({ + open, + onClose, + reSearchResults, + handleClickReSearchResult, +}) => { + return ( + +

Search Results

+ {reSearchResults.users.length === 0 && ( +
+ +
+ Loading... +
+
+ )} + {reSearchResults.users.length > 0 && ( +
+ {reSearchResults.users.map((user) => ( + + handleClickReSearchResult({ + sourceDid: reSearchResults.sourceDid, + user, + }) + } + user={{ + avatar: user.avatar, + handle: user.handle, + displayName: user.displayName, + description: user.description, + }} + /> + ))} +
+ )} +
+ ); +}; + +export default ReSearchModal; diff --git a/src/lib/bskyHelpers.ts b/src/lib/bskyHelpers.ts index e9460d2..cea2ed2 100644 --- a/src/lib/bskyHelpers.ts +++ b/src/lib/bskyHelpers.ts @@ -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, + ); +}; diff --git a/src/lib/components/ActionButton.tsx b/src/lib/components/ActionButton.tsx new file mode 100644 index 0000000..cb705e9 --- /dev/null +++ b/src/lib/components/ActionButton.tsx @@ -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) => ( + +); + +export default ActionButton; diff --git a/src/lib/components/DetectedUserListItem.tsx b/src/lib/components/DetectedUserListItem.tsx index b5d1c92..633452d 100644 --- a/src/lib/components/DetectedUserListItem.tsx +++ b/src/lib/components/DetectedUserListItem.tsx @@ -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; + reSearch: (user: { + sourceDid: string; + accountName: string; + displayName: string; + }) => Promise; }; -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 (
@@ -91,6 +110,7 @@ const DetectedUserListItem = ({ user, actionMode, clickAction }: Props) => { handleActionButtonClick={handleActionButtonClick} setIsBtnHovered={setIsBtnHovered} setIsJustClicked={setIsJustClicked} + handleReSearchClick={handleReSearchClick} />
); diff --git a/src/lib/components/DetectedUserSource.tsx b/src/lib/components/DetectedUserSource.tsx index 62121bd..ef9ddf7 100644 --- a/src/lib/components/DetectedUserSource.tsx +++ b/src/lib/components/DetectedUserSource.tsx @@ -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; diff --git a/src/lib/components/Modal.tsx b/src/lib/components/Modal.tsx index e8cf0ee..0f7c7b4 100644 --- a/src/lib/components/Modal.tsx +++ b/src/lib/components/Modal.tsx @@ -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(null); useEffect(() => { @@ -25,7 +26,10 @@ const Modal = ({ children, open = false, onClose }: Props) => { return ( <> -
+
{children}
diff --git a/src/lib/components/UserCard.tsx b/src/lib/components/UserCard.tsx index b0a591d..f2eceba 100644 --- a/src/lib/components/UserCard.tsx +++ b/src/lib/components/UserCard.tsx @@ -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) => ( - -); - -type UserInfoProps = { - handle: string; - displayName: string; - url: string; -}; - -export const UserInfo = ({ handle, displayName, url }: UserInfoProps) => ( - -); - -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) => ( - -); export type UserCardProps = { - user: BskyUser; + user: Pick; 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 ( + + ); }; const UserCard = ({ @@ -85,32 +50,38 @@ const UserCard = ({ handleActionButtonClick, setIsBtnHovered, setIsJustClicked, -}: UserCardProps) => ( -
- -
-
- -
- + handleReSearchClick, +}: UserCardProps) => { + return ( +
+ +
+
+
+ + +
+
+ +
+

{user.description}

-

{user.description}

-
-); + ); +}; export default UserCard; diff --git a/src/lib/components/UserCardWithoutActionButton.tsx b/src/lib/components/UserCardWithoutActionButton.tsx new file mode 100644 index 0000000..4ec6135 --- /dev/null +++ b/src/lib/components/UserCardWithoutActionButton.tsx @@ -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; + onClick: () => void; +}; + +const UserCardWithoutActionButton = ({ + user, + onClick, +}: UserCardWithoutActionButtonProps) => ( + +); + +export default UserCardWithoutActionButton; diff --git a/src/lib/components/UserInfo.tsx b/src/lib/components/UserInfo.tsx new file mode 100644 index 0000000..c04c9db --- /dev/null +++ b/src/lib/components/UserInfo.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +type UserInfoProps = { + handle: string; + displayName: string; + url?: string; +}; + +export const UserInfo = ({ handle, displayName, url }: UserInfoProps) => ( +
+

+ {url ? ( + + {displayName} + + ) : ( + <>{displayName} + )} +

+

+ + @{handle} + +

+
+); + +export default UserInfo; diff --git a/src/lib/components/UserProfile.tsx b/src/lib/components/UserProfile.tsx new file mode 100644 index 0000000..85ac76f --- /dev/null +++ b/src/lib/components/UserProfile.tsx @@ -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) => ( +
+
+ {url ? ( + + {avatar ? : } + + ) : ( +
+ {avatar ? : } +
+ )} +
+
+); + +export default UserProfile; diff --git a/src/lib/hooks/useBskyUserManager.ts b/src/lib/hooks/useBskyUserManager.ts index ff2cecc..d08e09f 100644 --- a/src/lib/hooks/useBskyUserManager.ts +++ b/src/lib/hooks/useBskyUserManager.ts @@ -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, }; }; diff --git a/src/lib/reSearchBskyUsers.ts b/src/lib/reSearchBskyUsers.ts new file mode 100644 index 0000000..39a7268 --- /dev/null +++ b/src/lib/reSearchBskyUsers.ts @@ -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 => { + const searchTerms = [ + userData.accountName, + userData.displayName, + userData.accountName.replaceAll("_", ""), + userData.displayName.replaceAll("_", ""), + ]; + const uniqueSearchTerms = new Set(searchTerms); + + const searchResultDidSet = new Set(); + 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; +}; diff --git a/src/lib/searchBskyUsers.ts b/src/lib/searchBskyUsers.ts index 55eeac0..45b0f40 100644 --- a/src/lib/searchBskyUsers.ts +++ b/src/lib/searchBskyUsers.ts @@ -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, diff --git a/src/options.tsx b/src/options.tsx index e8ad33d..a3443bf 100644 --- a/src/options.tsx +++ b/src/options.tsx @@ -4,6 +4,9 @@ import { ToastContainer, toast } from "react-toastify"; import useConfirm from "~lib/components/ConfirmDialog"; import Sidebar from "~lib/components/Sidebar"; import "react-toastify/dist/ReactToastify.css"; +import type { ProfileView } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; +import React from "react"; +import ReSearchModal from "~components/ReSearchModal"; import DetectedUserListItem from "~lib/components/DetectedUserListItem"; const Option = () => { @@ -18,6 +21,10 @@ const Option = () => { importList, followAll, blockAll, + reSearch, + reSearchResults, + changeDetectedUser, + clearReSearchResults, } = useBskyUserManager(); const { @@ -98,6 +105,37 @@ const Option = () => { }); }; + const [showReSearchModal, setShowReSearchModal] = React.useState(false); + const handleReSearch = async (user: { + sourceDid: string; + accountName: string; + displayName: string; + }) => { + reSearch({ + sourceDid: user.sourceDid, + accountName: user.accountName, + displayName: user.displayName, + }); + setShowReSearchModal(true); + }; + + const handleClickReSearchResult = ({ + sourceDid, + user, + }: { + sourceDid: string; + user: ProfileView; + }) => { + changeDetectedUser(sourceDid, user); + setShowReSearchModal(false); + clearReSearchResults(); + }; + + const handleCloseReSearchModal = () => { + setShowReSearchModal(false); + clearReSearchResults(); + }; + return ( <>
@@ -126,11 +164,18 @@ const Option = () => { user={user} clickAction={handleClickAction} actionMode={actionMode} + reSearch={handleReSearch} /> ))}
+