Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Allow users to bulk add all users who liked a post to workspace #196

Merged
merged 4 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/actions/ModActionPanel/QuickAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ function Form(
setModEventType(MOD_EVENTS.TAKEDOWN)
}),
)

return (
<>
{/* The inline styling is not ideal but there's no easy way to set calc() values in tailwind */}
Expand Down
36 changes: 32 additions & 4 deletions components/common/RecordCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
AppBskyActorDefs,
ComAtprotoLabelDefs,
} from '@atproto/api'
import { buildBlueSkyAppUrl, parseAtUri } from '@/lib/util'
import { buildBlueSkyAppUrl, parseAtUri, pluralize } from '@/lib/util'
import { PostAsCard } from './posts/PostsFeed'
import Link from 'next/link'
import { LoadingDense, displayError, LoadingFailedDense } from './Loader'
Expand Down Expand Up @@ -86,7 +86,7 @@ function PostCard({ uri, showLabels }: { uri: string; showLabels?: boolean }) {
renderRecord={(record) => (
<PostAsCard
dense
controls={false}
controls={[]}
item={{
post: {
uri: record.uri,
Expand Down Expand Up @@ -116,9 +116,9 @@ function PostCard({ uri, showLabels }: { uri: string; showLabels?: boolean }) {
return (
<PostAsCard
dense
controls={false}
item={{ post: data.thread.post }}
showLabels={showLabels}
item={{ post: data.thread.post }}
controls={['like', 'repost', 'workspace']}
/>
)
}
Expand Down Expand Up @@ -340,6 +340,34 @@ export function RepoCard(props: { did: string }) {
{profile.description}
</p>
)}
{!!profile && (
<div className="flex flex-row items-center gap-2">
<Link
href={`/repositories/${repo.did}?tab=followers`}
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-400 underline hover:underline cursor-pointer"
>
<span className="text-sm">
{pluralize(profile?.followersCount || 0, 'follower')}
</span>
</Link>
<Link
href={`/repositories/${repo.did}?tab=follows`}
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-400 underline hover:underline cursor-pointer"
>
<span className="text-sm">
{pluralize(profile?.followsCount || 0, 'follow')}
</span>
</Link>
<Link
href={`/repositories/${repo.did}?tab=posts`}
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-400 underline hover:underline cursor-pointer"
>
<span className="text-sm">
{pluralize(profile?.postsCount || 0, 'post')}
</span>
</Link>
</div>
)}
{takendown && (
<p className="pt-1 pb-1">
<LoadingFailedDense
Expand Down
96 changes: 90 additions & 6 deletions components/common/feeds/Likes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ import { AccountsGrid } from '@/repositories/AccountView'
import { useInfiniteQuery } from '@tanstack/react-query'
import { LoadMoreButton } from '../LoadMoreButton'
import { usePdsAgent } from '@/shell/AuthContext'
import { ActionButton } from '../buttons'
import { ConfirmationModal } from '../modals/confirmation'
import { useState } from 'react'
import { useWorkspaceAddItemsMutation } from '@/workspace/hooks'
import { toast } from 'react-toastify'
import { Agent } from '@atproto/api'
import { pluralize } from '@/lib/util'

const useLikes = (uri: string, cid?: string) => {
const pdsAgent = usePdsAgent()

const useLikes = (pdsAgent: Agent, uri: string, cid?: string) => {
return useInfiniteQuery({
queryKey: ['likes', { uri, cid }],
queryFn: async ({ pageParam }) => {
const { data } = await pdsAgent.api.app.bsky.feed.getLikes({
const { data } = await pdsAgent.app.bsky.feed.getLikes({
uri,
cid,
limit: 50,
Expand All @@ -22,11 +27,90 @@ const useLikes = (uri: string, cid?: string) => {
}

export const Likes = ({ uri, cid }: { uri: string; cid?: string }) => {
const { data, fetchNextPage, hasNextPage } = useLikes(uri, cid)
const pdsAgent = usePdsAgent()
const { data, fetchNextPage, hasNextPage, error } = useLikes(
pdsAgent,
uri,
cid,
)
const likes = data?.pages.flatMap((page) => page.likes) || []

const [isConfirmationOpen, setIsConfirmationOpen] = useState(false)
const [isAdding, setIsAdding] = useState(false)
const { mutateAsync: addItemsToWorkspace } = useWorkspaceAddItemsMutation()

const confirmAddToWorkspace = async () => {
// add items that are already loaded
await addItemsToWorkspace(likes.map((l) => l.actor.did))
if (!data?.pageParams) {
setIsConfirmationOpen(false)
return
}
setIsAdding(true)

try {
let cursor = data.pageParams[0] as string | undefined
do {
const nextLikes = await pdsAgent.app.bsky.feed.getLikes({
uri,
cid,
cursor,
limit: 50,
})
const dids = nextLikes.data?.likes.map((l) => l.actor.did) || []
if (dids.length) await addItemsToWorkspace(dids)
cursor = nextLikes.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) {
toast.error(`Something went wrong: ${(e as Error).message}`)
}
setIsAdding(false)
setIsConfirmationOpen(false)
}

return (
<>
<AccountsGrid error="" accounts={likes.map((l) => l.actor)} />
{!!likes?.length && (
<div className="flex flex-row justify-end pt-2 mx-auto mt-2 max-w-5xl px-4 sm:px-6 lg:px-8">
<ActionButton
appearance="primary"
size="sm"
onClick={() => setIsConfirmationOpen(true)}
>
Add {pluralize(likes.length, 'user')} to workspace
</ActionButton>

<ConfirmationModal
onConfirm={() => {
if (isAdding) {
setIsAdding(false)
setIsConfirmationOpen(false)
return
}

confirmAddToWorkspace()
}}
isOpen={isConfirmationOpen}
setIsOpen={setIsConfirmationOpen}
confirmButtonText={isAdding ? 'Stop adding' : 'Yes, add all'}
title={`Add users to workspace?`}
description={
<>
Once confirmed, all the users who liked the post will be added
to the workspace. For posts with a lot of likes, this may take
quite some time but you can always stop the process and already
added users will remain in the workspace.
</>
}
/>
</div>
)}

<AccountsGrid
error={error?.['message']}
accounts={likes.map((l) => l.actor)}
/>
{hasNextPage && (
<div className="flex justify-center">
<LoadMoreButton onClick={() => fetchNextPage()} />
Expand Down
1 change: 0 additions & 1 deletion components/common/feeds/PostThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export function ThreadPost({
<PostAsCard
className="bg-transparent px-3 py-2"
item={thread}
controls={false}
dense
/>
</ThreadPostWrapper>
Expand Down
108 changes: 78 additions & 30 deletions components/common/posts/PostsFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import {
} from '@heroicons/react/24/outline'
import { LoadMore } from '../LoadMore'
import { isRepost } from '@/lib/types'
import { buildBlueSkyAppUrl, classNames, parseAtUri } from '@/lib/util'
import {
buildBlueSkyAppUrl,
classNames,
parseAtUri,
pluralize,
} from '@/lib/util'
import { getActionClassNames } from '@/reports/ModerationView/ActionHelpers'
import { RichText } from '../RichText'
import { LabelList, ModerationLabel } from '../labels'
Expand All @@ -38,6 +43,7 @@ import {
} from '@/workspace/hooks'
import { ImageList } from './ImageList'
import { useGraphicMediaPreferences } from '@/config/useLocalPreferences'
import { HandThumbUpIcon } from '@heroicons/react/24/solid'
const VideoPlayer = dynamic(() => import('@/common/video/player'), {
ssr: false,
})
Expand Down Expand Up @@ -66,17 +72,27 @@ export function PostsFeed({
)
}

export type PostControl = 'like' | 'repost' | 'view' | 'report' | 'workspace'

export const PostControlOptions = [
'like',
'repost',
'view',
'report',
'workspace',
] as const

export function PostAsCard({
item,
dense,
controls = true,
onReport,
className = '',
showLabels = true,
controls = [...PostControlOptions],
}: {
item: AppBskyFeedDefs.FeedViewPost
dense?: boolean
controls?: boolean
controls?: PostControl[]
onReport?: (uri: string) => void
className?: string
showLabels?: boolean
Expand All @@ -87,7 +103,9 @@ export function PostAsCard({
<PostContent item={item} dense={dense} />
<PostEmbeds item={item} />
{showLabels && <PostLabels item={item} dense={dense} />}
{controls && <PostControls item={item} onReport={onReport} />}
{!!controls?.length && (
<PostControls item={item} onReport={onReport} controls={controls} />
)}
</div>
)
}
Expand Down Expand Up @@ -489,50 +507,80 @@ export function RecordEmbedView({
function PostControls({
item,
onReport,
controls,
}: {
item: AppBskyFeedDefs.FeedViewPost
onReport?: (uri: string) => void
controls: PostControl[]
}) {
const { data: workspaceList } = useWorkspaceList()
const { mutate: addToWorkspace } = useWorkspaceAddItemsMutation()
const { mutate: removeFromWorkspace } = useWorkspaceRemoveItemsMutation()
const isInWorkspace = workspaceList?.includes(item.post.uri)
const recordPath = `/repositories/${item.post.uri.replace('at://', '')}`

return (
<div className="flex gap-3 pl-10">
<Link
href={`/repositories/${item.post.uri.replace('at://', '')}`}
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-50 hover:underline cursor-pointer"
>
<DocumentMagnifyingGlassIcon className="w-4 h-4" />
<span className="text-sm">View</span>
</Link>
<button
type="button"
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-50 hover:underline cursor-pointer"
onClick={() => onReport?.(item.post.uri)}
>
<ExclamationCircleIcon className="w-4 h-4" />
<span className="text-sm">Report</span>
</button>
{isInWorkspace ? (
<button
type="button"
{controls.includes('like') && (
<Link
href={`${recordPath}?tab=likes`}
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-50 hover:underline cursor-pointer"
onClick={() => removeFromWorkspace([item.post.uri])}
>
<FolderMinusIcon className="w-4 h-4" />
<span className="text-sm">Remove from workspace</span>
</button>
) : (
<span className="text-sm">
{pluralize(item.post.likeCount || 0, 'like')}
</span>
</Link>
)}
{controls.includes('repost') && (
<Link
href={`${recordPath}?tab=reposts`}
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-50 hover:underline cursor-pointer"
>
<span className="text-sm">
{pluralize(item.post.repostCount || 0, 'repost')}
</span>
</Link>
)}
{controls.includes('view') && (
<Link
href={`${recordPath}`}
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-50 hover:underline cursor-pointer"
>
<DocumentMagnifyingGlassIcon className="w-4 h-4" />
<span className="text-sm">View</span>
</Link>
)}
{controls.includes('report') && (
<button
type="button"
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-50 hover:underline cursor-pointer"
onClick={() => addToWorkspace([item.post.uri])}
onClick={() => onReport?.(item.post.uri)}
>
<FolderPlusIcon className="w-4 h-4" />
<span className="text-sm">Add to workspace</span>
<ExclamationCircleIcon className="w-4 h-4" />
<span className="text-sm">Report</span>
</button>
)}

{controls.includes('workspace') &&
(isInWorkspace ? (
<button
type="button"
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-50 hover:underline cursor-pointer"
onClick={() => removeFromWorkspace([item.post.uri])}
>
<FolderMinusIcon className="w-4 h-4" />
<span className="text-sm">Remove from workspace</span>
</button>
) : (
<button
type="button"
className="flex gap-1 items-center rounded-md pt-2 pb-1 text-gray-500 dark:text-gray-50 hover:underline cursor-pointer"
onClick={() => addToWorkspace([item.post.uri])}
>
<FolderPlusIcon className="w-4 h-4" />
<span className="text-sm">Add to workspace</span>
</button>
))}
</div>
)
}
Expand Down
Loading
Loading