diff --git a/apps/mail/app/(auth)/login/early-access/page.tsx b/apps/mail/app/(auth)/login/early-access/page.tsx deleted file mode 100644 index b8115c93fa..0000000000 --- a/apps/mail/app/(auth)/login/early-access/page.tsx +++ /dev/null @@ -1,427 +0,0 @@ -'use client'; - -import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form'; -import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { AnimatePresence, motion } from 'motion/react'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useState, useEffect, Suspense } from 'react'; -import { Button } from '@/components/ui/button'; -import { ArrowLeft, Check } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { useForm } from 'react-hook-form'; -import confetti from 'canvas-confetti'; -import { cn } from '@/lib/utils'; -import Image from 'next/image'; -import { toast } from 'sonner'; -import Link from 'next/link'; -import { z } from 'zod'; - -const formSchema = z.object({ - name: z.string().min(1, { message: 'Name must be at least 1 character' }), - email: z - .string() - .min(1, { message: 'Username is required' }) - .refine((value) => !value.includes('@'), { message: 'Username should not include @ symbol' }), - earlyAccessEmail: z.string().email({ message: 'Invalid early access email address' }), - password: z.string().min(6, { message: 'Password must be at least 6 characters' }), -}); - -// Add this component to safely use useSearchParams -function EarlyAccessContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - - // Get current step from URL or default to 'claim' - const currentStep = searchParams.get('step') || 'claim'; - - const [showVerification, setShowVerification] = useState( - currentStep === 'verify' || currentStep === 'success', - ); - const [verified, setVerified] = useState(currentStep === 'success'); - const [verificationCode, setVerificationCode] = useState(''); - const [verificationError, setVerificationError] = useState(false); - const [userEmail, setUserEmail] = useState(searchParams.get('email') || ''); - - // Update URL when step changes - useEffect(() => { - const params = new URLSearchParams(searchParams.toString()); - - // Set the appropriate step - if (verified) { - params.set('step', 'success'); - } else if (showVerification) { - params.set('step', 'verify'); - } else { - params.set('step', 'claim'); - } - - // Add email to URL if we have it - if (userEmail) { - params.set('email', userEmail); - } - - const newUrl = `?${params.toString()}`; - router.replace(newUrl, { scroll: false }); - }, [showVerification, verified, userEmail, router, searchParams]); - - // Trigger confetti when verified changes to true - useEffect(() => { - if (verified) { - const duration = 3 * 1000; - const animationEnd = Date.now() + duration; - - const randomInRange = (min: number, max: number) => { - return Math.random() * (max - min) + min; - }; - - const interval = setInterval(() => { - const timeLeft = animationEnd - Date.now(); - - if (timeLeft <= 0) { - return clearInterval(interval); - } - - const particleCount = 50 * (timeLeft / duration); - - // since particles fall down, start a bit higher than random - confetti({ - particleCount, - startVelocity: 30, - spread: 360, - origin: { - x: randomInRange(0.1, 0.9), - y: randomInRange(0, 0.2), - }, - }); - }, 250); - - return () => clearInterval(interval); - } - }, [verified]); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: searchParams.get('email') || '', - password: '', - }, - }); - - function onSubmit(values: z.infer) { - // Append the @0.email suffix to the username - const fullEmail = `${values.email}@0.email`; - setUserEmail(fullEmail); - - // Use the correct sonner toast API - toast.success('Signup successful, please verify your email'); - - // Show verification screen - setShowVerification(true); - // URL will be updated in the useEffect - } - - // Handle form errors with toast notifications - const onError = (errors: any) => { - // Define error messages mapping - const errorMessageMap = [ - // Email errors - { - field: 'email', - pattern: '@ symbol', - message: 'Zero email has to have only letters (a-z), numbers (0-9), and periods (.).', - }, - { - field: 'email', - pattern: 'required', - message: 'Username is required', - }, - - // Password errors - { - field: 'password', - pattern: 'at least', - message: 'Password must be at least 6 characters', - }, - { - field: 'password', - pattern: '', - message: 'Password is required', - }, - - // Name errors - { - field: 'name', - pattern: '', - message: 'Name is required', - }, - - // Early access email errors - { - field: 'earlyAccessEmail', - pattern: 'Invalid', - message: 'Invalid early access email address', - }, - { - field: 'earlyAccessEmail', - pattern: '', - message: 'Early access email is required', - }, - ]; - - // Find the first matching error and show toast - for (const [field, fieldError] of Object.entries(errors)) { - const errorMessage = (fieldError as { message?: string })?.message || ''; - - // Find matching error pattern - const matchedError = errorMessageMap.find( - (mapping) => - mapping.field === field && - (mapping.pattern === '' || errorMessage.includes(mapping.pattern)), - ); - - if (matchedError) { - toast.error(matchedError.message); - return; - } - } - - // Fallback for any other errors - toast.error('Please fix the form errors'); - }; - - function handleVerify() { - if (verificationCode.length === 6) { - setVerified(true); - setVerificationError(false); - toast.success('Email verified successfully!'); - // Redirect to /mail after verification - router.push('/mail'); - // URL will be updated in the useEffect - } else { - setVerificationError(true); - toast.error('Please enter a valid 6-digit code'); - } - } - - return ( -
- - {currentStep === 'claim' ? ( - // Claim screen (signup form) - -
-

Claim your email

-

Enter your email below to claim your account

-
- -
- - ( - - Early access email - - - - - )} - /> - - ( - - Name - - - - - )} - /> - - ( - - Zero Email - -
- { - // Just update the field value without showing toast errors - const value = e.target.value; - field.onChange(value); - }} - error={!!fieldState.error} - className="w-full bg-black pr-16 text-sm text-white placeholder:text-sm" - /> - - @0.email - -
-
-
- )} - /> - - ( - -
- Password -
- - - -
- )} - /> - - - - -
- ) : ( - // Verification screen - -
-

Verify your email

-

Enter the 6-digit code sent to your email

-
- -
- { - setVerificationCode(value); - // Clear error when user starts typing again - if (verificationError) { - setVerificationError(false); - } - }} - className="justify-center gap-2" - > - - - - - - - - - - - - -

- Didn't receive a code?{' '} - -

-
-
- )} -
-
- ); -} - -// Main component with Suspense boundary -export default function EarlyAccess() { - return ( - -
Loading...
- - } - > - -
- ); -} diff --git a/apps/mail/app/(routes)/mail/[folder]/page.tsx b/apps/mail/app/(routes)/mail/[folder]/page.tsx index a7168d1158..c088acc576 100644 --- a/apps/mail/app/(routes)/mail/[folder]/page.tsx +++ b/apps/mail/app/(routes)/mail/[folder]/page.tsx @@ -14,7 +14,7 @@ interface MailPageProps { const ALLOWED_FOLDERS = ['inbox', 'draft', 'sent', 'spam', 'bin', 'archive']; -export default async function MailPage({ params, searchParams }: MailPageProps) { +export default async function MailPage({ params }: MailPageProps) { const headersList = await headers(); const session = await auth.api.getSession({ headers: headersList }); diff --git a/apps/mail/app/api/driver/google.ts b/apps/mail/app/api/driver/google.ts index 6d816c07f8..6e27f242ba 100644 --- a/apps/mail/app/api/driver/google.ts +++ b/apps/mail/app/api/driver/google.ts @@ -164,6 +164,7 @@ export const driver = async (config: IConfig): Promise => { return { id: id || 'ERROR', + bcc: [], threadId: threadId || '', title: snippet ? he.decode(snippet).trim() : 'ERROR', tls: wasSentWithTLS(receivedHeaders) || !!hasTLSReport, @@ -479,14 +480,7 @@ export const driver = async (config: IConfig): Promise => { const { folder: normalizedFolder, q: normalizedQ } = normalizeSearch(folder, q ?? ''); const labelIds = [..._labelIds]; if (normalizedFolder) labelIds.push(normalizedFolder.toUpperCase()); - console.log({ - folder, - userId: 'me', - q: normalizedQ ? normalizedQ : undefined, - labelIds: folder === 'inbox' ? labelIds : [], - maxResults, - pageToken: pageToken ? pageToken : undefined, - }) + const res = await gmail.users.threads.list({ userId: 'me', q: normalizedQ ? normalizedQ : undefined, diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index c31d6fb646..4562368914 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -16,7 +16,7 @@ import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef } fr import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { EmptyState, type FolderType } from '@/components/mail/empty-state'; import { ThreadContextMenu } from '@/components/context/thread-context'; -import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { cn, FOLDERS, formatDate, getEmailLogo } from '@/lib/utils'; import { Avatar, AvatarImage, AvatarFallback } from '../ui/avatar'; import { useMailNavigation } from '@/hooks/use-mail-navigation'; @@ -84,17 +84,17 @@ const Thread = memo( const [mail] = useMail(); const [searchValue] = useSearchValue(); const t = useTranslations(); - const searchParams = useSearchParams(); const { folder } = useParams<{ folder: string }>(); const { mutate } = useThreads(); - const threadIdParam = searchParams.get('threadId'); + const [threadId, setThreadId] = useQueryState('threadId'); const hoverTimeoutRef = useRef | undefined>(undefined); const isHovering = useRef(false); const hasPrefetched = useRef(false); const isMailSelected = useMemo(() => { - const threadId = message.threadId ?? message.id; - return threadId === threadIdParam || threadId === mail.selected; - }, [message.id, message.threadId, threadIdParam, mail.selected]); + if (!threadId) return false; + const _threadId = message.threadId ?? message.id; + return _threadId === threadId || threadId === mail.selected; + }, [threadId, message.id, message.threadId, mail.selected]); const isMailBulkSelected = mail.bulkSelected.includes(message.threadId ?? message.id); @@ -194,7 +194,7 @@ const Thread = memo( 'text-md flex items-baseline gap-1 group-hover:opacity-100', )} > - + {highlightText(message.sender.name, searchValue.highlight)} {' '} {message.unread && !isMailSelected ? ( @@ -281,7 +281,7 @@ const Thread = memo( )} > {highlightText(message.sender.name, searchValue.highlight)} {' '} @@ -377,7 +377,6 @@ export const MailList = memo(({ isCompact }: MailListProps) => { const [mail, setMail] = useMail(); const { data: session } = useSession(); const t = useTranslations(); - const searchParams = useSearchParams(); const router = useRouter(); const [threadId, setThreadId] = useQueryState('threadId'); const [category, setCategory] = useQueryState('category'); @@ -435,11 +434,9 @@ export const MailList = memo(({ isCompact }: MailListProps) => { const handleNavigateToThread = useCallback( (threadId: string) => { - const currentParams = new URLSearchParams(searchParams.toString()); - currentParams.set('threadId', threadId); - router.push(`/mail/${folder}?${currentParams.toString()}`); + setThreadId(threadId); }, - [folder, router, searchParams], + [folder, router], ); const { diff --git a/apps/mail/components/mail/mail-quick-actions.tsx b/apps/mail/components/mail/mail-quick-actions.tsx index a02895ac17..cd80e2d9ea 100644 --- a/apps/mail/components/mail/mail-quick-actions.tsx +++ b/apps/mail/components/mail/mail-quick-actions.tsx @@ -1,17 +1,18 @@ 'use client'; import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions'; -import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import { Archive, Mail, Reply, Trash, Inbox } from 'lucide-react'; +import { useParams, useRouter } from 'next/navigation'; +import { Archive, Mail, Inbox } from 'lucide-react'; import { markAsRead, markAsUnread } from '@/actions/mail'; import { useCallback, memo, useState } from 'react'; -import { cn, FOLDERS, LABELS } from '@/lib/utils'; +import { cn, FOLDERS } from '@/lib/utils'; import { useThreads } from '@/hooks/use-threads'; import { Button } from '@/components/ui/button'; import { useStats } from '@/hooks/use-stats'; import type { InitialThread } from '@/types'; import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; +import { useQueryState } from 'nuqs'; interface MailQuickActionsProps { message: InitialThread; @@ -36,27 +37,24 @@ export const MailQuickActions = memo( const { mutate: mutateStats } = useStats(); const t = useTranslations(); const router = useRouter(); - const searchParams = useSearchParams(); const [isProcessing, setIsProcessing] = useState(false); + const [threadId, setThreadId] = useQueryState('threadId'); const currentFolder = folder ?? ''; const isInbox = currentFolder === FOLDERS.INBOX; const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE; const closeThreadIfOpen = useCallback(() => { - const threadIdParam = searchParams.get('threadId'); const messageId = message.threadId ?? message.id; - if (threadIdParam === messageId) { - const currentParams = new URLSearchParams(searchParams.toString()); - currentParams.delete('threadId'); - router.push(`/mail/${currentFolder}?${currentParams.toString()}`); + if (threadId === messageId) { + setThreadId(null) } if (resetNavigation) { resetNavigation(); } - }, [searchParams, message, router, currentFolder, resetNavigation]); + }, [threadId, message, router, currentFolder, resetNavigation]); const handleArchive = useCallback( async (e?: React.MouseEvent) => { diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 58202d5361..9cc1a9412c 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -42,7 +42,7 @@ import { useState, useCallback, useMemo, useEffect, useRef, memo } from 'react'; import { ThreadDisplay, ThreadDemo } from '@/components/mail/thread-display'; import { MailList, MailListDemo } from '@/components/mail/mail-list'; import { handleUnsubscribe } from '@/lib/email-utils.client'; -import { useParams, useSearchParams } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { useMediaQuery } from '../../hooks/use-media-query'; import { useSearchValue } from '@/hooks/use-search-value'; import { useMail } from '@/components/mail/use-mail'; @@ -74,7 +74,7 @@ export function DemoMailLayout() { const isValidating = false; const isLoading = false; const isDesktop = true; - const threadIdParam = useQueryState('threadId'); + const [threadIdParam] = useQueryState('threadId'); const [activeCategory, setActiveCategory] = useState('Primary'); const [filteredItems, setFilteredItems] = useState(items); @@ -250,10 +250,9 @@ export function MailLayout() { const [threadId, setThreadId] = useQueryState('threadId'); - const handleClose = useCallback(() => { + const handleClose = () => { setThreadId(null); - router.push(`/mail/${folder}`); - }, [router, folder, setThreadId]); + } // Search bar is always visible now, no need for keyboard shortcuts to toggle it useHotKey('Esc', (event) => { diff --git a/apps/mail/components/mail/thread-display.tsx b/apps/mail/components/mail/thread-display.tsx index 2abab96485..bfa72faba1 100644 --- a/apps/mail/components/mail/thread-display.tsx +++ b/apps/mail/components/mail/thread-display.tsx @@ -10,7 +10,7 @@ import { Trash, } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { useSearchParams, useParams } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { ScrollArea } from '@/components/ui/scroll-area'; import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions'; @@ -31,6 +31,7 @@ import { ParsedMessage } from '@/types'; import { Inbox } from 'lucide-react'; import { toast } from 'sonner'; import { NotesPanel } from './note-panel'; +import { useQueryState } from 'nuqs'; interface ThreadDisplayProps { @@ -134,18 +135,16 @@ function ThreadActionButton({ ); } -export function ThreadDisplay({ threadParam, onClose, isMobile, id }: ThreadDisplayProps) { +export function ThreadDisplay({ isMobile, id }: ThreadDisplayProps) { const { data: emailData, isLoading, mutate: mutateThread } = useThread(id ?? null); const { mutate: mutateThreads } = useThreads(); - const searchParams = useSearchParams(); const [isMuted, setIsMuted] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [mail, setMail] = useMail(); const t = useTranslations(); const { mutate: mutateStats } = useStats(); const { folder } = useParams<{ folder: string }>(); - const threadIdParam = searchParams.get('threadId'); - const threadId = threadParam ?? threadIdParam ?? ''; + const [threadId, setThreadId] = useQueryState('threadId'); // Check if thread contains any images (excluding sender avatars) const hasImages = useMemo(() => { @@ -188,17 +187,12 @@ export function ThreadDisplay({ threadParam, onClose, isMobile, id }: ThreadDisp const isInSpam = folder === FOLDERS.SPAM; const isInBin = folder === FOLDERS.BIN; const handleClose = useCallback(() => { - // Reset reply composer state when closing thread display - setMail((prev) => ({ - ...prev, - replyComposerOpen: false, - forwardComposerOpen: false - })); - onClose?.(); - }, [onClose, setMail]); + setThreadId(null) + }, []); const moveThreadTo = useCallback( async (destination: ThreadDestination) => { + if (!threadId) return; const promise = async () => { await moveThreadsTo({ threadIds: [threadId], @@ -330,7 +324,7 @@ export function ThreadDisplay({ threadParam, onClose, isMobile, id }: ThreadDisp
- + {threadId ? : null} - {buttonContent} + {buttonContent} ); diff --git a/apps/mail/components/ui/nav-user.tsx b/apps/mail/components/ui/nav-user.tsx index 9c1c5b8375..68f0f4b54b 100644 --- a/apps/mail/components/ui/nav-user.tsx +++ b/apps/mail/components/ui/nav-user.tsx @@ -70,7 +70,6 @@ export function NavUser() { if (!isRendered) return null; const handleAccountSwitch = (connection: IConnection) => async () => { - router.push('/mail/inbox'); // this is temp, its not good. bad. we change later. await putConnection(connection.id); refetch(); mutate(); diff --git a/apps/mail/hooks/use-notes.tsx b/apps/mail/hooks/use-notes.tsx index ee0396dd5e..ca93db9439 100644 --- a/apps/mail/hooks/use-notes.tsx +++ b/apps/mail/hooks/use-notes.tsx @@ -8,6 +8,8 @@ import useSWR from 'swr'; export type { Note }; +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + export const useThreadNotes = (threadId: string) => { const t = useTranslations(); const { data: session } = useSession(); @@ -17,10 +19,10 @@ export const useThreadNotes = (threadId: string) => { isLoading, mutate, } = useSWR( - session?.connectionId ? `notes-${threadId}-${session.connectionId}` : null, + session?.connectionId && threadId ? ['notes', session.connectionId, threadId] : null, async () => { try { - const result = await fetchThreadNotes(threadId); + const result = await fetcher('/api/driver/notes?threadId=' + threadId); return result.data || []; } catch (err: any) { console.error('Error fetching notes:', err); diff --git a/apps/mail/hooks/use-threads.ts b/apps/mail/hooks/use-threads.ts index 82b6ffe498..0858f11b32 100644 --- a/apps/mail/hooks/use-threads.ts +++ b/apps/mail/hooks/use-threads.ts @@ -8,6 +8,7 @@ import useSWRInfinite from 'swr/infinite'; import useSWR, { preload } from 'swr'; import { useMemo } from 'react'; import axios from 'axios'; +import { useQueryState } from 'nuqs'; export const preloadThread = async (userId: string, threadId: string, connectionId: string) => { console.log(`🔄 Prefetching email ${threadId}...`); @@ -99,7 +100,7 @@ export const useThreads = () => { ); // Flatten threads from all pages and sort by receivedOn date (newest first) - const threads = useMemo(() => (data ? data.flatMap((e) => e.threads) : []), [data]); + const threads = useMemo(() => (data ? data.flatMap((e) => e.threads) : []), [data, session]); const isEmpty = useMemo(() => threads.length === 0, [threads]); const isReachingEnd = isEmpty || (data && !data[data.length - 1]?.nextPageToken); const loadMore = async () => { @@ -123,8 +124,8 @@ export const useThreads = () => { export const useThread = (threadId: string | null) => { const { data: session } = useSession(); - const searchParams = useSearchParams(); - const id = threadId ? threadId : searchParams.get('threadId'); + const [_threadId] = useQueryState('threadId'); + const id = threadId ? threadId : _threadId const { data, isLoading, error, mutate } = useSWR( session?.user.id && id ? [session.user.id, id, session.connectionId] : null,