Merge branch 'main' into enhance-list-features

This commit is contained in:
ryo
2024-11-28 12:38:31 +09:00
committed by GitHub
61 changed files with 2989 additions and 15571 deletions

View File

@@ -23,7 +23,6 @@ export const getStyle = () => {
const App = () => {
const {
initialize,
modalRef,
users,
loading,
stopRetrieveLoop,
@@ -33,6 +32,12 @@ const App = () => {
listName,
} = useRetrieveBskyUsers();
const [isModalOpen, setIsModalOpen] = React.useState(false);
const closeModal = () => {
setIsModalOpen(false);
stopRetrieveLoop();
};
React.useEffect(() => {
const messageHandler = (
message: {
@@ -44,6 +49,7 @@ const App = () => {
if (Object.values(MESSAGE_NAMES).includes(message.name)) {
initialize()
.then(() => {
setIsModalOpen(true);
sendResponse({ hasError: false });
})
.catch((e) => {
@@ -76,7 +82,7 @@ const App = () => {
return (
<>
<Modal anchorRef={modalRef} onClose={stopRetrieveLoop}>
<Modal open={isModalOpen} onClose={closeModal}>
<div className="flex flex-col gap-2 items-center">
{loading && (
<p className="text-lg font-bold">

View File

@@ -34,7 +34,7 @@ export const isSimilarUser = (
) {
return {
isSimilar: true,
type: BSKY_USER_MATCH_TYPE.HANDLE,
type: BSKY_USER_MATCH_TYPE.DESCRIPTION,
};
}
}
@@ -75,22 +75,6 @@ export const isSimilarUser = (
};
}
if (
bskyProfile.description
?.toLocaleLowerCase()
.includes(`@${lowerCaseNames.accountName}`) &&
!["pfp ", "pfp: ", "pfp by "].some((t) =>
bskyProfile.description
.toLocaleLowerCase()
.includes(`${t}@${lowerCaseNames.accountName}`),
)
) {
return {
isSimilar: true,
type: BSKY_USER_MATCH_TYPE.DESCRIPTION,
};
}
return {
isSimilar: false,
type: BSKY_USER_MATCH_TYPE.NONE,

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import AlertError from "./AlertError";
const meta: Meta<typeof AlertError> = {
title: "CSUI/AlertError",
title: "Components/AlertError",
component: AlertError,
};
export default meta;

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import AlertSuccess from "./AlertSuccess";
const meta: Meta<typeof AlertSuccess> = {
title: "CSUI/AlertSuccess",
title: "Components/AlertSuccess",
component: AlertSuccess,
};
export default meta;

View File

@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import AsyncButton from "./AsyncButton";
const meta = {
title: "Components/AsyncButton",
component: AsyncButton,
} as Meta<typeof AsyncButton>;
export default meta;
type Story = StoryObj<typeof AsyncButton>;
export const Default: Story = {
args: {
label: "Click Me",
onClick: async () => {
return new Promise((resolve) => setTimeout(resolve, 2000));
},
},
};

View File

@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react";
import useConfirm, { ConfirmationDialog } from "./ConfirmDialog";
const meta = {
title: "Components/ConfirmDialog",
component: ConfirmationDialog,
} satisfies Meta<typeof ConfirmationDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
message: "Are you sure you want to proceed?",
open: false,
handleConfirm: () => {},
handleCancel: () => {},
},
render: (args) => {
const { ConfirmationDialog, confirm } = useConfirm({
message: args.message,
});
const handleClick = async () => {
const result = await confirm();
alert(`Confirmed: ${result}`);
};
return (
<div>
<button type="button" onClick={handleClick} className="btn btn-primary">
Open Confirm Dialog
</button>
<ConfirmationDialog />
</div>
);
},
};

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
export const ConfirmationDialog = ({
title,
message,
open,
handleConfirm,
handleCancel,
cancelText = "Cancel",
okText = "OK",
}: {
title?: string;
message: string;
open: boolean;
cancelText?: string;
okText?: string;
handleConfirm: () => void;
handleCancel: () => void;
}) => (
<dialog id="my_modal_1" className="modal" open={open}>
<div className="modal-box">
{title && <h3 className="font-bold text-xl mb-2">{title}</h3>}
<p className="text-sm">{message}</p>
<div className="modal-action">
<form method="dialog">
<div className="flex gap-2">
<button
className="btn btn-neutral btn-sm min-w-24"
type="button"
onClick={handleCancel}
>
{cancelText}
</button>
<button
className="btn btn-primary btn-sm min-w-24"
type="button"
onClick={handleConfirm}
>
{okText}
</button>
</div>
</form>
</div>
</div>
</dialog>
);
const useConfirm = ({
title = "Confirm",
message = "Are you sure you want to proceed?",
cancelText = "Cancel",
okText = "OK",
}: {
title?: string;
message?: string;
cancelText?: string;
okText?: string;
}) => {
const [promise, setPromise] = useState(null);
const confirm = () => {
return new Promise((resolve, reject) => {
setPromise({ resolve });
});
};
const handleClose = () => {
setPromise(null);
};
const handleConfirm = () => {
promise?.resolve(true);
handleClose();
};
const handleCancel = () => {
promise?.resolve(false);
handleClose();
};
return {
ConfirmationDialog: () => (
<ConfirmationDialog
title={title}
message={message}
open={promise !== null}
handleConfirm={handleConfirm}
handleCancel={handleCancel}
cancelText={cancelText}
okText={okText}
/>
),
confirm,
};
};
export default useConfirm;

View File

@@ -1,49 +0,0 @@
const Header = () => {
return (
<div className="navbar bg-base-100 border-b border-base-200">
<div className="flex-1 flex items-center gap-2">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 48 48"
>
<g
fill="none"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="4"
>
<path
strokeLinecap="round"
d="M36 8H13c-3 0-9 2-9 8s6 8 9 8h22c3 0 9 2 9 8s-6 8-9 8H12"
/>
<path d="M40 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8ZM8 44a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z" />
</g>
</svg>
<span className="text-2xl font-bold">Sky Follower Bridge</span>
</div>
<div className="flex-none">
<a
href="https://github.com/kawamataryo/sky-follower-bridge"
target="_blank"
rel="noopener noreferrer"
className="btn btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
className="fill-current"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</div>
);
};
export default Header;

View File

@@ -1,23 +0,0 @@
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

@@ -1,42 +0,0 @@
import React from "react";
import type { MatchType } from "../../types";
import { BSKY_USER_MATCH_TYPE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
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

@@ -1,32 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useRef } from "react";
import BlueskyIconSvg from "./Icons/BlueskyIconSvg";
import { useState } from "react";
import Modal from "./Modal";
import UserCard, { type Props as UserCardProps } from "./UserCard";
const meta: Meta<typeof UserCard> = {
title: "CSUI/Modal",
component: UserCard,
const meta: Meta<typeof Modal> = {
title: "Components/Modal",
component: Modal,
};
export default meta;
type Story = StoryObj<{ items: UserCardProps["user"][] }>;
type Story = StoryObj<typeof Modal>;
const DefaultTemplate: Story = {
render: () => {
const modalRef = useRef<HTMLDialogElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<button
type="button"
className="btn btn-primary"
onClick={() => modalRef.current?.showModal()}
onClick={() => setIsModalOpen(true)}
>
open
</button>
<Modal anchorRef={modalRef}>
<Modal open={isModalOpen} onClose={() => setIsModalOpen(false)}>
<p>Modal content</p>
</Modal>
</>
@@ -34,42 +31,6 @@ const DefaultTemplate: Story = {
},
};
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

@@ -1,14 +1,15 @@
import type React from "react";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
export type Props = {
children: React.ReactNode;
anchorRef: React.RefObject<HTMLDialogElement>;
open?: boolean;
open: boolean;
onClose?: () => void;
};
const Modal = ({ children, anchorRef, open = false, onClose }: Props) => {
const Modal = ({ children, open = false, onClose }: Props) => {
const anchorRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (anchorRef.current) {
anchorRef.current.addEventListener("close", onClose);
@@ -19,7 +20,7 @@ const Modal = ({ children, anchorRef, open = false, onClose }: Props) => {
anchorRef.current.removeEventListener("close", onClose);
}
};
}, [anchorRef, onClose]);
}, [onClose]);
return (
<>

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BSKY_USER_MATCH_TYPE } from "../constants";
import { ACTION_MODE, BSKY_USER_MATCH_TYPE } from "../constants";
import Sidebar from "./Sidebar";
const meta = {
@@ -15,7 +15,7 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
detectedCount: 42,
detectedCount: 40,
filterValue: {
[BSKY_USER_MATCH_TYPE.HANDLE]: true,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: false,
@@ -25,6 +25,16 @@ export const Default: Story = {
onChangeFilter: (key) => {
console.log(`Filter changed: ${key}`);
},
matchTypeStats: {
[BSKY_USER_MATCH_TYPE.HANDLE]: 10,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: 10,
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: 10,
[BSKY_USER_MATCH_TYPE.FOLLOWING]: 10,
},
actionAll: async () => {
console.log("actionAll");
},
actionMode: ACTION_MODE.FOLLOW,
},
};
@@ -40,5 +50,15 @@ export const NoDetections: Story = {
onChangeFilter: (key) => {
console.log(`Filter changed: ${key}`);
},
matchTypeStats: {
[BSKY_USER_MATCH_TYPE.HANDLE]: 0,
[BSKY_USER_MATCH_TYPE.DISPLAY_NAME]: 0,
[BSKY_USER_MATCH_TYPE.DESCRIPTION]: 0,
[BSKY_USER_MATCH_TYPE.FOLLOWING]: 0,
},
actionAll: async () => {
console.log("actionAll");
},
actionMode: ACTION_MODE.FOLLOW,
},
};

View File

@@ -14,7 +14,7 @@ type Props = {
onChangeFilter: (key: MatchType) => void;
actionAll: () => Promise<void>;
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
matchTypeStats: Record<MatchType, number>;
matchTypeStats: Record<Exclude<MatchType, "none">, number>;
};
const Sidebar = ({
@@ -179,10 +179,9 @@ const Sidebar = ({
className="w-full"
>
<img
height={36}
src="https://storage.ko-fi.com/cdn/kofi1.png?v=6"
alt="Buy Me a Coffee at ko-fi.com"
className="w-[110px] h-auto m-auto"
className="w-[120px] h-auto m-auto"
/>
</a>
<div className="divider" />

View File

@@ -1,15 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react";
import Header from "./Header";
import SocialLinks from "./SocialLinks";
const meta = {
title: "Header",
component: Header,
parameters: {
layout: "fullscreen",
},
} satisfies Meta<typeof Header>;
title: "Components/SocialLinks",
component: SocialLinks,
} as Meta<typeof SocialLinks>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@@ -5,12 +5,12 @@ const SocialLinks = () => {
href="https://github.com/kawamataryo/sky-follower-bridge"
target="_blank"
rel="noreferrer"
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8"
className="bg-base-100 p-2 rounded-full hover:opacity-80"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
>
@@ -21,12 +21,12 @@ const SocialLinks = () => {
href="https://bsky.app/profile/kawamataryo.bsky.social"
target="_blank"
rel="noreferrer"
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8"
className="bg-base-100 p-2 rounded-full hover:opacity-80"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
>
@@ -37,12 +37,12 @@ const SocialLinks = () => {
href="https://twitter.com/KawamataRyo"
target="_blank"
rel="noreferrer"
className="bg-base-100 p-2 rounded-full hover:opacity-80 w-8 h-8"
className="bg-base-100 p-2 rounded-full hover:opacity-80"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
width="18"
height="18"
viewBox="0 0 24 24"
fill="currentColor"
>

View File

@@ -4,7 +4,7 @@ import { ACTION_MODE, BSKY_USER_MATCH_TYPE } from "../constants";
import UserCard, { type Props } from "./UserCard";
const meta: Meta<typeof UserCard> = {
title: "CSUI/UserCard",
title: "Components/UserCard",
component: UserCard,
};
export default meta;
@@ -27,13 +27,16 @@ const demoUser: Props["user"] = {
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",
avatar: "https://i.pravatar.cc/150?u=123",
matchType: BSKY_USER_MATCH_TYPE.HANDLE,
isFollowing: false,
followingUri: "",
isBlocking: false,
blockingUri: "",
originalAvatar: "https://i.pravatar.cc/150?u=123",
originalHandle: "kawamataryo",
originalDisplayName: "KawamataRyo",
originalProfileLink: "https://x.com/kawamataryo",
};
const mockAction: Props["clickAction"] = async () => {

View File

@@ -4,6 +4,74 @@ import type { BskyUser } from "~types";
import { ACTION_MODE, MATCH_TYPE_LABEL_AND_COLOR } from "../constants";
import AvatarFallbackSvg from "./Icons/AvatarFallbackSvg";
type UserProfileProps = {
avatar: string;
url: string;
};
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;
};
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;
};
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 Props = {
user: BskyUser;
actionMode: (typeof ACTION_MODE)[keyof typeof ACTION_MODE];
@@ -82,67 +150,64 @@ const UserCard = ({ user, actionMode, clickAction }: Props) => {
};
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 className="bg-base-100 w-full relative grid grid-cols-[22%_1fr] gap-5">
<div className="flex flex-row gap-2 bg-slate-100 dark:bg-slate-800 justify-between pr-2">
<div
className={`border-l-8 border-${
MATCH_TYPE_LABEL_AND_COLOR[user.matchType].color
} relative py-3 pl-4 pr-1 grid grid-cols-[50px_1fr]`}
>
<UserProfile
avatar={user.originalAvatar}
url={user.originalProfileLink}
/>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center gap-2">
<UserInfo
handle={user.originalHandle}
displayName={user.originalDisplayName}
url={user.originalProfileLink}
/>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-7 w-7"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</div>
</div>
<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>
<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 dark:text-gray-400 text-sm">
<a
href={`https://bsky.app/profile/${user.handle}`}
target="_blank"
rel="noreferrer"
>
@{user.handle}
</a>
</p>
</div>
<UserInfo
handle={user.handle}
displayName={user.displayName}
url={`https://bsky.app/profile/${user.handle}`}
/>
<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>
<ActionButton
loading={loading}
actionBtnLabelAndClass={actionBtnLabelAndClass}
handleActionButtonClick={handleActionButtonClick}
setIsBtnHovered={setIsBtnHovered}
setIsJustClicked={setIsJustClicked}
/>
</div>
</div>
<p className="text-sm break-all">{user.description}</p>

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import UserCardSkeleton from "./UserCardSkeleton";
const meta: Meta<typeof UserCardSkeleton> = {
title: "CSUI/UserCardSkeleton",
title: "Components/UserCardSkeleton",
component: UserCardSkeleton,
};
export default meta;

View File

@@ -2,11 +2,28 @@ import type { AtpSessionData } from "@atproto/api";
import { Storage } from "@plasmohq/storage";
import { useStorage } from "@plasmohq/storage/hook";
import React from "react";
import { P, match } from "ts-pattern";
import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
import { type MESSAGE_NAMES, STORAGE_KEYS } from "~lib/constants";
import { MESSAGE_NAMES, STORAGE_KEYS } from "~lib/constants";
import { searchBskyUser } from "~lib/searchBskyUsers";
import { XService } from "~lib/services/x";
import type { BskyUser, CrawledUserInfo } from "~types";
import type { AbstractService } from "~lib/services/abstractService";
import { XService } from "~lib/services/xService";
import type { BskyUser, CrawledUserInfo, MessageName } from "~types";
const getService = (messageName: string): AbstractService => {
return match(messageName)
.with(
P.when((name) =>
[
MESSAGE_NAMES.SEARCH_BSKY_USER_ON_FOLLOW_PAGE,
MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE,
MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE,
].includes(name as MessageName),
),
() => new XService(messageName),
)
.otherwise(() => new XService(messageName));
};
const scrapeListNameFromPage = (): string => {
const listNameElement = document.querySelector(
@@ -39,11 +56,6 @@ export const useRetrieveBskyUsers = () => {
}>(null);
const [listName, setListName] = React.useState<string>("");
const modalRef = React.useRef<HTMLDialogElement>(null);
const showModal = () => {
modalRef.current?.showModal();
};
const retrieveBskyUsers = React.useCallback(
async (usersData: CrawledUserInfo[]) => {
for (const userData of usersData) {
@@ -69,6 +81,10 @@ export const useRetrieveBskyUsers = () => {
followingUri: searchResult.bskyProfile.viewer?.following,
isBlocking: !!searchResult.bskyProfile.viewer?.blocking,
blockingUri: searchResult.bskyProfile.viewer?.blocking,
originalAvatar: userData.originalAvatar,
originalHandle: userData.accountName,
originalDisplayName: userData.displayName,
originalProfileLink: userData.originalProfileLink,
},
];
});
@@ -86,7 +102,7 @@ export const useRetrieveBskyUsers = () => {
let index = 0;
const xService = new XService(messageName);
const service = getService(messageName);
// loop until we get to the bottom
while (!isBottomReached) {
@@ -94,10 +110,10 @@ export const useRetrieveBskyUsers = () => {
break;
}
const data = xService.getCrawledUsers();
const data = service.getCrawledUsers();
await retrieveBskyUsers(data);
const isEnd = await xService.performScrollAndCheckEnd();
const isEnd = await service.performScrollAndCheckEnd();
if (isEnd) {
setIsBottomReached(true);
@@ -155,7 +171,6 @@ export const useRetrieveBskyUsers = () => {
});
setLoading(true);
await setUsers([]);
showModal();
}, []);
const restart = React.useCallback(() => {
@@ -177,8 +192,6 @@ export const useRetrieveBskyUsers = () => {
);
return {
modalRef,
showModal,
initialize,
users,
listName,

View File

@@ -0,0 +1,36 @@
import { MESSAGE_NAME_TO_QUERY_PARAM_MAP } from "~lib/constants";
import type { CrawledUserInfo, MessageName } from "~types";
export abstract class AbstractService {
messageName: MessageName;
crawledUsers: Set<string>;
constructor(messageName: string) {
this.messageName = messageName as MessageName;
this.crawledUsers = new Set();
}
abstract extractUserData(userCell: Element): CrawledUserInfo;
getCrawledUsers(): CrawledUserInfo[] {
const userCells = Array.from(
document.querySelectorAll(
MESSAGE_NAME_TO_QUERY_PARAM_MAP[this.messageName],
),
);
const users = Array.from(userCells)
.map((userCell) => this.extractUserData(userCell))
.filter((user) => {
const isNewUser = !this.crawledUsers.has(user.accountName);
if (isNewUser) {
this.crawledUsers.add(user.accountName);
}
return isNewUser;
});
return users;
}
abstract performScrollAndCheckEnd(): Promise<boolean>;
}

View File

@@ -1,20 +1,11 @@
import { MESSAGE_NAMES } from "~lib/constants";
import { BSKY_DOMAIN, MESSAGE_NAME_TO_QUERY_PARAM_MAP } from "~lib/constants";
import { wait } from "~lib/utils";
import type { CrawledUserInfo, MessageName } from "~types";
import type { CrawledUserInfo } from "~types";
import { AbstractService } from "./abstractService";
export class XService {
// 対象のdomを取得する処理
messageName: MessageName;
crawledUsers: Set<string>;
constructor(messageName: string) {
// TODO: add type check
this.messageName = messageName as MessageName;
this.crawledUsers = new Set();
}
private extractUserData(userCell: Element): CrawledUserInfo {
export class XService extends AbstractService {
extractUserData(userCell: Element): CrawledUserInfo {
const anchors = Array.from(userCell.querySelectorAll("a"));
const [avatarEl, displayNameEl] = anchors;
const accountName = avatarEl?.getAttribute("href")?.replace("/", "");
@@ -29,6 +20,10 @@ export class XService {
?.match(/bsky\.app\/profile\/([^/\s]+)…?/)?.[1]
?.replace("…", "") ??
"";
const originalAvatar = userCell
.querySelector('[data-testid^="UserAvatar-Container"]')
?.querySelector("img")
?.getAttribute("src");
return {
accountName,
@@ -36,28 +31,11 @@ export class XService {
accountNameRemoveUnderscore,
accountNameReplaceUnderscore,
bskyHandle,
originalAvatar,
originalProfileLink: `https://x.com/${accountName}`,
};
}
getCrawledUsers(): CrawledUserInfo[] {
const userCells = Array.from(
document.querySelectorAll(
MESSAGE_NAME_TO_QUERY_PARAM_MAP[this.messageName],
),
);
const users = userCells
.map((userCell) => this.extractUserData(userCell))
.filter((user) => !this.crawledUsers.has(user.accountName));
this.crawledUsers = new Set([
...this.crawledUsers,
...users.map((user) => user.accountName),
]);
return users;
}
async performScrollAndCheckEnd(): Promise<boolean> {
const isListMembersPage =
this.messageName === MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE;

View File

@@ -1,7 +1,10 @@
import UserCard from "~lib/components/UserCard";
import { useBskyUserManager } from "~lib/hooks/useBskyUserManager";
import "./style.css";
import { ToastContainer, toast } from "react-toastify";
import useConfirm from "~lib/components/ConfirmDialog";
import Sidebar from "~lib/components/Sidebar";
import "react-toastify/dist/ReactToastify.css";
const Option = () => {
const {
@@ -16,17 +19,23 @@ const Option = () => {
matchTypeStats,
} = useBskyUserManager();
const { confirm, ConfirmationDialog } = useConfirm({
title: "Proceed with Execution?",
message:
"User detection is not perfect and may include false positives. Do you still want to proceed?",
cancelText: "Cancel",
okText: "OK",
});
const handleActionAll = async () => {
if (
!window.confirm(
"User detection is not perfect and may include false positives. Do you still want to proceed?",
)
) {
if (!(await confirm())) {
return;
}
await actionAll();
const result = await actionAll();
toast.success(`Followed ${result} users`);
};
return (
<>
<div className="flex h-screen">
@@ -40,22 +49,30 @@ const Option = () => {
matchTypeStats={matchTypeStats}
/>
</div>
<div className="flex-1 ml-80 p-6 overflow-y-auto">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<div className="divide-y divide-gray-500">
{filteredUsers.map((user) => (
<UserCard
key={user.handle}
user={user}
clickAction={handleClickAction}
actionMode={actionMode}
/>
))}
</div>
<div className="flex-1 ml-80 p-6 pt-0 overflow-y-auto">
<div className="grid grid-cols-[22%_1fr] sticky top-0 z-10 bg-base-100 border-b-[1px] border-gray-500">
<h2 className="text-lg font-bold text-center py-2">Source</h2>
<h2 className="text-lg font-bold text-center py-2">Detected</h2>
</div>
<div className="flex flex-col gap-4">
<div className="divide-y divide-gray-500">
{filteredUsers.map((user) => (
<UserCard
key={user.handle}
user={user}
clickAction={handleClickAction}
actionMode={actionMode}
/>
))}
</div>
</div>
</div>
<ToastContainer
position="top-right"
autoClose={5000}
className="text-sm"
/>
<ConfirmationDialog />
</div>
</>
);

View File

@@ -1,5 +1,6 @@
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { P, match } from "ts-pattern";
import packageJson from "../package.json";
import "./style.css";
@@ -213,7 +214,8 @@ function IndexPopup() {
<path d="M40 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8ZM8 44a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z" />
</g>
</svg>
Sky Follower Bridge
Sky Follower Bridge{" "}
<span className="text-sm self-end">v{packageJson.version}</span>
</h1>
<form onSubmit={searchBskyUser} className="mt-5">
<label className="w-full block" htmlFor="identifier">

View File

@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.modal {
background-color: rgba(0, 0, 0, 0.5);
}

View File

@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.modal {
background-color: rgba(0, 0, 0, 0.5);
}

View File

@@ -16,6 +16,10 @@ export type BskyUser = {
followingUri: string | null;
isBlocking: boolean;
blockingUri: string | null;
originalAvatar: string;
originalHandle: string;
originalDisplayName: string;
originalProfileLink: string;
};
export type MatchTypeFilterValue = {
@@ -31,4 +35,6 @@ export type CrawledUserInfo = {
accountNameRemoveUnderscore: string;
accountNameReplaceUnderscore: string;
bskyHandle: string;
originalAvatar: string;
originalProfileLink: string;
};