diff --git a/.oxlintrc.json b/.oxlintrc.json index 2af0b8ba09..17dd4a846b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,9 +1,9 @@ { "plugins": ["react", "unicorn", "typescript", "oxc"], "rules": { - "no-alert": "error", // Emit an error message when a call to `alert()` is found - "oxc/approx-constant": "warn", // Show a warning when you write a number close to a known constant - "no-plusplus": "off", // Allow using the `++` and `--` operators + "no-alert": "error", + "oxc/approx-constant": "warn", + "no-plusplus": "off", "no-useless-call": "error", "no-accumulating-spread": "error", "no-array-index-key": "error", @@ -13,6 +13,7 @@ "jsx-no-new-object-as-prop": "error", "prefer-array-find": "error", "prefer-set-has": "error", - "exhaustive-deps": "off" + "exhaustive-deps": "off", + "@typescript-eslint/no-this-alias": "off" } } diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000000..86fa16eaf3 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,108 @@ +# Agent Configuration for Zero Email + +Zero is an open-source AI email solution built with a modern TypeScript/Next.js stack in a monorepo setup. + +## Project Structure + +This is a pnpm workspace monorepo with the following structure: +- `apps/mail/` - Next.js frontend email client +- `apps/server/` - Backend server +- `apps/ios-app/` - iOS mobile app +- `packages/cli/` - CLI tools (`nizzy` command) +- `packages/db/` - Database schemas and utilities +- `packages/eslint-config/` - Shared ESLint configuration +- `packages/tailwind-config/` - Shared Tailwind configuration +- `packages/tsconfig/` - Shared TypeScript configuration + +## Frequently Used Commands + +### Development +- `pnpm go` - Quick start: starts database and dev servers +- `pnpm dev` - Start all development servers (uses Turbo) +- `pnpm docker:db:up` - Start PostgreSQL database in Docker +- `pnpm docker:db:down` - Stop and remove database container +- `pnpm docker:db:clean` - Stop and remove database with volumes + +### Build & Deploy +- `pnpm build` - Build all packages (uses Turbo) +- `pnpm build:frontend` - Build only the mail frontend +- `pnpm deploy:frontend` - Deploy frontend +- `pnpm deploy:backend` - Deploy backend + +### Code Quality +- `pnpm check` - Run format check and lint +- `pnpm lint` - Run ESLint across all packages +- `pnpm format` - Format code with Prettier +- `pnpm check:format` - Check code formatting + +### Database +- `pnpm db:push` - Push schema changes to database +- `pnpm db:generate` - Generate migration files +- `pnpm db:migrate` - Apply database migrations +- `pnpm db:studio` - Open Drizzle Studio + +### Testing & Evaluation +- `pnpm test:ai` - Run AI tests +- `pnpm eval` - Run evaluation suite +- `pnpm eval:dev` - Run evaluation in dev mode +- `pnpm eval:ci` - Run evaluation in CI mode + +### Utilities +- `pnpm nizzy env` - Setup environment variables +- `pnpm nizzy sync` - Sync environment variables and types +- `pnpm scripts` - Run custom scripts + +## Tech Stack + +- **Frontend**: Next.js, React 19, TypeScript, TailwindCSS, Shadcn UI +- **Backend**: Node.js, tRPC, Drizzle ORM +- **Database**: PostgreSQL +- **Authentication**: Better Auth, Google OAuth +- **Package Manager**: pnpm (v10+) +- **Build Tool**: Turbo +- **Linting**: ESLint, Oxlint, Prettier + +## Code Style & Conventions + +### Formatting +- 2-space indentation +- Single quotes +- 100 character line width +- Semicolons required +- Uses Prettier with sort-imports and Tailwind plugins + +### File Organization +- TypeScript strict mode enabled +- Workspace packages use catalog versioning for shared dependencies +- Monorepo managed with pnpm workspaces + +### Important Environment Variables +- `BETTER_AUTH_SECRET` - Auth secret key +- `GOOGLE_CLIENT_ID` & `GOOGLE_CLIENT_SECRET` - Gmail integration +- `AUTUMN_SECRET_KEY` - Encryption service +- `TWILIO_*` - SMS integration +- `DATABASE_URL` - PostgreSQL connection string + +## Development Setup + +1. Install dependencies: `pnpm install` +2. Setup environment: `pnpm nizzy env` +3. Sync environment: `pnpm nizzy sync` +4. Start database: `pnpm docker:db:up` +5. Initialize database: `pnpm db:push` +6. Start development: `pnpm dev` + +## Common Workflow + +1. Always run `pnpm check` before committing +2. Use `pnpm nizzy sync` after environment variable changes +3. Run `pnpm db:push` after schema changes +4. Use `pnpm go` for quick development startup + +## Notes + +- Uses Husky for git hooks +- Integrates with Sentry for error tracking +- Uses Cloudflare Workers for backend deployment +- iOS app is part of the monorepo +- CLI tool `nizzy` helps manage environment and sync operations diff --git a/apps/mail/app/(routes)/mail/[folder]/page.tsx b/apps/mail/app/(routes)/mail/[folder]/page.tsx index 95780ce9e4..0017ea4e72 100644 --- a/apps/mail/app/(routes)/mail/[folder]/page.tsx +++ b/apps/mail/app/(routes)/mail/[folder]/page.tsx @@ -6,7 +6,7 @@ import { authProxy } from '@/lib/auth-proxy'; import { useEffect, useState } from 'react'; import type { Route } from './+types/page'; -const ALLOWED_FOLDERS = new Set(['inbox', 'draft', 'sent', 'spam', 'bin', 'archive']); +const ALLOWED_FOLDERS = new Set(['inbox', 'draft', 'sent', 'spam', 'bin', 'archive', 'snoozed']); export async function clientLoader({ params, request }: Route.ClientLoaderArgs) { if (!params.folder) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`); diff --git a/apps/mail/app/(routes)/settings/notifications/page.tsx b/apps/mail/app/(routes)/settings/notifications/page.tsx index a845eb90c7..7515d11887 100644 --- a/apps/mail/app/(routes)/settings/notifications/page.tsx +++ b/apps/mail/app/(routes)/settings/notifications/page.tsx @@ -15,8 +15,8 @@ import { } from '@/components/ui/select'; import { SettingsCard } from '@/components/settings/settings-card'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; import { useForm } from 'react-hook-form'; import { Bell } from 'lucide-react'; import { useState } from 'react'; @@ -38,10 +38,9 @@ export default function NotificationsPage() { }, }); - function onSubmit(values: z.infer) { + function onSubmit() { setIsSaving(true); setTimeout(() => { - console.log(values); setIsSaving(false); }, 1000); } diff --git a/apps/mail/app/entry.client.tsx b/apps/mail/app/entry.client.tsx index accd5e23c8..6755cc2a3c 100644 --- a/apps/mail/app/entry.client.tsx +++ b/apps/mail/app/entry.client.tsx @@ -1,9 +1,8 @@ -import '../instrument'; - import { startTransition, StrictMode } from 'react'; import { HydratedRouter } from 'react-router/dom'; import { hydrateRoot } from 'react-dom/client'; import * as Sentry from '@sentry/react'; +import './instrument'; startTransition(() => { hydrateRoot( diff --git a/apps/mail/app/instrument.ts b/apps/mail/app/instrument.ts new file mode 100644 index 0000000000..5534f55dc4 --- /dev/null +++ b/apps/mail/app/instrument.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/react'; + +Sentry.init({ + dsn: 'https://03f6397c0eb458bf1e37c4776a31797c@o4509328786915328.ingest.us.sentry.io/4509328795303936', + tunnel: import.meta.env.VITE_PUBLIC_BACKEND_URL + '/monitoring/sentry', + integrations: [Sentry.replayIntegration()], + tracesSampleRate: 1, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + debug: false, +}); diff --git a/apps/mail/app/root.tsx b/apps/mail/app/root.tsx index 7236fc2709..8abe90401c 100644 --- a/apps/mail/app/root.tsx +++ b/apps/mail/app/root.tsx @@ -10,23 +10,23 @@ import { type LoaderFunctionArgs, type MetaFunction, } from 'react-router'; +import { Analytics as DubAnalytics } from '@dub/analytics/react'; import { ServerProviders } from '@/providers/server-providers'; import { ClientProviders } from '@/providers/client-providers'; import { createTRPCClient, httpBatchLink } from '@trpc/client'; import { useEffect, type PropsWithChildren } from 'react'; -import { AlertCircle, Loader2 } from 'lucide-react'; import type { AppRouter } from '@zero/server/trpc'; import { Button } from '@/components/ui/button'; import { getLocale } from '@/paraglide/runtime'; import { siteConfig } from '@/lib/site-config'; import { signOut } from '@/lib/auth-client'; import type { Route } from './+types/root'; +import { AlertCircle } from 'lucide-react'; import { m } from '@/paraglide/messages'; import { ArrowLeft } from 'lucide-react'; +import * as Sentry from '@sentry/react'; import superjson from 'superjson'; import './globals.css'; -import { Analytics as DubAnalytics } from '@dub/analytics/react'; - const getUrl = () => import.meta.env.VITE_PUBLIC_BACKEND_URL + '/api/trpc'; @@ -55,13 +55,13 @@ export const meta: MetaFunction = () => { ]; }; -export async function loader({ request }: LoaderFunctionArgs) { - const trpc = getServerTrpc(request); - const defaultConnection = await trpc.connections.getDefault - .query() - .then((res) => (res?.id as string) ?? null) - .catch(() => null); - return { connectionId: defaultConnection }; +export async function loader(_: LoaderFunctionArgs) { + // const trpc = getServerTrpc(request); + // const defaultConnection = await trpc.connections.getDefault + // .query() + // .then((res) => (res?.id as string) ?? null) + // .catch(() => null); + return { connectionId: 'defaultConnection' }; } export function Layout({ children }: PropsWithChildren) { @@ -84,9 +84,11 @@ export function Layout({ children }: PropsWithChildren) { {children} - + @@ -95,13 +97,13 @@ export function Layout({ children }: PropsWithChildren) { ); } -export function HydrateFallback() { - return ( -
- -
- ); -} +// export function HydrateFallback() { +// return ( +//
+// +//
+// ); +// } export default function App() { return ; @@ -127,6 +129,35 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { useEffect(() => { console.error(error); console.error({ message, details, stack }); + + // Report error to Sentry + if (isRouteErrorResponse(error)) { + Sentry.captureException(new Error(`Route Error ${error.status}: ${error.statusText}`), { + tags: { + type: 'route_error', + status: error.status, + }, + extra: { + statusText: error.statusText, + data: error.data, + }, + }); + } else if (error instanceof Error) { + Sentry.captureException(error, { + tags: { + type: 'app_error', + }, + }); + } else { + Sentry.captureException(new Error('Unknown error occurred'), { + tags: { + type: 'unknown_error', + }, + extra: { + error: error, + }, + }); + } }, [error, message, details, stack]); return ( diff --git a/apps/mail/components/context/command-palette-context.tsx b/apps/mail/components/context/command-palette-context.tsx index 83e54698ff..92f8ef3c1b 100644 --- a/apps/mail/components/context/command-palette-context.tsx +++ b/apps/mail/components/context/command-palette-context.tsx @@ -278,8 +278,8 @@ export function CommandPalette({ children }: { children: React.ReactNode }) { } }; - document.addEventListener('keydown', down); - return () => document.removeEventListener('keydown', down); + document.addEventListener('keydown', down, { capture: true }); + return () => document.removeEventListener('keydown', down, { capture: true }); }, [open, currentView]); const runCommand = useCallback((command: () => unknown) => { @@ -739,7 +739,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) { const result: CommandGroup[] = [ { - group: 'Search & Filter', + group: 'Search', items: searchCommands, }, { diff --git a/apps/mail/components/context/thread-context.tsx b/apps/mail/components/context/thread-context.tsx index bb8d42e89f..6d48164abb 100644 --- a/apps/mail/components/context/thread-context.tsx +++ b/apps/mail/components/context/thread-context.tsx @@ -27,8 +27,8 @@ import { useOptimisticThreadState } from '@/components/mail/optimistic-thread-st import { useOptimisticActions } from '@/hooks/use-optimistic-actions'; import { type ThreadDestination } from '@/lib/thread-actions'; import { useThread, useThreads } from '@/hooks/use-threads'; -import { ExclamationCircle, Mail } from '../icons/icons'; -import { useMemo, type ReactNode } from 'react'; +import { ExclamationCircle, Mail, Clock } from '../icons/icons'; +import { useMemo, type ReactNode, useState } from 'react'; import { useLabels } from '@/hooks/use-labels'; import { FOLDERS, LABELS } from '@/lib/utils'; import { useMail } from '../mail/use-mail'; @@ -37,6 +37,7 @@ import { m } from '@/paraglide/messages'; import { useParams } from 'react-router'; import { useQueryState } from 'nuqs'; import { toast } from 'sonner'; +import { SnoozeDialog } from '@/components/mail/snooze-dialog'; interface EmailAction { id: string; @@ -68,12 +69,11 @@ const LabelsList = ({ threadId, bulkSelected }: { threadId: string; bulkSelected if (!labels || !thread) return null; - const handleToggleLabel = async (labelId: string) => { + const handleToggleLabel = (labelId: string) => { if (!labelId) return; - let shouldAddLabel = false; - - let hasLabel = thread.labels?.map((label) => label.id).includes(labelId) || false; + // Determine current label state considering optimistic updates + let hasLabel = thread!.labels?.some((l) => l.id === labelId) ?? false; if (rightClickedThreadOptimisticState.optimisticLabels) { if (rightClickedThreadOptimisticState.optimisticLabels.addedLabelIds.includes(labelId)) { @@ -85,9 +85,7 @@ const LabelsList = ({ threadId, bulkSelected }: { threadId: string; bulkSelected } } - shouldAddLabel = !hasLabel; - - optimisticToggleLabel(targetThreadIds, labelId, shouldAddLabel); + optimisticToggleLabel(targetThreadIds, labelId, !hasLabel); }; return ( @@ -95,14 +93,13 @@ const LabelsList = ({ threadId, bulkSelected }: { threadId: string; bulkSelected {labels .filter((label) => label.id) .map((label) => { - let isChecked = label.id ? thread.labels?.map((l) => l.id).includes(label.id) : false; + let isChecked = label.id ? thread!.labels?.some((l) => l.id === label.id) ?? false : false; - const checkboxOptimisticState = useOptimisticThreadState(threadId); - if (label.id && checkboxOptimisticState.optimisticLabels) { - if (checkboxOptimisticState.optimisticLabels.addedLabelIds.includes(label.id)) { + if (rightClickedThreadOptimisticState.optimisticLabels) { + if (rightClickedThreadOptimisticState.optimisticLabels.addedLabelIds.includes(label.id)) { isChecked = true; } else if ( - checkboxOptimisticState.optimisticLabels.removedLabelIds.includes(label.id) + rightClickedThreadOptimisticState.optimisticLabels.removedLabelIds.includes(label.id) ) { isChecked = false; } @@ -138,7 +135,7 @@ export function ThreadContextMenu({ const [{ isLoading, isFetching }] = useThreads(); const currentFolder = folder ?? ''; const isArchiveFolder = currentFolder === FOLDERS.ARCHIVE; - + const isSnoozedFolder = currentFolder === FOLDERS.SNOOZED; const [, setMode] = useQueryState('mode'); const [, setThreadId] = useQueryState('threadId'); const { data: threadData } = useThread(threadId); @@ -151,6 +148,8 @@ export function ThreadContextMenu({ optimisticMarkAsRead, optimisticMarkAsUnread, optimisticDeleteThreads, + optimisticSnooze, + optimisticUnsnooze, } = useOptimisticActions(); const { isUnread, isStarred, isImportant } = useMemo(() => { @@ -359,6 +358,31 @@ export function ThreadContextMenu({ ]; } + if (isSnoozedFolder) { + return [ + { + id: 'unsnooze', + label: 'Unsnooze', + icon: , + action: () => { + const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId]; + optimisticUnsnooze(targets, currentFolder); + if (mail.bulkSelected.length) { + setMail({ ...mail, bulkSelected: [] }); + } + }, + disabled: false, + }, + { + id: 'move-to-bin', + label: m['common.mail.moveToBin'](), + icon: , + action: handleMove(LABELS.SNOOZED, LABELS.TRASH), + disabled: false, + }, + ]; + } + if (isArchiveFolder || !isInbox) { return [ { @@ -422,6 +446,14 @@ export function ThreadContextMenu({ ]; }, [isSpam, isBin, isArchiveFolder, isInbox, isSent, handleMove, handleDelete]); + const [snoozeOpen, setSnoozeOpen] = useState(false); + + const handleSnoozeConfirm = (wakeAt: Date) => { + const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId]; + optimisticSnooze(targets, currentFolder, wakeAt); + setSnoozeOpen(false); + }; + const otherActions: EmailAction[] = useMemo( () => [ { @@ -453,8 +485,23 @@ export function ThreadContextMenu({ ), action: handleFavorites, }, + { + id: 'snooze', + label: 'Snooze', + icon: , + action: () => setSnoozeOpen(true), + disabled: false, + }, + ], + [ + isUnread, + isImportant, + isStarred, + m, + handleReadUnread, + handleToggleImportant, + handleFavorites, ], - [isUnread, isImportant, isStarred, m, handleReadUnread, handleToggleImportant, handleFavorites], ); const renderAction = (action: EmailAction) => { @@ -473,36 +520,43 @@ export function ThreadContextMenu({ }; return ( - - - {children} - - e.preventDefault()} - > - {primaryActions.map(renderAction)} - - - - - - - {m['common.mail.labels']()} - - - - - - - - - {getActions.map(renderAction)} - - - - {otherActions.map(renderAction)} - - + <> + + + {children} + + e.preventDefault()} + > + {primaryActions.map(renderAction)} + + + + + + + {m['common.mail.labels']()} + + + + + + + + + {getActions.map(renderAction)} + + + + {otherActions.map(renderAction)} + + + + ); } diff --git a/apps/mail/components/create/email-composer.tsx b/apps/mail/components/create/email-composer.tsx index 5a1f676476..75b37c99c8 100644 --- a/apps/mail/components/create/email-composer.tsx +++ b/apps/mail/components/create/email-composer.tsx @@ -149,6 +149,7 @@ export function EmailComposer({ const [imageQuality, setImageQuality] = useState( settings?.settings?.imageCompression || 'medium', ); + const [activeReplyId] = useQueryState('activeReplyId'); const [toggleToolbar, setToggleToolbar] = useState(false); const processAndSetAttachments = async ( filesToProcess: File[], @@ -1224,33 +1225,35 @@ export function EmailComposer({ {/* Subject */} -
-

