Merge pull request #112 from dtflowers/enhance-list-features

[RFC] Allow Users to Import List from Bridge
This commit is contained in:
ryo 2024-11-28 12:46:16 +09:00 committed by GitHub
commit f637e48f3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 257 additions and 9 deletions

View File

@ -0,0 +1,21 @@
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { BskyClient } from "~lib/bskyClient";
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
const { session, userDid, listUri } = req.body;
const client = await BskyClient.createAgentFromSession(session);
try {
res.send({
result: await client.addUserToList({ userDid, listUri }),
});
} catch (e) {
res.send({
error: {
message: e.message,
},
});
}
};
export default handler;

View File

@ -0,0 +1,21 @@
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { BskyClient } from "~lib/bskyClient";
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
const { session, name, description } = req.body;
const client = await BskyClient.createAgentFromSession(session);
try {
res.send({
uri: await client.createList({ name, description }),
});
} catch (e) {
res.send({
error: {
message: e.message,
},
});
}
};
export default handler;

View File

@ -0,0 +1,35 @@
import type { PlasmoMessaging } from "@plasmohq/messaging";
import { BskyClient } from "~lib/bskyClient";
import { STORAGE_KEYS } from "~lib/constants";
const handler: PlasmoMessaging.MessageHandler = async (req, res) => {
const { name, description, userDids } = req.body;
const storage = await chrome.storage.local.get(
STORAGE_KEYS.BSKY_CLIENT_SESSION,
);
const session = storage[STORAGE_KEYS.BSKY_CLIENT_SESSION];
if (!session || !session.did) {
res.send({
error: {
message: "Invalid session data",
},
});
return;
}
try {
const client = await BskyClient.createAgentFromSession(session);
await client.createListAndAddUsers({ name, description, userDids });
res.send({ success: true });
} catch (e) {
res.send({
error: {
message: e.message,
},
});
}
};
export default handler;

View File

@ -29,6 +29,7 @@ const App = () => {
restart, restart,
isBottomReached, isBottomReached,
errorMessage, errorMessage,
listName,
} = useRetrieveBskyUsers(); } = useRetrieveBskyUsers();
const [isModalOpen, setIsModalOpen] = React.useState(false); const [isModalOpen, setIsModalOpen] = React.useState(false);
@ -72,7 +73,10 @@ const App = () => {
const stopAndShowDetectedUsers = async () => { const stopAndShowDetectedUsers = async () => {
stopRetrieveLoop(); stopRetrieveLoop();
await chrome.storage.local.set({ users: JSON.stringify(users) }); await chrome.storage.local.set({
users: JSON.stringify(users),
listName: listName,
});
openOptionPage(); openOptionPage();
}; };

View File

@ -114,4 +114,59 @@ export class BskyClient {
rkey, rkey,
}); });
}; };
public createList = async ({
name,
description,
}: {
name: string;
description: string;
}) => {
const result = await this.agent.com.atproto.repo.createRecord({
repo: this.me.did,
collection: "app.bsky.graph.list",
record: {
$type: "app.bsky.graph.list",
purpose: "app.bsky.graph.defs#curatelist",
name,
description,
createdAt: new Date().toISOString(),
},
});
return result.data.uri;
};
public addUserToList = async ({
userDid,
listUri,
}: {
userDid: string;
listUri: string;
}) => {
return await this.agent.com.atproto.repo.createRecord({
repo: this.me.did,
collection: "app.bsky.graph.listitem",
record: {
$type: "app.bsky.graph.listitem",
subject: userDid,
list: listUri,
createdAt: new Date().toISOString(),
},
});
};
public createListAndAddUsers = async ({
name,
description,
userDids,
}: {
name: string;
description: string;
userDids: string[];
}) => {
const listUri = await this.createList({ name, description });
for (const userDid of userDids) {
await this.addUserToList({ userDid, listUri });
}
};
} }

View File

@ -110,4 +110,59 @@ export class BskyServiceWorkerClient {
return result; return result;
}; };
public createList = async ({
name,
description,
}: {
name: string;
description: string;
}) => {
const { uri, error } = await sendToBackground({
name: "createList",
body: {
session: this.session,
name,
description,
},
});
if (error) throw new Error(error.message);
return uri;
};
public addUserToList = async ({
userDid,
listUri,
}: {
userDid: string;
listUri: string;
}) => {
const { result, error } = await sendToBackground({
name: "addUserToList",
body: {
session: this.session,
userDid,
listUri,
},
});
if (error) throw new Error(error.message);
return result;
};
public createListAndAddUsers = async ({
name,
description,
userDids,
}: {
name: string;
description: string;
userDids: string[];
}) => {
const listUri = await this.createList({ name, description });
for (const userDid of userDids) {
await this.addUserToList({ userDid, listUri });
}
};
} }

