diff --git a/.vscode/settings.json b/.vscode/settings.json index cbf1c2eecd..48d5a5d50a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.preferences.importModuleSpecifier": "non-relative", "[typescript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/apps/web/.env.example b/apps/web/.env.example index 1a054c7c84..d263537a4f 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -3,6 +3,7 @@ DIRECT_URL="postgresql://postgres:password@localhost:5432/inboxzero?schema=publi # Generate a random secret here: https://generate-secret.vercel.app/32 NEXTAUTH_SECRET= +NEXTAUTH_URL=http://localhost:3000 GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= @@ -17,6 +18,9 @@ BEDROCK_REGION=us-west-2 UPSTASH_REDIS_URL="http://localhost:8079" # Generate a random secret here: https://generate-secret.vercel.app/32 UPSTASH_REDIS_TOKEN= +QSTASH_TOKEN= +QSTASH_CURRENT_SIGNING_KEY= +QSTASH_NEXT_SIGNING_KEY= TINYBIRD_TOKEN= TINYBIRD_BASE_URL=https://api.us-east.tinybird.co/ diff --git a/apps/web/app/(app)/bulk-unsubscribe/ArchiveProgress.tsx b/apps/web/app/(app)/bulk-unsubscribe/ArchiveProgress.tsx index 99e4b805c2..bdc312ef52 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/ArchiveProgress.tsx +++ b/apps/web/app/(app)/bulk-unsubscribe/ArchiveProgress.tsx @@ -1,10 +1,8 @@ "use client"; import { memo, useEffect } from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { ProgressBar } from "@tremor/react"; import { resetTotalThreads, useQueueState } from "@/store/archive-queue"; -import { cn } from "@/utils"; +import { ProgressPanel } from "@/components/ProgressPanel"; export const ArchiveProgress = memo(() => { const { totalThreads, activeThreads } = useQueueState(); @@ -23,37 +21,13 @@ export const ArchiveProgress = memo(() => { } }, [isCompleted]); - if (!totalThreads) return null; - return ( -
- - - -

- - {isCompleted ? "Archiving complete!" : "Archiving emails..."} - - - {totalProcessed} of {totalThreads} emails archived - -

-
-
-
+ ); }); diff --git a/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx b/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx index 36be555696..d61efc96bc 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx +++ b/apps/web/app/(app)/bulk-unsubscribe/BulkActions.tsx @@ -42,7 +42,6 @@ export function BulkActions({ const { bulkAutoArchiveLoading, onBulkAutoArchive } = useBulkAutoArchive({ hasUnsubscribeAccess, mutate, - posthog, refetchPremium, }); diff --git a/apps/web/app/(app)/bulk-unsubscribe/hooks.ts b/apps/web/app/(app)/bulk-unsubscribe/hooks.ts index 315a62234f..0203b5315a 100644 --- a/apps/web/app/(app)/bulk-unsubscribe/hooks.ts +++ b/apps/web/app/(app)/bulk-unsubscribe/hooks.ts @@ -181,7 +181,9 @@ export function useAutoArchive({ const onDisableAutoArchive = useCallback(async () => { setAutoArchiveLoading(true); - await onDeleteFilter(item.autoArchived?.id!); + if (item.autoArchived?.id) { + await onDeleteFilter(item.autoArchived?.id); + } await setNewsletterStatusAction({ newsletterEmail: item.name, status: null, @@ -215,12 +217,10 @@ export function useAutoArchive({ export function useBulkAutoArchive({ hasUnsubscribeAccess, mutate, - posthog, refetchPremium, }: { hasUnsubscribeAccess: boolean; mutate: () => Promise; - posthog: PostHog; refetchPremium: () => Promise; }) { const [bulkAutoArchiveLoading, setBulkAutoArchiveLoading] = @@ -257,6 +257,13 @@ export function useApproveButton({ posthog: PostHog; }) { const [approveLoading, setApproveLoading] = React.useState(false); + const { onDisableAutoArchive } = useAutoArchive({ + item, + hasUnsubscribeAccess: true, + mutate, + posthog, + refetchPremium: () => Promise.resolve(), + }); const onApprove = async () => { setApproveLoading(true); @@ -265,6 +272,7 @@ export function useApproveButton({ newsletterEmail: item.name, status: NewsletterStatus.APPROVED, }); + await onDisableAutoArchive(); await mutate(); posthog.capture("Clicked Approve Sender"); diff --git a/apps/web/app/(app)/smart-categories/CategorizeProgress.tsx b/apps/web/app/(app)/smart-categories/CategorizeProgress.tsx new file mode 100644 index 0000000000..fc9f241f67 --- /dev/null +++ b/apps/web/app/(app)/smart-categories/CategorizeProgress.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { useEffect } from "react"; +import { atom, useAtom } from "jotai"; +import useSWR from "swr"; +import { ProgressPanel } from "@/components/ProgressPanel"; +import type { CategorizeProgress } from "@/app/api/user/categorize/senders/progress/route"; + +const isCategorizeInProgressAtom = atom(false); + +export function useCategorizeProgress() { + const [isBulkCategorizing, setIsBulkCategorizing] = useAtom( + isCategorizeInProgressAtom, + ); + return { isBulkCategorizing, setIsBulkCategorizing }; +} + +export function CategorizeSendersProgress({ + refresh = false, +}: { + refresh: boolean; +}) { + const { isBulkCategorizing } = useCategorizeProgress(); + const { data } = useSWR( + "/api/user/categorize/senders/progress", + { + refreshInterval: refresh || isBulkCategorizing ? 1_000 : undefined, + }, + ); + + // If the categorization is complete, wait 3 seconds and then set isBulkCategorizing to false + const { setIsBulkCategorizing } = useCategorizeProgress(); + useEffect(() => { + let timeoutId: NodeJS.Timeout | undefined; + if (data?.completedItems === data?.totalItems) { + timeoutId = setTimeout(() => { + setIsBulkCategorizing(false); + }, 3_000); + } + return () => { + if (timeoutId) clearTimeout(timeoutId); + }; + }, [data?.completedItems, data?.totalItems, setIsBulkCategorizing]); + + if (!data) return null; + + const totalItems = data.totalItems || 0; + const completedItems = data.completedItems || 0; + + return ( + + ); +} diff --git a/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx b/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx index 8c1a47a1fb..bae858bc53 100644 --- a/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx +++ b/apps/web/app/(app)/smart-categories/CategorizeWithAiButton.tsx @@ -4,17 +4,25 @@ import { useState } from "react"; import { SparklesIcon } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { categorizeSendersAction } from "@/utils/actions/categorize"; +import { bulkCategorizeSendersAction } from "@/utils/actions/categorize"; import { handleActionCall } from "@/utils/server-action"; import { isActionError } from "@/utils/error"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; +import type { ButtonProps } from "@/components/ui/button"; +import { useCategorizeProgress } from "@/app/(app)/smart-categories/CategorizeProgress"; -export function CategorizeWithAiButton() { +export function CategorizeWithAiButton({ + buttonProps, +}: { + buttonProps?: ButtonProps; +}) { const [isCategorizing, setIsCategorizing] = useState(false); const { hasAiAccess } = usePremium(); const { PremiumModal, openModal: openPremiumModal } = usePremiumModal(); + const { setIsBulkCategorizing } = useCategorizeProgress(); + return ( <> @@ -27,9 +35,10 @@ export function CategorizeWithAiButton() { toast.promise( async () => { setIsCategorizing(true); + setIsBulkCategorizing(true); const result = await handleActionCall( - "categorizeSendersAction", - categorizeSendersAction, + "bulkCategorizeSendersAction", + bulkCategorizeSendersAction, ); if (isActionError(result)) { @@ -42,9 +51,11 @@ export function CategorizeWithAiButton() { return result; }, { - loading: "Categorizing senders...", - success: () => { - return "Senders categorized successfully!"; + loading: "Categorizing senders... This might take a while.", + success: ({ totalUncategorizedSenders }) => { + return totalUncategorizedSenders + ? `Categorizing ${totalUncategorizedSenders} senders...` + : "There are no more senders to categorize."; }, error: (err) => { return `Error categorizing senders: ${err.message}`; @@ -52,9 +63,14 @@ export function CategorizeWithAiButton() { }, ); }} + {...buttonProps} > - - Categorize Senders with AI + {buttonProps?.children || ( + <> + + Categorize Senders with AI + + )} diff --git a/apps/web/app/(app)/smart-categories/Uncategorized.tsx b/apps/web/app/(app)/smart-categories/Uncategorized.tsx index aaa8ae47a8..4aa1a4950c 100644 --- a/apps/web/app/(app)/smart-categories/Uncategorized.tsx +++ b/apps/web/app/(app)/smart-categories/Uncategorized.tsx @@ -1,13 +1,8 @@ "use client"; import useSWRInfinite from "swr/infinite"; -import { useMemo, useCallback, useState } from "react"; -import { - ChevronsDownIcon, - SparklesIcon, - StopCircleIcon, - ZapIcon, -} from "lucide-react"; +import { useMemo, useCallback } from "react"; +import { ChevronsDownIcon, SparklesIcon, StopCircleIcon } from "lucide-react"; import { useSession } from "next-auth/react"; import { ClientOnly } from "@/components/ClientOnly"; import { SendersTable } from "@/components/GroupedTable"; @@ -16,7 +11,7 @@ import { Button } from "@/components/ui/button"; import type { UncategorizedSendersResponse } from "@/app/api/user/categorize/senders/uncategorized/route"; import type { Category } from "@prisma/client"; import { TopBar } from "@/components/TopBar"; -import { toastError, toastSuccess } from "@/components/Toast"; +import { toastError } from "@/components/Toast"; import { useHasProcessingItems, pushToAiCategorizeSenderQueueAtom, @@ -27,14 +22,8 @@ import { ButtonLoader } from "@/components/Loading"; import { PremiumTooltip, usePremium } from "@/components/PremiumAlert"; import { usePremiumModal } from "@/app/(app)/premium/PremiumModal"; import { Toggle } from "@/components/Toggle"; -import { - fastCategorizeSendersAction, - setAutoCategorizeAction, -} from "@/utils/actions/categorize"; +import { setAutoCategorizeAction } from "@/utils/actions/categorize"; import { TooltipExplanation } from "@/components/TooltipExplanation"; -import { isActionError } from "@/utils/error"; - -type FastCategorizeResults = Record; export function Uncategorized({ categories, @@ -48,23 +37,13 @@ export function Uncategorized({ const { data: senderAddresses, loadMore, isLoading, hasMore } = useSenders(); const hasProcessingItems = useHasProcessingItems(); - const [isFastCategorizing, setIsFastCategorizing] = useState(false); - const [fastCategorizeResult, setFastCategorizeResult] = - useState(null); const senders = useMemo( () => senderAddresses?.map((address) => { - const fastCategorization = fastCategorizeResult?.[address]; - - if (!fastCategorization) return { address, category: null }; - - const category = - categories.find((c) => c.name === fastCategorization) || null; - - return { address, category }; + return { address, category: null }; }), - [senderAddresses, fastCategorizeResult, categories], + [senderAddresses], ); const session = useSession(); @@ -80,7 +59,7 @@ export function Uncategorized({ > - - - - {hasProcessingItems && (