diff --git a/app/repositories/[id]/[...record]/page-content.tsx b/app/repositories/[id]/[...record]/page-content.tsx index 0c3e5943..2c43f52d 100644 --- a/app/repositories/[id]/[...record]/page-content.tsx +++ b/app/repositories/[id]/[...record]/page-content.tsx @@ -83,7 +83,7 @@ export default function RecordViewPageContent({ const uri = createAtUri({ did, collection, rkey }) const getRecord = async () => { const { data: record } = - await labelerAgent.api.tools.ozone.moderation.getRecord({ uri }) + await labelerAgent.tools.ozone.moderation.getRecord({ uri }) return record } const getThread = async () => { @@ -92,7 +92,7 @@ export default function RecordViewPageContent({ } try { const { data: thread } = - await labelerAgent.api.app.bsky.feed.getPostThread({ uri }) + await labelerAgent.app.bsky.feed.getPostThread({ uri }) return thread } catch (err) { if (err instanceof GetPostThread.NotFoundError) { @@ -101,26 +101,24 @@ export default function RecordViewPageContent({ throw err } } - const getListProfiles = async () => { + const getList = async () => { if (collection !== CollectionId.List) { return undefined } - // TODO: We need pagination here, right? how come getPostThread doesn't need it? - const { data: listData } = - await labelerAgent.api.app.bsky.graph.getList({ - list: uri, - }) - return listData.items.map(({ subject }) => subject) + const { data } = await labelerAgent.app.bsky.graph.getList({ + list: uri, + }) + return data } - const [record, profiles, thread] = await Promise.allSettled([ + const [record, listData, thread] = await Promise.allSettled([ getRecord(), - getListProfiles(), + getList(), getThread(), ]) return { record: record.status === 'fulfilled' ? record.value : undefined, thread: thread.status === 'fulfilled' ? thread.value : undefined, - profiles: profiles.status === 'fulfilled' ? profiles.value : undefined, + listData: listData.status === 'fulfilled' ? listData.value : undefined, } }, }) @@ -200,9 +198,9 @@ export default function RecordViewPageContent({ /> {data?.record && ( setQuickActionPanelSubject(subject)} /> diff --git a/components/list/Accounts.tsx b/components/list/Accounts.tsx new file mode 100644 index 00000000..e5d6bead --- /dev/null +++ b/components/list/Accounts.tsx @@ -0,0 +1,172 @@ +import { ActionButton } from '@/common/buttons' +import { LoadMoreButton } from '@/common/LoadMoreButton' +import { ConfirmationModal } from '@/common/modals/confirmation' +import { AccountsGrid } from '@/repositories/AccountView' +import { useLabelerAgent } from '@/shell/ConfigurationContext' +import { useWorkspaceAddItemsMutation } from '@/workspace/hooks' +import { useInfiniteQuery } from '@tanstack/react-query' +import { useEffect, useRef, useState } from 'react' +import { toast } from 'react-toastify' + +type ListAccountsProps = { + uri: string +} + +const useListAccounts = ({ uri }: ListAccountsProps) => { + const labelerAgent = useLabelerAgent() + + const abortController = useRef(null) + const [isConfirmationOpen, setIsConfirmationOpen] = useState(false) + const [isAdding, setIsAdding] = useState(false) + const { mutateAsync: addItemsToWorkspace } = useWorkspaceAddItemsMutation() + + const { data, error, isLoading, fetchNextPage, hasNextPage } = + useInfiniteQuery({ + queryKey: ['list-accounts', { uri }], + queryFn: async ({ pageParam }) => { + const { data } = await labelerAgent.app.bsky.graph.getList({ + list: uri, + limit: 50, + cursor: pageParam, + }) + return { + cursor: data.cursor, + profiles: data.items.map(({ subject }) => subject), + } + }, + getNextPageParam: (lastPage) => lastPage.cursor, + }) + const profiles = data?.pages.flatMap((page) => page.profiles) + + const confirmAddToWorkspace = async () => { + // add items that are already loaded + if (profiles?.length) { + await addItemsToWorkspace(profiles.map((f) => f.did)) + } + if (!data?.pageParams) { + setIsConfirmationOpen(false) + return + } + setIsAdding(true) + const newAbortController = new AbortController() + abortController.current = newAbortController + + try { + let cursor = data.pageParams[0] as string | undefined + do { + const netItems = await labelerAgent.app.bsky.graph.getList( + { + list: uri, + cursor, + }, + { signal: abortController.current?.signal }, + ) + await addItemsToWorkspace(netItems.data.items.map((f) => f.subject.did)) + cursor = netItems.data.cursor + // if the modal is closed, that means the user decided not to add any more user to workspace + } while (cursor && isConfirmationOpen) + } catch (e) { + if (abortController.current?.signal.reason === 'user-cancelled') { + toast.info('Stopped adding list members to workspace') + } else { + toast.error(`Something went wrong: ${(e as Error).message}`) + } + } + setIsAdding(false) + setIsConfirmationOpen(false) + } + + useEffect(() => { + if (!isConfirmationOpen) { + abortController.current?.abort('user-cancelled') + } + }, [isConfirmationOpen]) + + useEffect(() => { + // User cancelled by closing this view (navigation, other?) + return () => abortController.current?.abort('user-cancelled') + }, []) + + return { + profiles, + isLoading, + error, + fetchNextPage, + hasNextPage, + isAdding, + setIsAdding, + isConfirmationOpen, + confirmAddToWorkspace, + setIsConfirmationOpen, + } +} + +export default function ListAccounts({ uri }: ListAccountsProps) { + const { + isConfirmationOpen, + setIsConfirmationOpen, + profiles, + isLoading, + error, + isAdding, + setIsAdding, + fetchNextPage, + hasNextPage, + confirmAddToWorkspace, + } = useListAccounts({ uri }) + + return ( +
+
+ setIsConfirmationOpen(true)} + > + Add all to workspace + + { + if (isAdding) { + setIsAdding(false) + setIsConfirmationOpen(false) + return + } + + confirmAddToWorkspace() + }} + isOpen={isConfirmationOpen} + setIsOpen={setIsConfirmationOpen} + confirmButtonText={isAdding ? 'Stop adding' : 'Yes, add all'} + title={`Add to workspace?`} + description={ + <> + Once confirmed, all users in this list will be added to the + workspace. If there are a lot of users in the list, this may take + quite some time but you can always stop the process and already + added users will remain in the workspace. + + } + /> +
+ + + + {hasNextPage && ( +
+ fetchNextPage()} /> +
+ )} +
+ ) +} diff --git a/components/repositories/RecordView.tsx b/components/repositories/RecordView.tsx index fefdb9eb..51338102 100644 --- a/components/repositories/RecordView.tsx +++ b/components/repositories/RecordView.tsx @@ -7,6 +7,8 @@ import { AppBskyFeedDefs, AppBskyActorDefs, ToolsOzoneModerationDefs, + AtUri, + AppBskyGraphDefs, } from '@atproto/api' import { ChevronLeftIcon, @@ -31,6 +33,8 @@ import { Likes } from '@/common/feeds/Likes' import { Reposts } from '@/common/feeds/Reposts' import { Thread } from '@/common/feeds/PostThread' import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { CollectionId } from '@/reports/helpers/subject' +import ListAccounts from 'components/list/Accounts' enum Views { Details, @@ -54,20 +58,22 @@ const TabKeys = { export function RecordView({ record, + list, thread, - profiles, onReport, onShowActionPanel, }: { + list?: AppBskyGraphDefs.ListView record: GetRecord.OutputSchema thread?: GetPostThread.OutputSchema - profiles?: AppBskyActorDefs.ProfileView[] onReport: (uri: string) => void onShowActionPanel: (subject: string) => void }) { const searchParams = useSearchParams() const router = useRouter() const pathname = usePathname() + const atUri = new AtUri(record.uri) + const isListRecord = atUri.collection === CollectionId.List const currentView = TabKeys[searchParams.get('tab') || 'details'] || TabKeys.details @@ -80,11 +86,11 @@ export function RecordView({ const getTabViews = () => { const views: TabView[] = [{ view: Views.Details, label: 'Details' }] - if (!!profiles?.length) { + if (isListRecord) { views.push({ view: Views.Profiles, label: 'Profiles', - sublabel: String(profiles.length), + sublabel: String(list?.listItemCount || ''), }) } if (!!thread) { @@ -155,8 +161,8 @@ export function RecordView({ onSetCurrentView={setCurrentView} /> {currentView === Views.Details &&
} - {currentView === Views.Profiles && !!profiles?.length && ( - + {currentView === Views.Profiles && ( + )} {currentView === Views.Likes && !!thread &&