View File

@ -25,6 +25,19 @@ const Sidebar = ({
actionMode, actionMode,
matchTypeStats, matchTypeStats,
}: Props) => { }: Props) => {
const getActionLabel = () => {
switch (actionMode) {
case ACTION_MODE.FOLLOW:
return "Follow All";
case ACTION_MODE.BLOCK:
return "Block All";
case ACTION_MODE.IMPORT_LIST:
return "Import List";
default:
return "";
}
};
return ( return (
<aside className="bg-base-300 w-80 min-h-screen p-4 border-r border-base-300 flex flex-col"> <aside className="bg-base-300 w-80 min-h-screen p-4 border-r border-base-300 flex flex-col">
<div className="flex-grow"> <div className="flex-grow">
@ -144,10 +157,7 @@ const Sidebar = ({
</svg> </svg>
<p className="text-xl font-bold">Action</p> <p className="text-xl font-bold">Action</p>
</div> </div>
<AsyncButton <AsyncButton onClick={actionAll} label={getActionLabel()} />
onClick={actionAll}
label={actionMode === ACTION_MODE.FOLLOW ? "Follow All" : "Block All"}
/>
<p className="text-xs"> <p className="text-xs">
User detection is not perfect and may include false positives. User detection is not perfect and may include false positives.
</p> </p>

View File

@ -84,7 +84,7 @@ const UserCard = ({ user, actionMode, clickAction }: Props) => {
const actionBtnLabelAndClass = React.useMemo( const actionBtnLabelAndClass = React.useMemo(
() => () =>
match(actionMode) match(actionMode)
.with(ACTION_MODE.FOLLOW, () => { .with(ACTION_MODE.FOLLOW, ACTION_MODE.IMPORT_LIST, () => {
const follow = { const follow = {
label: "Follow on Bluesky", label: "Follow on Bluesky",
class: "btn-primary", class: "btn-primary",
@ -109,7 +109,7 @@ const UserCard = ({ user, actionMode, clickAction }: Props) => {
}) })
.with(ACTION_MODE.BLOCK, () => { .with(ACTION_MODE.BLOCK, () => {
const block = { const block = {
label: "block on Bluesky", label: "Block on Bluesky",
class: "btn-primary", class: "btn-primary",
}; };
const blocking = { const blocking = {

View File

@ -20,11 +20,13 @@ export const MESSAGE_NAME_TO_QUERY_PARAM_MAP = {
export const ACTION_MODE = { export const ACTION_MODE = {
FOLLOW: "follow", FOLLOW: "follow",
BLOCK: "block", BLOCK: "block",
IMPORT_LIST: "import_list",
}; };
export const MESSAGE_NAME_TO_ACTION_MODE_MAP = { 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_FOLLOW_PAGE]: ACTION_MODE.FOLLOW,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE]: ACTION_MODE.FOLLOW, [MESSAGE_NAMES.SEARCH_BSKY_USER_ON_LIST_MEMBERS_PAGE]:
ACTION_MODE.IMPORT_LIST,
[MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE]: ACTION_MODE.BLOCK, [MESSAGE_NAMES.SEARCH_BSKY_USER_ON_BLOCK_PAGE]: ACTION_MODE.BLOCK,
}; };

View File

@ -21,6 +21,14 @@ export const useBskyUserManager = () => {
}, },
(v) => (v === undefined ? [] : v), (v) => (v === undefined ? [] : v),
); );
const [listName, setListName] = React.useState<string>("");
React.useEffect(() => {
chrome.storage.local.get("listName", (result) => {
const name = result.listName || "Imported List from X";
setListName(name);
});
}, []);
const bskyClient = React.useRef<BskyServiceWorkerClient | null>(null); const bskyClient = React.useRef<BskyServiceWorkerClient | null>(null);
const [actionMode, setActionMode] = React.useState< const [actionMode, setActionMode] = React.useState<
(typeof ACTION_MODE)[keyof typeof ACTION_MODE] (typeof ACTION_MODE)[keyof typeof ACTION_MODE]
@ -121,6 +129,19 @@ export const useBskyUserManager = () => {
if (!bskyClient.current) return; if (!bskyClient.current) return;
let actionCount = 0; let actionCount = 0;
if (actionMode === ACTION_MODE.IMPORT_LIST) {
const userDids = filteredUsers.map((user) => user.did);
await chrome.runtime.sendMessage({
name: "createListAndAddUsers",
body: {
name: listName,
description: "List imported via Sky Follower Bridge",
userDids,
},
});
return;
}
for (const user of filteredUsers) { for (const user of filteredUsers) {
let resultUri: string | null = null; let resultUri: string | null = null;
// follow // follow
@ -170,7 +191,7 @@ export const useBskyUserManager = () => {
} }
} }
return actionCount; return actionCount;
}, [filteredUsers, actionMode, setUsers]); }, [filteredUsers, actionMode, setUsers, listName]);
React.useEffect(() => { React.useEffect(() => {
chrome.storage.local.get( chrome.storage.local.get(
@ -204,6 +225,7 @@ export const useBskyUserManager = () => {
return { return {
handleClickAction, handleClickAction,
users, users,
listName,
actionMode, actionMode,
matchTypeFilter, matchTypeFilter,
changeMatchTypeFilter, changeMatchTypeFilter,

View File

@ -25,6 +25,16 @@ const getService = (messageName: string): AbstractService => {
.otherwise(() => new XService(messageName)); .otherwise(() => new XService(messageName));
}; };
const scrapeListNameFromPage = (): string => {
const listNameElement = document.querySelector(
'div[aria-label="Timeline: List"] span',
);
if (listNameElement) {
return listNameElement.textContent.trim();
}
return "Imported List from X";
};
export const useRetrieveBskyUsers = () => { export const useRetrieveBskyUsers = () => {
const bskyClient = React.useRef<BskyServiceWorkerClient | null>(null); const bskyClient = React.useRef<BskyServiceWorkerClient | null>(null);
const [users, setUsers] = useStorage<BskyUser[]>( const [users, setUsers] = useStorage<BskyUser[]>(
@ -44,6 +54,7 @@ export const useRetrieveBskyUsers = () => {
session: AtpSessionData; session: AtpSessionData;
messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES]; messageName: (typeof MESSAGE_NAMES)[keyof typeof MESSAGE_NAMES];
}>(null); }>(null);
const [listName, setListName] = React.useState<string>("");
const retrieveBskyUsers = React.useCallback( const retrieveBskyUsers = React.useCallback(
async (usersData: CrawledUserInfo[]) => { async (usersData: CrawledUserInfo[]) => {
@ -120,6 +131,13 @@ export const useRetrieveBskyUsers = () => {
[retrieveBskyUsers, isBottomReached], [retrieveBskyUsers, isBottomReached],
); );
React.useEffect(() => {
chrome.storage.local.set({
users: JSON.stringify(users),
listName: listName,
});
}, [users, listName]);
const stopRetrieveLoop = React.useCallback(() => { const stopRetrieveLoop = React.useCallback(() => {
if (abortControllerRef.current) { if (abortControllerRef.current) {
abortControllerRef.current.abort(); abortControllerRef.current.abort();
@ -143,6 +161,9 @@ export const useRetrieveBskyUsers = () => {
bskyClient.current = new BskyServiceWorkerClient(session); bskyClient.current = new BskyServiceWorkerClient(session);
const listName = scrapeListNameFromPage();
setListName(listName);
startRetrieveLoop(messageName).catch((e) => { startRetrieveLoop(messageName).catch((e) => {
console.error(e); console.error(e);
setErrorMessage(e.message); setErrorMessage(e.message);
@ -173,6 +194,7 @@ export const useRetrieveBskyUsers = () => {
return { return {
initialize, initialize,
users, users,
listName,
loading, loading,
errorMessage, errorMessage,
isRateLimitError, isRateLimitError,

View File

@ -10,6 +10,7 @@ const Option = () => {
const { const {
users, users,
filteredUsers, filteredUsers,
listName,
matchTypeFilter, matchTypeFilter,
changeMatchTypeFilter, changeMatchTypeFilter,
handleClickAction, handleClickAction,