Subject:

- { - const value = replaceEmojiShortcodes(e.target.value); - setValue('subject', value); - setHasUnsavedChanges(true); - }} - /> -
- - + + + ) : null} {/* From */} {aliases && aliases.length > 1 ? ( diff --git a/apps/mail/components/icons/icons.tsx b/apps/mail/components/icons/icons.tsx index 725e8423d9..0a13e7c352 100644 --- a/apps/mail/components/icons/icons.tsx +++ b/apps/mail/components/icons/icons.tsx @@ -1625,6 +1625,8 @@ export const Clock = ({ className }: { className?: string }) => ( fillRule="evenodd" clipRule="evenodd" d="M0 7C0 3.13401 3.13401 0 7 0C10.866 0 14 3.13401 14 7C14 10.866 10.866 14 7 14C3.13401 14 0 10.866 0 7ZM7.75 2.75C7.75 2.33579 7.41421 2 7 2C6.58579 2 6.25 2.33579 6.25 2.75V7C6.25 7.41421 6.58579 7.75 7 7.75H10.25C10.6642 7.75 11 7.41421 11 7C11 6.58579 10.6642 6.25 10.25 6.25H7.75V2.75Z" + fill="var(--icon-color)" + fillOpacity="0.5" /> ); diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx index 92bc57e340..77c735dee6 100644 --- a/apps/mail/components/mail/mail-display.tsx +++ b/apps/mail/components/mail/mail-display.tsx @@ -37,6 +37,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import type { Sender, ParsedMessage, Attachment } from '@/types'; import { useActiveConnection } from '@/hooks/use-connections'; +import { useAttachments } from '@/hooks/use-attachments'; import { useBrainState } from '../../hooks/use-summary'; import { useTRPC } from '@/providers/query-provider'; import { useThreadLabels } from '@/hooks/use-labels'; @@ -378,9 +379,20 @@ const ActionButton = ({ onClick, icon, text, shortcut }: ActionButtonProps) => { ); }; -const downloadAttachment = (attachment: { body: string; mimeType: string; filename: string }) => { +const downloadAttachment = async (attachment: { + body: string; + mimeType: string; + filename: string; + attachmentId: string; +}) => { try { - const byteCharacters = atob(attachment.body); + const attachmentData = attachment.body; + + if (!attachmentData) { + throw new Error('Attachment data not found'); + } + + const byteCharacters = atob(attachmentData); const byteNumbers: number[] = Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); @@ -398,6 +410,7 @@ const downloadAttachment = (attachment: { body: string; mimeType: string; filena window.URL.revokeObjectURL(url); } catch (error) { console.error('Error downloading attachment:', error); + toast.error('Failed to download attachment'); } }; @@ -455,9 +468,20 @@ const handleDownloadAllAttachments = console.log('downloaded', subject, attachments); }; -const openAttachment = (attachment: { body: string; mimeType: string; filename: string }) => { +const openAttachment = async (attachment: { + body: string; + mimeType: string; + filename: string; + attachmentId: string; +}) => { try { - const byteCharacters = atob(attachment.body); + const attachmentData = attachment.body; + + if (!attachmentData) { + throw new Error('Attachment data not found'); + } + + const byteCharacters = atob(attachmentData); const byteNumbers: number[] = Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); @@ -484,6 +508,7 @@ const openAttachment = (attachment: { body: string; mimeType: string; filename: } } catch (error) { console.error('Error opening attachment:', error); + toast.error('Failed to open attachment'); } }; @@ -638,6 +663,7 @@ const MoreAboutQuery = ({ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: Props) => { const [isCollapsed, setIsCollapsed] = useState(false); const { data: threadData } = useThread(emailData.threadId ?? null); + const { data: messageAttachments } = useAttachments(emailData.id); // const [unsubscribed, setUnsubscribed] = useState(false); // const [isUnsubscribing, setIsUnsubscribing] = useState(false); const [preventCollapse, setPreventCollapse] = useState(false); @@ -660,6 +686,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: const { data: activeConnection } = useActiveConnection(); const [researchSender, setResearchSender] = useState(null); const [searchQuery, setSearchQuery] = useState(null); + // const trpc = useTRPC(); const isLastEmail = useMemo( () => emailData.id === threadData?.latest?.id, @@ -1077,11 +1104,11 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: ${ - emailData.attachments && emailData.attachments.length > 0 + messageAttachments && messageAttachments.length > 0 ? `
-

Attachments (${emailData.attachments.length})

- ${emailData.attachments +

Attachments (${messageAttachments.length})

+ ${messageAttachments .map( (attachment) => `
@@ -1491,11 +1518,11 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: {m['common.mailDisplay.print']()} - {(emailData.attachments?.length ?? 0) > 0 && ( + {(messageAttachments?.length ?? 0) > 0 && ( @@ -1642,9 +1669,9 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: /> ) : null} {/* mail attachments */} - {emailData?.attachments && emailData?.attachments.length > 0 ? ( + {messageAttachments && messageAttachments.length > 0 ? (
- {emailData?.attachments.map((attachment) => ( + {messageAttachments.map((attachment) => (
- {index < (emailData?.attachments?.length || 0) - 1 && ( + {index < (messageAttachments?.length || 0) - 1 && (
)}
diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index bf333cdd4e..15edc1b1b0 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -1,12 +1,12 @@ -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; +// import { +// Dialog, +// DialogContent, +// DialogDescription, +// DialogFooter, +// DialogHeader, +// DialogTitle, +// DialogTrigger, +// } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuItem, @@ -14,17 +14,17 @@ import { DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { Bell, Lightning, Mail, ScanEye, Tag, Trash, User, X, Search } from '../icons/icons'; +import { Bell, Lightning, Mail, ScanEye, Tag, User, X, Search } from '../icons/icons'; import { useCategorySettings, useDefaultCategoryId } from '@/hooks/use-categories'; import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useCommandPalette } from '../context/command-palette-context'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; import { ThreadDisplay } from '@/components/mail/thread-display'; import { useActiveConnection } from '@/hooks/use-connections'; -import { useMutation, useQuery } from '@tanstack/react-query'; -import { useTRPC } from '@/providers/query-provider'; +// import { useMutation, useQuery } from '@tanstack/react-query'; +// import { useTRPC } from '@/providers/query-provider'; import { useMediaQuery } from '../../hooks/use-media-query'; @@ -33,40 +33,40 @@ import * as CustomIcons from '@/components/icons/icons'; import { isMac } from '@/lib/hotkeys/use-hotkey-utils'; import { MailList } from '@/components/mail/mail-list'; import { useHotkeysContext } from 'react-hotkeys-hook'; -import SelectAllCheckbox from './select-all-checkbox'; +// import SelectAllCheckbox from './select-all-checkbox'; import { useNavigate, useParams } from 'react-router'; import { useMail } from '@/components/mail/use-mail'; import { SidebarToggle } from '../ui/sidebar-toggle'; import { PricingDialog } from '../ui/pricing-dialog'; -import { Textarea } from '@/components/ui/textarea'; -import { useBrainState } from '@/hooks/use-summary'; +// import { Textarea } from '@/components/ui/textarea'; +// import { useBrainState } from '@/hooks/use-summary'; import { clearBulkSelectionAtom } from './use-mail'; import AISidebar from '@/components/ui/ai-sidebar'; import { useThreads } from '@/hooks/use-threads'; -import { useBilling } from '@/hooks/use-billing'; +// import { useBilling } from '@/hooks/use-billing'; import AIToggleButton from '../ai-toggle-button'; import { useIsMobile } from '@/hooks/use-mobile'; -import { Switch } from '@/components/ui/switch'; +// import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { useLabels } from '@/hooks/use-labels'; import { useSession } from '@/lib/auth-client'; -import { ScrollArea } from '../ui/scroll-area'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; +// import { ScrollArea } from '../ui/scroll-area'; +// import { Label } from '@/components/ui/label'; +// import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { m } from '@/paraglide/messages'; import { useQueryState } from 'nuqs'; import { useAtom } from 'jotai'; -import { toast } from 'sonner'; +// import { toast } from 'sonner'; -interface ITag { - id: string; - name: string; - usecase: string; - text: string; -} +// interface ITag { +// id: string; +// name: string; +// usecase: string; +// text: string; +// } export const defaultLabels = [ { @@ -101,282 +101,282 @@ export const defaultLabels = [ }, ]; -const AutoLabelingSettings = () => { - const trpc = useTRPC(); - const [open, setOpen] = useState(false); - const { data: storedLabels, refetch: refetchStoredLabels } = useQuery( - trpc.brain.getLabels.queryOptions(void 0, { - staleTime: 1000 * 60 * 60, // 1 hour - }), - ); - const { mutateAsync: updateLabels, isPending } = useMutation( - trpc.brain.updateLabels.mutationOptions({ - onSuccess: () => { - refetchStoredLabels(); - }, - }), - ); - const [, setPricingDialog] = useQueryState('pricingDialog'); - const [labels, setLabels] = useState([]); - const [newLabel, setNewLabel] = useState({ name: '', usecase: '' }); - const { mutateAsync: EnableBrain, isPending: isEnablingBrain } = useMutation( - trpc.brain.enableBrain.mutationOptions(), - ); - const { mutateAsync: DisableBrain, isPending: isDisablingBrain } = useMutation( - trpc.brain.disableBrain.mutationOptions(), - ); - const { data: brainState, refetch: refetchBrainState } = useBrainState(); - const { isLoading, isPro } = useBilling(); - - useEffect(() => { - if (storedLabels) { - setLabels( - storedLabels.map((label) => ({ - id: label.name, - name: label.name, - text: label.name, - usecase: label.usecase, - })), - ); - } - }, [storedLabels]); - - const handleResetToDefault = useCallback(() => { - setLabels( - defaultLabels.map((label) => ({ - id: label.name, - name: label.name, - text: label.name, - usecase: label.usecase, - })), - ); - }, [storedLabels]); - - const handleAddLabel = () => { - if (!newLabel.name || !newLabel.usecase) return; - setLabels([...labels, { id: newLabel.name, ...newLabel, text: newLabel.name }]); - setNewLabel({ name: '', usecase: '' }); - }; - - const handleDeleteLabel = (id: string) => { - setLabels(labels.filter((label) => label.id !== id)); - }; - - const handleUpdateLabel = (id: string, field: 'name' | 'usecase', value: string) => { - setLabels( - labels.map((label) => - label.id === id - ? { ...label, [field]: value, text: field === 'name' ? value : label.text } - : label, - ), - ); - }; - - const handleSubmit = async () => { - const updatedLabels = labels.map((label) => ({ - name: label.name, - usecase: label.usecase, - })); - - if (newLabel.name.trim() && newLabel.usecase.trim()) { - updatedLabels.push({ - name: newLabel.name, - usecase: newLabel.usecase, - }); - } - await updateLabels({ labels: updatedLabels }); - setOpen(false); - toast.success('Labels updated successfully, Zero will start using them.'); - }; - - const handleEnableBrain = useCallback(async () => { - toast.promise(EnableBrain, { - loading: 'Enabling autolabeling...', - success: 'Autolabeling enabled successfully', - error: 'Failed to enable autolabeling', - finally: async () => { - await refetchBrainState(); - }, - }); - }, []); - - const handleDisableBrain = useCallback(async () => { - toast.promise(DisableBrain, { - loading: 'Disabling autolabeling...', - success: 'Autolabeling disabled successfully', - error: 'Failed to disable autolabeling', - finally: async () => { - await refetchBrainState(); - }, - }); - }, []); - - const handleToggleAutolabeling = useCallback(() => { - if (brainState?.enabled) { - handleDisableBrain(); - } else { - handleEnableBrain(); - } - }, [brainState?.enabled]); - - return ( - { - if (!isPro) { - setPricingDialog('true'); - } else { - setOpen(state); - } - }} - > - -
- - - Auto label - -
-
- - -
- Label Settings - -
- - Configure the labels that Zero uses to automatically organize your emails. - -
- - -
- {labels.map((label, index) => ( -
-
- - -
- ) => - handleUpdateLabel(label.id, 'name', e.target.value) - } - className="h-8" - placeholder="e.g., Important, Follow-up, Archive" - /> -
- -