tok-to-insta-follower-bridge/src/lib/hooks/useRetrieveBskyUsers.ts
2024-11-17 10:34:03 +09:00

251 lines
7.6 KiB
TypeScript

import type { AtpSessionData } from "@atproto/api";
import { Storage } from "@plasmohq/storage";
import { useStorage } from "@plasmohq/storage/hook";
import React from "react";
import { BskyServiceWorkerClient } from "~lib/bskyServiceWorkerClient";
import {
type MESSAGE_NAMES,
MESSAGE_NAME_TO_QUERY_PARAM_MAP,
STORAGE_KEYS,
} from "~lib/constants";
import { getAccountNameAndDisplayName, getUserCells } from "~lib/domHelpers";
import { searchBskyUser } from "~lib/searchBskyUsers";
import { wait } from "~lib/utils";
import type { MatchType } from "~types";
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 [detectedXUsers, setDetectedXUsers] = React.useState<
ReturnType<typeof detectXUsers>
>([]);
const [users, setUsers] = useStorage<BskyUser[]>(
{
key: STORAGE_KEYS.DETECTED_BSKY_USERS,
instance: new Storage({
area: "local",
}),
},
(v) => (v === undefined ? [] : v),
);
const [loading, setLoading] = React.useState(true);
const [errorMessage, setErrorMessage] = React.useState("");
const [isBottomReached, setIsBottomReached] = React.useState(false);
const [retrievalParams, setRetrievalParams] = React.useState<null | {
session: AtpSessionData;
messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
}>(null);
const modalRef = React.useRef<HTMLDialogElement>(null);
const showModal = () => {
modalRef.current?.showModal();
};
const retrieveBskyUsers = React.useCallback(
async (usersData: ReturnType<typeof getAccountNameAndDisplayName>[]) => {
for (const userData of usersData) {
const searchResult = await searchBskyUser({
client: bskyClient.current,
userData,
});
if (searchResult) {
await 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,
},
];
});
}
}
},
[setUsers],
);
const abortControllerRef = React.useRef<AbortController | null>(null);
const startRetrieveLoop = React.useCallback(
async (messageName: string) => {
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
let index = 0;
const queryParam = MESSAGE_NAME_TO_QUERY_PARAM_MAP[messageName];
let scrollElement: HTMLElement | Window;
let modalScrollInterval: number;
if (messageName === "search_bsky_user_on_list_members_page") {
// select the modal wrapper using viewport selector to avoid conflation with feed in the background
scrollElement = document.querySelector(
'div[data-viewportview="true"]',
) as HTMLElement;
// base interval off of intitial scroll height
modalScrollInterval = scrollElement.scrollHeight;
} else {
// for other cases, use the window, no need to cache a scroll interval due to different window scroll logic
scrollElement = window;
}
// loop until we get to the bottom
while (!isBottomReached) {
if (signal.aborted) {
break;
}
const data = detectXUsers(queryParam).filter((u) => {
return !detectedXUsers.some(
(t) => t.twAccountName === u.twAccountName,
);
});
setDetectedXUsers((prev) => [...prev, ...data]);
await retrieveBskyUsers(data);
// handle scrolling pattern for both modal and window
if (scrollElement instanceof HTMLElement) {
scrollElement.scrollTop += modalScrollInterval;
} else {
window.scrollTo(0, document.body.scrollHeight);
}
// wait for fetching data by x
await wait(3000);
// break if bottom is reached
if (scrollElement instanceof HTMLElement) {
if (
scrollElement.scrollTop + scrollElement.clientHeight >=
scrollElement.scrollHeight
) {
setIsBottomReached(true);
setLoading(false);
break;
}
} else {
const documentElement = document.documentElement;
if (
documentElement.scrollTop + documentElement.clientHeight >=
documentElement.scrollHeight
) {
setIsBottomReached(true);
setLoading(false);
break;
}
}
index++;
if (process.env.NODE_ENV === "development" && index > 5) {
setLoading(false);
break;
}
}
},
[retrieveBskyUsers, detectedXUsers, isBottomReached],
);
React.useEffect(() => {
chrome.storage.local.set({ users: JSON.stringify(users) });
}, [users]);
const stopRetrieveLoop = React.useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setLoading(false);
}
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
const initialize = React.useCallback(async () => {
const storage = await chrome.storage.local.get([
STORAGE_KEYS.BSKY_CLIENT_SESSION,
STORAGE_KEYS.BSKY_MESSAGE_NAME,
]);
const messageName = storage[STORAGE_KEYS.BSKY_MESSAGE_NAME];
const session = storage[STORAGE_KEYS.BSKY_CLIENT_SESSION];
setRetrievalParams({
session,
messageName,
});
bskyClient.current = new BskyServiceWorkerClient(session);
startRetrieveLoop(messageName).catch((e) => {
console.error(e);
setErrorMessage(e.message);
setLoading(false);
});
setLoading(true);
await setUsers([]);
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],
);
return {
modalRef,
showModal,
initialize,
users,
loading,
errorMessage,
isRateLimitError,
restart,
isSucceeded,
isBottomReached,
stopRetrieveLoop,
};
};