diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx b/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx new file mode 100644 index 0000000000..a509404738 --- /dev/null +++ b/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx @@ -0,0 +1,116 @@ +import { usePostHog } from "posthog-js/react"; +import { + useBulkUnsubscribe, + useBulkApprove, + useBulkAutoArchive, + useBulkArchive, + useBulkDelete, +} from "@/app/(app)/bulk-unsubscribe/hooks"; +import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; +import { ButtonLoader } from "@/components/Loading"; +import { Button } from "@/components/ui/button"; +import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; + +export function BulkActions({ + selected, + mutate, +}: { + selected: Map; + mutate: () => Promise; +}) { + const posthog = usePostHog(); + const { hasUnsubscribeAccess, mutate: refetchPremium } = usePremium(); + const { PremiumModal, openModal } = usePremiumModal(); + + const { bulkUnsubscribeLoading, onBulkUnsubscribe } = useBulkUnsubscribe({ + hasUnsubscribeAccess, + mutate, + posthog, + refetchPremium, + }); + + const { bulkApproveLoading, onBulkApprove } = useBulkApprove({ + mutate, + posthog, + }); + + const { bulkAutoArchiveLoading, onBulkAutoArchive } = useBulkAutoArchive({ + hasUnsubscribeAccess, + mutate, + posthog, + refetchPremium, + }); + + const { onBulkArchive } = useBulkArchive({ mutate, posthog }); + + const { onBulkDelete } = useBulkDelete({ mutate, posthog }); + + const getSelectedValues = () => + Array.from(selected.entries()) + .filter(([_, value]) => value) + .map(([name, value]) => ({ + name, + value, + })); + + return ( + <> + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + + ); +} diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx b/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx index ec39540b95..5e5cb84dd5 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx +++ b/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeDesktop.tsx @@ -1,6 +1,7 @@ "use client"; import React from "react"; +import { ProgressBar } from "@tremor/react"; import { Table, TableBody, @@ -11,20 +12,31 @@ import { } from "@/components/ui/table"; import { ActionCell, HeaderButton } from "@/app/(app)/bulk-unsubscribe/common"; import { RowProps } from "@/app/(app)/bulk-unsubscribe/types"; -import { ProgressBar } from "@tremor/react"; +import { Checkbox } from "@/components/Checkbox"; export function BulkUnsubscribeDesktop(props: { tableRows?: React.ReactNode; sortColumn: "emails" | "unread" | "unarchived"; setSortColumn: (sortColumn: "emails" | "unread" | "unarchived") => void; + isAllSelected: boolean; + onToggleSelectAll: () => void; }) { - const { tableRows, sortColumn, setSortColumn } = props; + const { + tableRows, + sortColumn, + setSortColumn, + isAllSelected, + onToggleSelectAll, + } = props; return ( - + + + + From @@ -71,6 +83,8 @@ export function BulkUnsubscribeRowDesktop({ userGmailLabels, openPremiumModal, userEmail, + onToggleSelect, + checked, }: RowProps) { const readPercentage = (item.readEmails / item.value) * 100; const archivedEmails = item.value - item.inboxEmails; @@ -85,7 +99,13 @@ export function BulkUnsubscribeRowDesktop({ onMouseEnter={onSelectRow} onDoubleClick={onDoubleClick} > - + + onToggleSelect?.(item.name)} + /> + + {item.name} {item.value} diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx b/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx index 3af98c4f64..5d5b44d54c 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx +++ b/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeMobile.tsx @@ -3,10 +3,10 @@ import React from "react"; import Link from "next/link"; import { - useUnsubscribeButton, + useUnsubscribe, useApproveButton, - useArchiveAllButton, -} from "@/app/(app)/bulk-unsubscribe/common"; + useArchiveAll, +} from "@/app/(app)/bulk-unsubscribe/hooks"; import { Card, CardContent, @@ -58,14 +58,14 @@ export function BulkUnsubscribeRowMobile({ mutate, posthog, }); - const { unsubscribeLoading, onUnsubscribe } = useUnsubscribeButton({ + const { unsubscribeLoading, onUnsubscribe } = useUnsubscribe({ item, hasUnsubscribeAccess, mutate, posthog, refetchPremium, }); - const { archiveAllLoading, onArchiveAll } = useArchiveAllButton({ + const { archiveAllLoading, onArchiveAll } = useArchiveAll({ item, posthog, }); diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSection.tsx b/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSection.tsx index 3903020f7b..23874d71aa 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSection.tsx +++ b/apps/web/app/(app)/bulk-unsubscribe/BulkUnsubscribeSection.tsx @@ -22,7 +22,7 @@ import { usePremium } from "@/components/PremiumAlert"; import { useNewsletterFilter, useBulkUnsubscribeShortcuts, -} from "@/app/(app)/bulk-unsubscribe/common"; +} from "@/app/(app)/bulk-unsubscribe/hooks"; import BulkUnsubscribeSummary from "@/app/(app)/bulk-unsubscribe/BulkUnsubscribeSummary"; import { useStatLoader } from "@/providers/StatLoaderProvider"; import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; @@ -38,6 +38,8 @@ import { import { Card } from "@/components/ui/card"; import { ShortcutTooltip } from "@/app/(app)/bulk-unsubscribe/ShortcutTooltip"; import { SearchBar } from "@/app/(app)/bulk-unsubscribe/SearchBar"; +import { useToggleSelect } from "@/hooks/useToggleSelect"; +import { BulkActions } from "@/app/(app)/bulk-unsubscribe/BulkActions"; type Newsletter = NewsletterStatsResponse["newsletters"][number]; @@ -114,7 +116,7 @@ export function BulkUnsubscribeSection({ ? BulkUnsubscribeRowMobile : BulkUnsubscribeRowDesktop; - const tableRows = data?.newsletters + const rows = data?.newsletters .filter( search ? (item) => @@ -124,8 +126,13 @@ export function BulkUnsubscribeSection({ .includes(search.toLowerCase()) : Boolean, ) - .slice(0, expanded ? undefined : 50) - .map((item) => ( + .slice(0, expanded ? undefined : 50); + + const { selected, isAllSelected, onToggleSelect, onToggleSelectAll } = + useToggleSelect(rows?.map((item) => ({ id: item.name })) || []); + + const tableRows = rows?.map((item) => { + return ( { - setSelectedRow(item); - }} + onSelectRow={() => setSelectedRow(item)} onDoubleClick={() => onOpenNewsletter(item)} hasUnsubscribeAccess={hasUnsubscribeAccess} refetchPremium={refetchPremium} openPremiumModal={openModal} + checked={selected.get(item.name) || false} + onToggleSelect={onToggleSelect} /> - )); + ); + }); return ( <> {!isMobile && } - -
- - Bulk unsubscribe from emails - + +
+ {Array.from(selected.values()).filter(Boolean).length > 0 ? ( + + ) : ( + + Bulk unsubscribe from emails + + )} +
@@ -223,6 +236,8 @@ export function BulkUnsubscribeSection({ sortColumn={sortColumn} setSortColumn={setSortColumn} tableRows={tableRows} + isAllSelected={isAllSelected} + onToggleSelectAll={onToggleSelectAll} /> )}
{extra}
diff --git a/apps/web/app/(app)/bulk-unsubscribe/common.tsx b/apps/web/app/(app)/bulk-unsubscribe/common.tsx index a3bc8ccf70..7e828fc676 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/common.tsx +++ b/apps/web/app/(app)/bulk-unsubscribe/common.tsx @@ -1,11 +1,10 @@ "use client"; -import React, { useCallback, useState } from "react"; +import React from "react"; import clsx from "clsx"; import Link from "next/link"; import useSWR from "swr"; import type { gmail_v1 } from "googleapis"; -import { toast } from "sonner"; import { ArchiveIcon, ArchiveXIcon, @@ -26,7 +25,6 @@ import { type PostHog, usePostHog } from "posthog-js/react"; import { Button } from "@/components/ui/button"; import { ButtonLoader } from "@/components/Loading"; import { Tooltip } from "@/components/Tooltip"; -import { onAutoArchive, onDeleteFilter } from "@/utils/actions/client"; import { Separator } from "@/components/ui/separator"; import { DropdownMenu, @@ -41,8 +39,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { LabelsResponse } from "@/app/api/google/labels/route"; -import { setNewsletterStatusAction } from "@/utils/actions/unsubscriber"; -import { decrementUnsubscribeCreditAction } from "@/utils/actions/premium"; import { PremiumTooltip, PremiumTooltipContent, @@ -53,15 +49,16 @@ import type { GroupsResponse } from "@/app/api/user/group/route"; import { addGroupItemAction } from "@/utils/actions/group"; import { toastError, toastSuccess } from "@/components/Toast"; import { createFilterAction } from "@/utils/actions/mail"; -import { captureException, isActionError, isErrorMessage } from "@/utils/error"; -import type { GetThreadsResponse } from "@/app/api/google/threads/basic/route"; -import { - archiveAllSenderEmails, - deleteEmails, -} from "@/providers/QueueProvider"; -import { isDefined } from "@/utils/types"; +import { isActionError, isErrorMessage } from "@/utils/error"; import { getGmailSearchUrl } from "@/utils/url"; import { Row } from "@/app/(app)/bulk-unsubscribe/types"; +import { + useUnsubscribe, + useAutoArchive, + useApproveButton, + useArchiveAll, + useDeleteAllFromSender, +} from "@/app/(app)/bulk-unsubscribe/hooks"; export function ActionCell({ item, @@ -150,147 +147,6 @@ export function ActionCell({ ); } -export function useUnsubscribeButton({ - item, - hasUnsubscribeAccess, - mutate, - posthog, - refetchPremium, -}: { - item: T; - hasUnsubscribeAccess: boolean; - mutate: () => Promise; - posthog: PostHog; - refetchPremium: () => Promise; -}) { - const [unsubscribeLoading, setUnsubscribeLoading] = React.useState(false); - - const onUnsubscribe = useCallback(async () => { - if (!hasUnsubscribeAccess) return; - - setUnsubscribeLoading(true); - - try { - posthog.capture("Clicked Unsubscribe"); - - await setNewsletterStatusAction({ - newsletterEmail: item.name, - status: - item.status === NewsletterStatus.UNSUBSCRIBED - ? null - : NewsletterStatus.UNSUBSCRIBED, - }); - await mutate(); - await decrementUnsubscribeCreditAction(); - await refetchPremium(); - - archiveAllSenderEmails(item.name, () => {}); - } catch (error) { - captureException(error); - console.error(error); - } - - setUnsubscribeLoading(false); - }, [hasUnsubscribeAccess, item.name, mutate, posthog, refetchPremium]); - - return { - unsubscribeLoading, - onUnsubscribe, - }; -} - -export function useApproveButton({ - item, - mutate, - posthog, -}: { - item: T; - mutate: () => Promise; - posthog: PostHog; -}) { - const [approveLoading, setApproveLoading] = React.useState(false); - - const onApprove = async () => { - setApproveLoading(true); - - await setNewsletterStatusAction({ - newsletterEmail: item.name, - status: NewsletterStatus.APPROVED, - }); - await mutate(); - - posthog.capture("Clicked Approve Sender"); - - setApproveLoading(false); - }; - - return { - approveLoading, - onApprove, - }; -} - -export function useArchiveAllButton({ - item, - posthog, -}: { - item: T; - posthog: PostHog; -}) { - const [archiveAllLoading, setArchiveAllLoading] = React.useState(false); - - const onArchiveAll = async () => { - setArchiveAllLoading(true); - - posthog.capture("Clicked Archive All"); - - toast.promise( - async () => { - const data = await archiveAllSenderEmails(item.name, () => - setArchiveAllLoading(false), - ); - return data.length; - }, - { - loading: `Archiving all emails from ${item.name}`, - success: (data) => - data - ? `Archiving ${data} emails from ${item.name}...` - : `No emails to archive from ${item.name}`, - error: `There was an error archiving the emails from ${item.name} :(`, - }, - ); - }; - - return { - archiveAllLoading, - onArchiveAll, - }; -} - -export function useMoreButton({ - item, - posthog, -}: { - item: T; - posthog: PostHog; -}) { - const [moreLoading, setMoreLoading] = React.useState(false); - - const onMore = async () => { - setMoreLoading(true); - - posthog.capture("Clicked More"); - - setMoreLoading(false); - }; - - return { - moreLoading, - onMore, - }; -} - function UnsubscribeButton({ item, hasUnsubscribeAccess, @@ -304,7 +160,7 @@ function UnsubscribeButton({ posthog: PostHog; refetchPremium: () => Promise; }) { - const { unsubscribeLoading, onUnsubscribe } = useUnsubscribeButton({ + const { unsubscribeLoading, onUnsubscribe } = useUnsubscribe({ item, hasUnsubscribeAccess, mutate, @@ -357,24 +213,18 @@ function AutoArchiveButton({ refetchPremium: () => Promise; userGmailLabels: LabelsResponse["labels"]; }) { - const [autoArchiveLoading, setAutoArchiveLoading] = React.useState(false); - - const onAutoArchiveClick = useCallback(async () => { - setAutoArchiveLoading(true); - - onAutoArchive(item.name); - await setNewsletterStatusAction({ - newsletterEmail: item.name, - status: NewsletterStatus.AUTO_ARCHIVED, - }); - await mutate(); - await decrementUnsubscribeCreditAction(); - await refetchPremium(); - - posthog.capture("Clicked Auto Archive"); - - setAutoArchiveLoading(false); - }, [item.name, mutate, posthog, refetchPremium]); + const { + autoArchiveLoading, + onAutoArchive, + onAutoArchiveAndLabel, + onDisableAutoArchive, + } = useAutoArchive({ + item, + hasUnsubscribeAccess, + mutate, + posthog, + refetchPremium, + }); return (
({ } className="px-3 shadow-none" size="sm" - onClick={onAutoArchiveClick} + onClick={onAutoArchive} disabled={!hasUnsubscribeAccess} > {autoArchiveLoading && } @@ -430,18 +280,8 @@ function AutoArchiveButton({ <> { - setAutoArchiveLoading(true); - - onDeleteFilter(item.autoArchived?.id!); - await setNewsletterStatusAction({ - newsletterEmail: item.name, - status: null, - }); - await mutate(); - posthog.capture("Clicked Disable Auto Archive"); - - setAutoArchiveLoading(false); + onDisableAutoArchive(); }} > Disable Auto Archive @@ -457,20 +297,8 @@ function AutoArchiveButton({ { - setAutoArchiveLoading(true); - - onAutoArchive(item.name, label.id || undefined); - await setNewsletterStatusAction({ - newsletterEmail: item.name, - status: NewsletterStatus.AUTO_ARCHIVED, - }); - await mutate(); - await decrementUnsubscribeCreditAction(); - await refetchPremium(); - posthog.capture("Clicked Auto Archive and Label"); - - setAutoArchiveLoading(false); + await onAutoArchiveAndLabel(label.id!); }} > {label.name} @@ -537,7 +365,11 @@ export function MoreDropdown({ userGmailLabels: LabelsResponse["labels"]; posthog: PostHog; }) { - const { archiveAllLoading, onArchiveAll } = useArchiveAllButton({ + const { archiveAllLoading, onArchiveAll } = useArchiveAll({ + item, + posthog, + }); + const { deleteAllLoading, onDeleteAll } = useDeleteAllFromSender({ item, posthog, }); @@ -599,36 +431,14 @@ export function MoreDropdown({ ); if (!yes) return; - toast.promise( - async () => { - // 1. search gmail for messages from sender - const res = await fetch( - `/api/google/threads/basic?from=${item.name}`, - ); - const data: GetThreadsResponse = await res.json(); - - // 2. delete messages - if (data?.length) { - deleteEmails( - data.map((t) => t.id).filter(isDefined), - () => {}, - ); - } - - return data.length; - }, - { - loading: `Deleting all emails from ${item.name}`, - success: (data) => - data - ? `Deleting ${data} emails from ${item.name}...` - : `No emails to delete from ${item.name}`, - error: `There was an error deleting the emails from ${item.name} :(`, - }, - ); + onDeleteAll(); }} > - + {deleteAllLoading ? ( + + ) : ( + + )} Delete all @@ -658,125 +468,6 @@ export function HeaderButton(props: { ); } -export function useBulkUnsubscribeShortcuts({ - newsletters, - selectedRow, - onOpenNewsletter, - setSelectedRow, - refetchPremium, - hasUnsubscribeAccess, - mutate, -}: { - newsletters?: T[]; - selectedRow?: T; - setSelectedRow: (row: T) => void; - onOpenNewsletter: (row: T) => void; - refetchPremium: () => Promise; - hasUnsubscribeAccess: boolean; - mutate: () => Promise; -}) { - // perform actions using keyboard shortcuts - // TODO make this available to command-K dialog too - // TODO limit the copy-paste. same logic appears twice in this file - React.useEffect(() => { - const down = async (e: KeyboardEvent) => { - const item = selectedRow; - if (!item) return; - - // to prevent when typing in an input such as Crisp support - if (document?.activeElement?.tagName !== "BODY") return; - - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - const index = newsletters?.findIndex((n) => n.name === item.name); - if (index === undefined) return; - const nextItem = - newsletters?.[index + (e.key === "ArrowDown" ? 1 : -1)]; - if (!nextItem) return; - setSelectedRow(nextItem); - return; - } else if (e.key === "Enter") { - // open modal - e.preventDefault(); - onOpenNewsletter(item); - return; - } - - if (!hasUnsubscribeAccess) return; - - if (e.key === "e") { - // auto archive - e.preventDefault(); - onAutoArchive(item.name); - await setNewsletterStatusAction({ - newsletterEmail: item.name, - status: NewsletterStatus.AUTO_ARCHIVED, - }); - await mutate(); - await decrementUnsubscribeCreditAction(); - await refetchPremium(); - return; - } else if (e.key === "u") { - // unsubscribe - e.preventDefault(); - if (!item.lastUnsubscribeLink) return; - window.open(cleanUnsubscribeLink(item.lastUnsubscribeLink), "_blank"); - await setNewsletterStatusAction({ - newsletterEmail: item.name, - status: NewsletterStatus.UNSUBSCRIBED, - }); - await mutate(); - await decrementUnsubscribeCreditAction(); - await refetchPremium(); - return; - } else if (e.key === "a") { - // approve - e.preventDefault(); - await setNewsletterStatusAction({ - newsletterEmail: item.name, - status: NewsletterStatus.APPROVED, - }); - await mutate(); - return; - } - }; - document.addEventListener("keydown", down); - return () => document.removeEventListener("keydown", down); - }, [ - mutate, - newsletters, - selectedRow, - hasUnsubscribeAccess, - refetchPremium, - setSelectedRow, - onOpenNewsletter, - ]); -} - -export function useNewsletterFilter() { - const [filters, setFilters] = useState< - Record<"unhandled" | "unsubscribed" | "autoArchived" | "approved", boolean> - >({ - unhandled: true, - unsubscribed: false, - autoArchived: false, - approved: false, - }); - - return { - filters, - filtersArray: Object.entries(filters) - .filter(([, selected]) => selected) - .map(([key]) => key) as ( - | "unhandled" - | "unsubscribed" - | "autoArchived" - | "approved" - )[], - setFilters, - }; -} - function GroupsSubMenu({ sender }: { sender: string }) { const { data, isLoading, error } = useSWR(`/api/user/group`); diff --git a/apps/web/app/(app)/bulk-unsubscribe/hooks.ts b/apps/web/app/(app)/bulk-unsubscribe/hooks.ts new file mode 100644 index 0000000000..bfad6553e3 --- /dev/null +++ b/apps/web/app/(app)/bulk-unsubscribe/hooks.ts @@ -0,0 +1,544 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { type PostHog } from "posthog-js/react"; +import { onAutoArchive, onDeleteFilter } from "@/utils/actions/client"; +import { setNewsletterStatusAction } from "@/utils/actions/unsubscriber"; +import { decrementUnsubscribeCreditAction } from "@/utils/actions/premium"; +import { NewsletterStatus } from "@prisma/client"; +import { cleanUnsubscribeLink } from "@/utils/parse/parseHtml.client"; +import { captureException } from "@/utils/error"; +import { + archiveAllSenderEmails, + deleteEmails, +} from "@/providers/QueueProvider"; +import { Row } from "@/app/(app)/bulk-unsubscribe/types"; +import { GetThreadsResponse } from "@/app/api/google/threads/basic/route"; +import { isDefined } from "@/utils/types"; + +async function unsubscribeAndArchive( + newsletterEmail: string, + mutate: () => Promise, + refetchPremium: () => Promise, +) { + await setNewsletterStatusAction({ + newsletterEmail, + status: NewsletterStatus.UNSUBSCRIBED, + }); + await mutate(); + await decrementUnsubscribeCreditAction(); + await refetchPremium(); + await archiveAllSenderEmails(newsletterEmail, () => {}); +} + +export function useUnsubscribe({ + item, + hasUnsubscribeAccess, + mutate, + posthog, + refetchPremium, +}: { + item: T; + hasUnsubscribeAccess: boolean; + mutate: () => Promise; + posthog: PostHog; + refetchPremium: () => Promise; +}) { + const [unsubscribeLoading, setUnsubscribeLoading] = React.useState(false); + + const onUnsubscribe = useCallback(async () => { + if (!hasUnsubscribeAccess) return; + + setUnsubscribeLoading(true); + + try { + posthog.capture("Clicked Unsubscribe"); + + if (item.status === NewsletterStatus.UNSUBSCRIBED) { + await setNewsletterStatusAction({ + newsletterEmail: item.name, + status: null, + }); + await mutate(); + } else { + await unsubscribeAndArchive(item.name, mutate, refetchPremium); + } + } catch (error) { + captureException(error); + console.error(error); + } + + setUnsubscribeLoading(false); + }, [hasUnsubscribeAccess, item.name, mutate, posthog, refetchPremium]); + + return { + unsubscribeLoading, + onUnsubscribe, + }; +} + +export function useBulkUnsubscribe({ + hasUnsubscribeAccess, + mutate, + posthog, + refetchPremium, +}: { + hasUnsubscribeAccess: boolean; + mutate: () => Promise; + posthog: PostHog; + refetchPremium: () => Promise; +}) { + const [bulkUnsubscribeLoading, setBulkUnsubscribeLoading] = + React.useState(false); + + const onBulkUnsubscribe = useCallback( + async (items: T[]) => { + if (!hasUnsubscribeAccess) return; + + setBulkUnsubscribeLoading(true); + + try { + posthog.capture("Clicked Bulk Unsubscribe"); + + for (const item of items) { + try { + await unsubscribeAndArchive(item.name, mutate, refetchPremium); + } catch (error) { + captureException(error); + console.error(error); + } + } + } catch (error) { + captureException(error); + console.error(error); + } + + setBulkUnsubscribeLoading(false); + }, + [hasUnsubscribeAccess, mutate, posthog, refetchPremium], + ); + + return { + bulkUnsubscribeLoading, + onBulkUnsubscribe, + }; +} + +async function autoArchive( + name: string, + labelId: string | undefined, + mutate: () => Promise, + refetchPremium: () => Promise, +) { + await onAutoArchive(name, labelId); + await setNewsletterStatusAction({ + newsletterEmail: name, + status: NewsletterStatus.AUTO_ARCHIVED, + }); + await mutate(); + await decrementUnsubscribeCreditAction(); + await refetchPremium(); + await archiveAllSenderEmails(name, () => {}); +} + +export function useAutoArchive({ + item, + hasUnsubscribeAccess, + mutate, + posthog, + refetchPremium, +}: { + item: T; + hasUnsubscribeAccess: boolean; + mutate: () => Promise; + posthog: PostHog; + refetchPremium: () => Promise; +}) { + const [autoArchiveLoading, setAutoArchiveLoading] = React.useState(false); + + const onAutoArchiveClick = useCallback(async () => { + if (!hasUnsubscribeAccess) return; + + setAutoArchiveLoading(true); + + await autoArchive(item.name, undefined, mutate, refetchPremium); + + posthog.capture("Clicked Auto Archive"); + + setAutoArchiveLoading(false); + }, [item.name, mutate, posthog, refetchPremium]); + + const onDisableAutoArchive = useCallback(async () => { + setAutoArchiveLoading(true); + + await onDeleteFilter(item.autoArchived?.id!); + await setNewsletterStatusAction({ + newsletterEmail: item.name, + status: null, + }); + await mutate(); + }, [item.name, mutate, posthog, refetchPremium]); + + const onAutoArchiveAndLabel = useCallback( + async (labelId: string) => { + if (!hasUnsubscribeAccess) return; + + setAutoArchiveLoading(true); + + await autoArchive(item.name, labelId, mutate, refetchPremium); + + setAutoArchiveLoading(false); + }, + [item.name, mutate, posthog, refetchPremium], + ); + + return { + autoArchiveLoading, + onAutoArchive: onAutoArchiveClick, + onDisableAutoArchive, + onAutoArchiveAndLabel, + }; +} + +export function useBulkAutoArchive({ + hasUnsubscribeAccess, + mutate, + posthog, + refetchPremium, +}: { + hasUnsubscribeAccess: boolean; + mutate: () => Promise; + posthog: PostHog; + refetchPremium: () => Promise; +}) { + const [bulkAutoArchiveLoading, setBulkAutoArchiveLoading] = + React.useState(false); + + const onBulkAutoArchive = useCallback( + async (items: T[]) => { + if (!hasUnsubscribeAccess) return; + + setBulkAutoArchiveLoading(true); + + for (const item of items) { + await autoArchive(item.name, undefined, mutate, refetchPremium); + } + + setBulkAutoArchiveLoading(false); + }, + [hasUnsubscribeAccess, mutate, posthog, refetchPremium], + ); + + return { + bulkAutoArchiveLoading, + onBulkAutoArchive, + }; +} + +export function useApproveButton({ + item, + mutate, + posthog, +}: { + item: T; + mutate: () => Promise; + posthog: PostHog; +}) { + const [approveLoading, setApproveLoading] = React.useState(false); + + const onApprove = async () => { + setApproveLoading(true); + + await setNewsletterStatusAction({ + newsletterEmail: item.name, + status: NewsletterStatus.APPROVED, + }); + await mutate(); + + posthog.capture("Clicked Approve Sender"); + + setApproveLoading(false); + }; + + return { + approveLoading, + onApprove, + }; +} + +export function useBulkApprove({ + mutate, + posthog, +}: { + mutate: () => Promise; + posthog: PostHog; +}) { + const [bulkApproveLoading, setBulkApproveLoading] = React.useState(false); + + const onBulkApprove = async (items: T[]) => { + setBulkApproveLoading(true); + + posthog.capture("Clicked Bulk Approve"); + + for (const item of items) { + await setNewsletterStatusAction({ + newsletterEmail: item.name, + status: NewsletterStatus.APPROVED, + }); + await mutate(); + } + + setBulkApproveLoading(false); + }; + + return { + bulkApproveLoading, + onBulkApprove, + }; +} + +async function archiveAll(name: string, onFinish: () => void) { + toast.promise( + async () => { + const data = await archiveAllSenderEmails(name, onFinish); + return data.length; + }, + { + loading: `Archiving all emails from ${name}`, + success: (data) => + data + ? `Archiving ${data} emails from ${name}...` + : `No emails to archive from ${name}`, + error: `There was an error archiving the emails from ${name} :(`, + }, + ); +} + +export function useArchiveAll({ + item, + posthog, +}: { + item: T; + posthog: PostHog; +}) { + const [archiveAllLoading, setArchiveAllLoading] = React.useState(false); + + const onArchiveAll = async () => { + setArchiveAllLoading(true); + + posthog.capture("Clicked Archive All"); + + await archiveAll(item.name, () => setArchiveAllLoading(false)); + + setArchiveAllLoading(false); + }; + + return { + archiveAllLoading, + onArchiveAll, + }; +} + +export function useBulkArchive({ + mutate, + posthog, +}: { + mutate: () => Promise; + posthog: PostHog; +}) { + const onBulkArchive = async (items: T[]) => { + posthog.capture("Clicked Bulk Archive"); + + for (const item of items) { + await archiveAll(item.name, mutate); + } + }; + + return { onBulkArchive }; +} + +async function deleteAllFromSender(name: string, onFinish: () => void) { + toast.promise( + async () => { + // 1. search gmail for messages from sender + const res = await fetch(`/api/google/threads/basic?from=${name}`); + const data: GetThreadsResponse = await res.json(); + + // 2. delete messages + if (data?.length) { + deleteEmails(data.map((t) => t.id).filter(isDefined), onFinish); + } + + return data.length; + }, + { + loading: `Deleting all emails from ${name}`, + success: (data) => + data + ? `Deleting ${data} emails from ${name}...` + : `No emails to delete from ${name}`, + error: `There was an error deleting the emails from ${name} :(`, + }, + ); +} + +export function useDeleteAllFromSender({ + item, + posthog, +}: { + item: T; + posthog: PostHog; +}) { + const [deleteAllLoading, setDeleteAllLoading] = React.useState(false); + + const onDeleteAll = async () => { + setDeleteAllLoading(true); + + posthog.capture("Clicked Delete All"); + + await deleteAllFromSender(item.name, () => setDeleteAllLoading(false)); + }; + + return { + deleteAllLoading, + onDeleteAll, + }; +} + +export function useBulkDelete({ + mutate, + posthog, +}: { + mutate: () => Promise; + posthog: PostHog; +}) { + const onBulkDelete = async (items: T[]) => { + posthog.capture("Clicked Bulk Delete"); + + for (const item of items) { + await deleteAllFromSender(item.name, mutate); + } + }; + + return { onBulkDelete }; +} + +export function useBulkUnsubscribeShortcuts({ + newsletters, + selectedRow, + onOpenNewsletter, + setSelectedRow, + refetchPremium, + hasUnsubscribeAccess, + mutate, +}: { + newsletters?: T[]; + selectedRow?: T; + setSelectedRow: (row: T) => void; + onOpenNewsletter: (row: T) => void; + refetchPremium: () => Promise; + hasUnsubscribeAccess: boolean; + mutate: () => Promise; +}) { + // perform actions using keyboard shortcuts + // TODO make this available to command-K dialog too + // TODO limit the copy-paste. same logic appears twice in this file + React.useEffect(() => { + const down = async (e: KeyboardEvent) => { + const item = selectedRow; + if (!item) return; + + // to prevent when typing in an input such as Crisp support + if (document?.activeElement?.tagName !== "BODY") return; + + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const index = newsletters?.findIndex((n) => n.name === item.name); + if (index === undefined) return; + const nextItem = + newsletters?.[index + (e.key === "ArrowDown" ? 1 : -1)]; + if (!nextItem) return; + setSelectedRow(nextItem); + return; + } else if (e.key === "Enter") { + // open modal + e.preventDefault(); + onOpenNewsletter(item); + return; + } + + if (!hasUnsubscribeAccess) return; + + if (e.key === "e") { + // auto archive + e.preventDefault(); + onAutoArchive(item.name); + await setNewsletterStatusAction({ + newsletterEmail: item.name, + status: NewsletterStatus.AUTO_ARCHIVED, + }); + await mutate(); + await decrementUnsubscribeCreditAction(); + await refetchPremium(); + return; + } else if (e.key === "u") { + // unsubscribe + e.preventDefault(); + if (!item.lastUnsubscribeLink) return; + window.open(cleanUnsubscribeLink(item.lastUnsubscribeLink), "_blank"); + await setNewsletterStatusAction({ + newsletterEmail: item.name, + status: NewsletterStatus.UNSUBSCRIBED, + }); + await mutate(); + await decrementUnsubscribeCreditAction(); + await refetchPremium(); + return; + } else if (e.key === "a") { + // approve + e.preventDefault(); + await setNewsletterStatusAction({ + newsletterEmail: item.name, + status: NewsletterStatus.APPROVED, + }); + await mutate(); + return; + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [ + mutate, + newsletters, + selectedRow, + hasUnsubscribeAccess, + refetchPremium, + setSelectedRow, + onOpenNewsletter, + ]); +} + +export function useNewsletterFilter() { + const [filters, setFilters] = useState< + Record<"unhandled" | "unsubscribed" | "autoArchived" | "approved", boolean> + >({ + unhandled: true, + unsubscribed: false, + autoArchived: false, + approved: false, + }); + + return { + filters, + filtersArray: Object.entries(filters) + .filter(([, selected]) => selected) + .map(([key]) => key) as ( + | "unhandled" + | "unsubscribed" + | "autoArchived" + | "approved" + )[], + setFilters, + }; +} diff --git a/apps/web/app/(app)/bulk-unsubscribe/types.ts b/apps/web/app/(app)/bulk-unsubscribe/types.ts index c27aafb279..4911d9e5c0 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/types.ts +++ b/apps/web/app/(app)/bulk-unsubscribe/types.ts @@ -23,4 +23,6 @@ export interface RowProps { hasUnsubscribeAccess: boolean; refetchPremium: () => Promise; openPremiumModal: () => void; + checked: boolean; + onToggleSelect: (id: string) => void; } diff --git a/apps/web/app/(app)/new-senders/NewSenders.tsx b/apps/web/app/(app)/new-senders/NewSenders.tsx index 600b73d1b8..2869469993 100644 --- a/apps/web/app/(app)/new-senders/NewSenders.tsx +++ b/apps/web/app/(app)/new-senders/NewSenders.tsx @@ -18,12 +18,11 @@ import type { import { formatShortDate } from "@/utils/date"; import { formatStat } from "@/utils/stats"; import { StatsCards } from "@/components/StatsCards"; +import { ActionCell, HeaderButton } from "@/app/(app)/bulk-unsubscribe/common"; import { useNewsletterFilter, useBulkUnsubscribeShortcuts, - ActionCell, - HeaderButton, -} from "@/app/(app)/bulk-unsubscribe/common"; +} from "@/app/(app)/bulk-unsubscribe/hooks"; import { DetailedStatsFilter } from "@/app/(app)/stats/DetailedStatsFilter"; import type { LabelsResponse } from "@/app/api/google/labels/route"; import { usePremium } from "@/components/PremiumAlert"; diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index 435dc89344..ab503e8096 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -31,6 +31,7 @@ async function executeGmailAction( user: { id: string; email: string }, ) => Promise, errorMessage: string, + onError?: (error: unknown) => boolean, // returns true if error was handled ): Promise> { const { gmail, user, error } = await getSessionAndGmailClient(); if (error) return { error }; @@ -40,6 +41,7 @@ async function executeGmailAction( const res = await action(gmail, user); return !isStatusOk(res.status) ? handleError(res, errorMessage) : undefined; } catch (error) { + if (onError?.(error)) return; return handleError(error, errorMessage); } } @@ -119,6 +121,11 @@ export async function createAutoArchiveFilterAction( return executeGmailAction( async (gmail) => createAutoArchiveFilter({ gmail, from, gmailLabelId }), "Failed to create auto archive filter", + (error) => { + const errorMessage = (error as any)?.errors?.[0]?.message; + if (errorMessage === "Filter already exists") return true; + return false; + }, ); }