From 564f0c0104f51734c39685fce9258cd9f5ad3aa8 Mon Sep 17 00:00:00 2001 From: Aj Wazzan Date: Sun, 3 Aug 2025 16:15:36 -0700 Subject: [PATCH] Redesign mail categories to use label-based filtering instead of search queries --- .husky/pre-commit | 2 +- AGENT.md | 7 + .../app/(routes)/settings/categories/page.tsx | 517 ++++++++++-------- apps/mail/app/routes.ts | 2 +- apps/mail/components/mail/mail-list.tsx | 38 +- apps/mail/components/mail/mail.tsx | 147 ++--- .../components/settings/settings-card.tsx | 9 +- apps/mail/components/ui/nav-main.tsx | 17 +- apps/mail/config/navigation.ts | 10 +- apps/mail/hooks/use-categories.ts | 48 +- apps/mail/lib/hotkeys/mail-list-hotkeys.tsx | 8 +- apps/mail/messages/en.json | 2 +- apps/server/src/lib/schemas.ts | 37 +- apps/server/src/routes/agent/index.ts | 327 ++++++----- package.json | 1 + 15 files changed, 574 insertions(+), 598 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index e02c24e2b5..22c2457047 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm lint-staged \ No newline at end of file +pnpm dlx oxlint@1.9.0 --deny-warnings \ No newline at end of file diff --git a/AGENT.md b/AGENT.md index 68e20f816a..e0f5cbfc3a 100644 --- a/AGENT.md +++ b/AGENT.md @@ -105,3 +105,10 @@ This is a pnpm workspace monorepo with the following structure: - Uses Cloudflare Workers for backend deployment - iOS app is part of the monorepo - CLI tool `nizzy` helps manage environment and sync operations + +## IMPORTANT RESTRICTIONS + +- **NEVER run project-wide lint/format commands** (`pnpm check`, `pnpm lint`, `pnpm format`, `pnpm check:format`) +- These commands format/lint the entire codebase and cause unnecessary changes +- Only use targeted linting/formatting on specific files when absolutely necessary +- Focus on the specific task at hand without touching unrelated files diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx index c58e905fc7..1635d44ab6 100644 --- a/apps/mail/app/(routes)/settings/categories/page.tsx +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -1,21 +1,15 @@ -import { useSettings } from '@/hooks/use-settings'; -import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { SettingsCard } from '@/components/settings/settings-card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; -import { useState, useEffect, useCallback } from 'react'; -import { useTRPC } from '@/providers/query-provider'; -import { toast } from 'sonner'; -import type { CategorySetting } from '@/hooks/use-categories'; -import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; - -import { Sparkles } from '@/components/icons/icons'; -import { Loader, GripVertical } from 'lucide-react'; import { - } from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import { DndContext, closestCenter, @@ -24,176 +18,183 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { SettingsCard } from '@/components/settings/settings-card'; +import { Check, ChevronDown, Trash2, Plus } from 'lucide-react'; +import type { CategorySetting } from '@/hooks/use-categories'; +import { defaultMailCategories } from '@zero/server/schemas'; +import React, { useState, useEffect, useMemo } from 'react'; +import { useTRPC } from '@/providers/query-provider'; +import { useSettings } from '@/hooks/use-settings'; import type { DragEndEvent } from '@dnd-kit/core'; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; +import { Switch } from '@/components/ui/switch'; +import { Button } from '@/components/ui/button'; import { useSortable } from '@dnd-kit/sortable'; +import { useLabels } from '@/hooks/use-labels'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { GripVertical } from 'lucide-react'; +import { m } from '@/paraglide/messages'; import { CSS } from '@dnd-kit/utilities'; -import React from 'react'; +import { toast } from 'sonner'; interface SortableCategoryItemProps { cat: CategorySetting; - isActiveAi: boolean; - promptValue: string; - setPromptValue: (val: string) => void; - setActiveAiCat: (id: string | null) => void; - isGeneratingQuery: boolean; - generateSearchQuery: (params: { query: string }) => Promise<{ query: string }>; handleFieldChange: (id: string, field: keyof CategorySetting, value: any) => void; toggleDefault: (id: string) => void; + handleDeleteCategory: (id: string) => void; + allLabels: Array<{ id: string; name: string; type: string }>; } const SortableCategoryItem = React.memo(function SortableCategoryItem({ cat, - isActiveAi, - promptValue, - setPromptValue, - setActiveAiCat, - isGeneratingQuery, - generateSearchQuery, handleFieldChange, toggleDefault, + handleDeleteCategory, + allLabels, }: SortableCategoryItemProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: cat.id }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: cat.id, + }); const style = { transform: CSS.Transform.toString(transform), transition, }; + const handleLabelToggle = React.useCallback( + (labelId: string, isSelected: boolean) => (e: React.MouseEvent) => { + e.preventDefault(); + const currentLabels = cat.searchValue ? cat.searchValue.split(',').filter(Boolean) : []; + let newLabels; + + if (isSelected) { + newLabels = currentLabels.filter((id) => id !== labelId); + } else { + newLabels = [...currentLabels, labelId]; + } + + handleFieldChange(cat.id, 'searchValue', newLabels.join(',')); + }, + [cat.id, cat.searchValue, handleFieldChange], + ); + + const handleDeleteClick = React.useCallback(() => { + handleDeleteCategory(cat.id); + }, [cat.id, handleDeleteCategory]); + + const handleToggleDefault = React.useCallback(() => { + toggleDefault(cat.id); + }, [cat.id, toggleDefault]); + + const handleNameChange = React.useCallback( + (e: React.ChangeEvent) => { + handleFieldChange(cat.id, 'name', e.target.value); + }, + [cat.id, handleFieldChange], + ); + return (
-
+
-
- -
- + + + {cat.id} {cat.isDefault && ( - - Default - + Default )}
+ toggleDefault(cat.id)} + onCheckedChange={handleToggleDefault} /> -
-
+
- - handleFieldChange(cat.id, 'name', e.target.value)} - /> + +
- -
- -
- handleFieldChange(cat.id, 'searchValue', e.target.value)} - /> - - { - if (open) { - setActiveAiCat(cat.id); - } else { - setActiveAiCat(null); - } - }} - > - - - - -
- - setPromptValue(e.target.value)} - /> -
-
- Example: "emails that mention quarterly reports" -
- -
-
-
+ return `${selectedLabels.length} labels selected`; + })()} + + + + + + {allLabels.map((label) => { + const selectedLabels = cat.searchValue + ? cat.searchValue.split(',').filter(Boolean) + : []; + const isSelected = selectedLabels.includes(label.id); + + return ( + +
+ + {label.name} + +
+ {isSelected && } +
+ ); + })} +
+
@@ -204,21 +205,11 @@ export default function CategoriesSettingsPage() { const { data } = useSettings(); const trpc = useTRPC(); const queryClient = useQueryClient(); - const { mutateAsync: saveUserSettings, isPending } = useMutation( - trpc.settings.save.mutationOptions(), - ); + const { userLabels, systemLabels } = useLabels(); + const allLabels = useMemo(() => [...systemLabels, ...userLabels], [systemLabels, userLabels]); - const { mutateAsync: generateSearchQuery, isPending: isGeneratingQuery } = useMutation( - trpc.ai.generateSearchQuery.mutationOptions(), - ); - - const { data: defaultMailCategories = [] } = useQuery( - trpc.categories.defaults.queryOptions(void 0, { staleTime: Infinity }), - ); - - const [categories, setCategories] = useState([]); - const [activeAiCat, setActiveAiCat] = useState(null); - const [promptValues, setPromptValues] = useState>({}); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const { mutateAsync: saveUserSettings } = useMutation(trpc.settings.save.mutationOptions()); const sensors = useSensors( useSensor(PointerSensor, { @@ -229,32 +220,36 @@ export default function CategoriesSettingsPage() { }), ); - const toggleDefault = useCallback( - (id: string) => { - setCategories((prev) => - prev.map((c) => ({ ...c, isDefault: c.id === id ? !c.isDefault : false })), - ); - }, - [], - ); - - useEffect(() => { - if (!defaultMailCategories.length) return; - + const initialCategories = useMemo(() => { const stored = data?.settings?.categories ?? []; + return stored.slice().sort((a, b) => a.order - b.order); + }, [data?.settings?.categories]); + const [categories, setCategories] = useState(initialCategories); - const merged = defaultMailCategories.map((def) => { - const override = stored.find((c: { id: string }) => c.id === def.id); - return override ? { ...def, ...override } : def; - }); - - setCategories(merged.sort((a, b) => a.order - b.order)); - }, [data, defaultMailCategories]); + useEffect(() => { + setCategories(initialCategories); + setHasUnsavedChanges(false); + }, [data?.settings?.categories]); - const handleFieldChange = (id: string, field: keyof CategorySetting, value: string | number | boolean) => { - setCategories((prev) => - prev.map((cat) => (cat.id === id ? { ...cat, [field]: value } : cat)), + const handleFieldChange = ( + id: string, + field: keyof CategorySetting, + value: string | number | boolean, + ) => { + const updatedCategories = categories.map((cat) => + cat.id === id ? { ...cat, [field]: value } : cat, ); + setCategories(updatedCategories); + setHasUnsavedChanges(true); + }; + + const toggleDefault = (id: string) => { + const updatedCategories = categories.map((c) => ({ + ...c, + isDefault: c.id === id ? !c.isDefault : false, + })); + setCategories(updatedCategories); + setHasUnsavedChanges(true); }; const handleDragEnd = (event: DragEndEvent) => { @@ -264,39 +259,28 @@ export default function CategoriesSettingsPage() { return; } - setCategories((prev) => { - const oldIndex = prev.findIndex((cat) => cat.id === active.id); - const newIndex = prev.findIndex((cat) => cat.id === over.id); - - const reorderedCategories = arrayMove(prev, oldIndex, newIndex); - - return reorderedCategories.map((cat, index) => ({ - ...cat, - order: index, - })); - }); - }; - - const handleSave = async () => { - if (categories.filter((c) => c.isDefault).length !== 1) { - toast.error('Please mark exactly one category as default'); - return; - } + const oldIndex = categories.findIndex((cat) => cat.id === active.id); + const newIndex = categories.findIndex((cat) => cat.id === over.id); - const sortedCategories = categories.map((cat, index) => ({ + const reorderedCategories = arrayMove(categories, oldIndex, newIndex).map((cat, index) => ({ ...cat, order: index, })); + setCategories(reorderedCategories); + setHasUnsavedChanges(true); + }; + + const handleSave = async () => { try { - await saveUserSettings({ categories: sortedCategories }); - queryClient.setQueryData(trpc.settings.get.queryKey(), (updater) => { - if (!updater) return; - return { - settings: { ...updater.settings, categories: sortedCategories }, - }; - }); - setCategories(sortedCategories); + const defaultCategoryCount = categories.filter((cat) => cat.isDefault).length; + if (defaultCategoryCount !== 1) { + toast.error('Exactly one category must be set as default'); + return; + } + await saveUserSettings({ categories }); + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + setHasUnsavedChanges(false); toast.success('Categories saved'); } catch (e) { console.error(e); @@ -304,53 +288,104 @@ export default function CategoriesSettingsPage() { } }; + const handleDeleteCategory = (id: string) => { + const categoryToDelete = categories.find((cat) => cat.id === id); + + if (categoryToDelete?.isDefault) { + const remainingCategories = categories.filter((cat) => cat.id !== id); + + if (remainingCategories.length === 0) { + toast.error('Cannot delete the last remaining category'); + return; + } + + const updatedCategories = remainingCategories.map((cat, index) => + index === 0 ? { ...cat, isDefault: true } : cat, + ); + + setCategories(updatedCategories); + toast.success('Default category reassigned to the first remaining category'); + } else { + const updatedCategories = categories.filter((cat) => cat.id !== id); + setCategories(updatedCategories); + } + + setHasUnsavedChanges(true); + }; + + const handleAddCategory = () => { + const newCategory: CategorySetting = { + id: `custom-${crypto.randomUUID()}`, + name: 'New Category', + searchValue: '', + order: categories.length, + isDefault: false, + }; + setCategories([...categories, newCategory]); + setHasUnsavedChanges(true); + }; + + const handleResetToDefaults = async () => { + try { + await saveUserSettings({ categories: defaultMailCategories }); + queryClient.invalidateQueries({ queryKey: trpc.settings.get.queryKey() }); + setHasUnsavedChanges(false); + toast.success('Reset to defaults'); + } catch (e) { + console.error(e); + toast.error('Failed to reset'); + } + }; + if (!categories.length) { return
Loading...
; } return ( -
- - +
+ {hasUnsavedChanges && ( + Unsaved changes + )} +
- } - > -
- - cat.id)} - strategy={verticalListSortingStrategy} - > - {categories.map((cat) => ( - - setPromptValues((prev) => ({ ...prev, [cat.id]: val })) - } - setActiveAiCat={setActiveAiCat} - isGeneratingQuery={isGeneratingQuery} - generateSearchQuery={generateSearchQuery} - handleFieldChange={handleFieldChange} - toggleDefault={toggleDefault} - /> - ))} - -
-
-
+ } + > +
+
+ +
+ + cat.id)} + strategy={verticalListSortingStrategy} + > + {categories.map((cat) => ( + + ))} + + +
+ ); -} \ No newline at end of file +} diff --git a/apps/mail/app/routes.ts b/apps/mail/app/routes.ts index 29b9244baf..57d9fbe23f 100644 --- a/apps/mail/app/routes.ts +++ b/apps/mail/app/routes.ts @@ -42,7 +42,7 @@ export default [ route('/danger-zone', '(routes)/settings/danger-zone/page.tsx'), route('/general', '(routes)/settings/general/page.tsx'), route('/labels', '(routes)/settings/labels/page.tsx'), - // route('/categories', '(routes)/settings/categories/page.tsx'), + route('/categories', '(routes)/settings/categories/page.tsx'), route('/notifications', '(routes)/settings/notifications/page.tsx'), route('/privacy', '(routes)/settings/privacy/page.tsx'), route('/security', '(routes)/settings/security/page.tsx'), diff --git a/apps/mail/components/mail/mail-list.tsx b/apps/mail/components/mail/mail-list.tsx index ced2721e96..6605905134 100644 --- a/apps/mail/components/mail/mail-list.tsx +++ b/apps/mail/components/mail/mail-list.tsx @@ -22,13 +22,9 @@ import { useSearchValue } from '@/hooks/use-search-value'; import { EmptyStateIcon } from '../icons/empty-state-svg'; import { highlightText } from '@/lib/email-utils.client'; import { cn, FOLDERS, formatDate } from '@/lib/utils'; -import { Avatar } from '../ui/avatar'; - import { useTRPC } from '@/providers/query-provider'; import { useThreadLabels } from '@/hooks/use-labels'; - import { useSettings } from '@/hooks/use-settings'; - import { useKeyState } from '@/hooks/use-hot-key'; import { VList, type VListHandle } from 'virtua'; import { BimiAvatar } from '../ui/bimi-avatar'; @@ -37,13 +33,11 @@ import { Badge } from '@/components/ui/badge'; import { useDraft } from '@/hooks/use-drafts'; import { Check, Star } from 'lucide-react'; import { Skeleton } from '../ui/skeleton'; - import { m } from '@/paraglide/messages'; import { useParams } from 'react-router'; - import { Button } from '../ui/button'; +import { Avatar } from '../ui/avatar'; import { useQueryState } from 'nuqs'; -import { Categories } from './mail'; import { useAtom } from 'jotai'; const Thread = memo( @@ -229,7 +223,7 @@ const Thread = memo( data-thread-id={idToUse} key={idToUse} className={cn( - 'hover:bg-offsetLight hover:bg-primary/5 group relative mx-1 flex cursor-pointer flex-col items-start rounded-lg py-2 text-left text-sm transition-all hover:opacity-100', + 'hover:bg-offsetLight dark:hover:bg-primary/5 group relative mx-1 flex cursor-pointer flex-col items-start rounded-lg py-2 text-left text-sm transition-all hover:opacity-100', (isMailSelected || isMailBulkSelected || isKeyboardFocused) && 'border-border bg-primary/5 opacity-100', isKeyboardFocused && 'ring-primary/50', @@ -239,7 +233,7 @@ const Thread = memo( >
@@ -610,7 +604,7 @@ const Draft = memo(({ message }: { message: { id: string } }) => {
{ - if (!shouldFilter) return; - - const currentCategory = category - ? allCategories.find((cat) => cat.id === category) - : allCategories.find((cat) => cat.id === 'All Mail'); - - if (currentCategory && searchValue.value === '') { - setSearchValue({ - value: currentCategory.searchValue || '', - highlight: '', - folder: '', - }); - } - }, [allCategories, category, shouldFilter, searchValue.value, setSearchValue]); - // Add event listener for refresh useEffect(() => { const handleRefresh = () => { @@ -851,7 +822,6 @@ export const MailList = memo( }, [isLoading, isFiltering, setSearchValue]); const clearFilters = () => { - setCategory(null); setSearchValue({ value: '', highlight: '', diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 7b563c4179..7fb7cb7a14 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -1,12 +1,3 @@ -// import { -// Dialog, -// DialogContent, -// DialogDescription, -// DialogFooter, -// DialogHeader, -// DialogTitle, -// DialogTrigger, -// } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuItem, @@ -18,88 +9,31 @@ import { Bell, Lightning, Mail, ScanEye, Tag, User, X, Search } from '../icons/i import { useCategorySettings, useDefaultCategoryId } from '@/hooks/use-categories'; import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; 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 { useCallback, useEffect, useRef, useState } from 'react'; import { useActiveConnection } from '@/hooks/use-connections'; -// import { useMutation, useQuery } from '@tanstack/react-query'; -// import { useTRPC } from '@/providers/query-provider'; - +import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; import { useMediaQuery } from '../../hooks/use-media-query'; - import useSearchLabels from '@/hooks/use-labels-search'; 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 { 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 { clearBulkSelectionAtom } from './use-mail'; import AISidebar from '@/components/ui/ai-sidebar'; import { useThreads } from '@/hooks/use-threads'; -// 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 { 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 { cn } from '@/lib/utils'; - import { m } from '@/paraglide/messages'; import { useQueryState } from 'nuqs'; +import { cn } from '@/lib/utils'; import { useAtom } from 'jotai'; -// import { toast } from 'sonner'; - -// interface ITag { -// id: string; -// name: string; -// usecase: string; -// text: string; -// } - -export const defaultLabels = [ - { - name: 'to respond', - usecase: 'emails you need to respond to. NOT sales, marketing, or promotions.', - }, - { - name: 'FYI', - usecase: - 'emails that are not important, but you should know about. NOT sales, marketing, or promotions.', - }, - { - name: 'comment', - usecase: - 'Team chats in tools like Google Docs, Slack, etc. NOT marketing, sales, or promotions.', - }, - { - name: 'notification', - usecase: 'Automated updates from services you use. NOT sales, marketing, or promotions.', - }, - { - name: 'promotion', - usecase: 'Sales, marketing, cold emails, special offers or promotions. NOT to respond to.', - }, - { - name: 'meeting', - usecase: 'Calendar events, invites, etc. NOT sales, marketing, or promotions.', - }, - { - name: 'billing', - usecase: 'Billing notifications. NOT sales, marketing, or promotions.', - }, -]; // const AutoLabelingSettings = () => { // const trpc = useTRPC(); @@ -461,7 +395,7 @@ export function MailLayout() { return ( -
+
-
+
{mail.bulkSelected.length === 0 ? (
@@ -528,16 +462,16 @@ export function MailLayout() { Clear )} - + {isMac ? '⌘' : 'Ctrl'}{' '} - K + K @@ -582,11 +516,11 @@ export function MailLayout() {
-
+
@@ -738,7 +672,7 @@ interface CategoryDropdownProps { } function CategoryDropdown({ isMultiSelectMode }: CategoryDropdownProps) { - const { systemLabels } = useLabels(); + const categorySettings = useCategorySettings(); const { setLabels, labels } = useSearchLabels(); const params = useParams<{ folder: string }>(); const folder = params?.folder ?? 'inbox'; @@ -746,14 +680,34 @@ function CategoryDropdown({ isMultiSelectMode }: CategoryDropdownProps) { if (folder !== 'inbox' || isMultiSelectMode) return null; - const handleLabelChange = (labelId: string) => { - const index = labels.indexOf(labelId); - if (index !== -1) { - const newLabels = [...labels]; - newLabels.splice(index, 1); - setLabels(newLabels); + const handleLabelChange = (searchValue: string) => { + const trimmed = searchValue.trim(); + if (!trimmed) { + setLabels([]); + return; + } + + const parsedLabels = trimmed + .split(',') + .map((label) => label.trim()) + .filter((label) => label.length > 0); + + if (parsedLabels.length === 0) { + setLabels([]); + return; + } + + const currentLabelsSet = new Set(labels); + const parsedLabelsSet = new Set(parsedLabels); + + const allLabelsSelected = parsedLabels.every((label) => currentLabelsSet.has(label)); + + if (allLabelsSelected) { + const updatedLabels = labels.filter((label) => !parsedLabelsSet.has(label)); + setLabels(updatedLabels); } else { - setLabels([...labels, labelId]); + const newLabelsSet = new Set([...labels, ...parsedLabels]); + setLabels(Array.from(newLabelsSet)); } }; @@ -769,7 +723,11 @@ function CategoryDropdown({ isMultiSelectMode }: CategoryDropdownProps) { aria-expanded={isOpen} aria-haspopup="menu" > - Categories + + {labels.length > 0 + ? `${labels.length} View${labels.length > 1 ? 's' : ''}` + : m['navigation.settings.categories']()} + @@ -781,20 +739,25 @@ function CategoryDropdown({ isMultiSelectMode }: CategoryDropdownProps) { role="menu" aria-label="Label filter options" > - {systemLabels.map((label) => ( + {categorySettings.map((category) => ( { e.preventDefault(); e.stopPropagation(); - handleLabelChange(label.id); + handleLabelChange(category.searchValue); }} role="menuitemcheckbox" - aria-checked={labels.includes(label.id)} + aria-checked={labels.includes(category.id)} > - {label.name.toLowerCase()} - {labels.includes(label.id) && } + {category.name.toLowerCase()} + {/* Special case: empty searchValue means "All Mail" - shows everything */} + {(category.searchValue === '' + ? labels.length === 0 + : category.searchValue.split(',').some((val) => labels.includes(val))) && ( + + )} ))} diff --git a/apps/mail/components/settings/settings-card.tsx b/apps/mail/components/settings/settings-card.tsx index 542861d7d9..f5ab1d91cf 100644 --- a/apps/mail/components/settings/settings-card.tsx +++ b/apps/mail/components/settings/settings-card.tsx @@ -1,13 +1,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import type { ReactNode, HTMLAttributes } from 'react'; import { PricingDialog } from '../ui/pricing-dialog'; import { cn } from '@/lib/utils'; -interface SettingsCardProps extends React.HTMLAttributes { +interface SettingsCardProps extends HTMLAttributes { title: string; description?: string; - children: React.ReactNode; - footer?: React.ReactNode; - action?: React.ReactNode; + children: ReactNode; + footer?: ReactNode; + action?: ReactNode; } export function SettingsCard({ diff --git a/apps/mail/components/ui/nav-main.tsx b/apps/mail/components/ui/nav-main.tsx index 3835bebd0a..392a3bbe71 100644 --- a/apps/mail/components/ui/nav-main.tsx +++ b/apps/mail/components/ui/nav-main.tsx @@ -19,7 +19,6 @@ import { useStats } from '@/hooks/use-stats'; import SidebarLabels from './sidebar-labels'; import { useCallback, useRef } from 'react'; import { BASE_URL } from '@/lib/constants'; -import { useQueryState } from 'nuqs'; import { Plus } from 'lucide-react'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; @@ -55,7 +54,6 @@ export function NavMain({ items }: NavMainProps) { const location = useLocation(); const pathname = location.pathname; const searchParams = new URLSearchParams(); - const [category] = useQueryState('category'); const trpc = useTRPC(); const { data: intercomToken } = useQuery(trpc.user.getIntercomToken.queryOptions()); @@ -108,9 +106,7 @@ export function NavMain({ items }: NavMainProps) { // Handle settings navigation if (item.isSettingsButton) { // Include current path with category query parameter if present - const currentPath = category - ? `${pathname}?category=${encodeURIComponent(category)}` - : pathname; + const currentPath = pathname; return `${item.url}?from=${encodeURIComponent(currentPath)}`; } @@ -137,14 +133,9 @@ export function NavMain({ items }: NavMainProps) { return `${item.url}?from=/mail`; } - // Handle category links - if (item.id === 'inbox' && category) { - return `${item.url}?category=${encodeURIComponent(category)}`; - } - return item.url; }, - [pathname, category, searchParams, isValidInternalUrl], + [pathname, searchParams, isValidInternalUrl], ); const { data: activeAccount } = useActiveConnection(); @@ -176,7 +167,9 @@ export function NavMain({ items }: NavMainProps) { loading: 'Creating label...', success: 'Label created successfully', error: 'Failed to create label', - finally: () => {refetch()}, + finally: () => { + refetch(); + }, }); }; diff --git a/apps/mail/config/navigation.ts b/apps/mail/config/navigation.ts index 7a538ea393..4b6d4f4176 100644 --- a/apps/mail/config/navigation.ts +++ b/apps/mail/config/navigation.ts @@ -173,11 +173,11 @@ export const navigationConfig: Record = { url: '/settings/labels', icon: Sheet, }, - // { - // title: m['navigation.settings.categories'](), - // url: '/settings/categories', - // icon: Tabs, - // }, + { + title: m['navigation.settings.categories'](), + url: '/settings/categories', + icon: Tabs, + }, { title: m['navigation.settings.signatures'](), url: '/settings/signatures', diff --git a/apps/mail/hooks/use-categories.ts b/apps/mail/hooks/use-categories.ts index f9972c0517..199283de23 100644 --- a/apps/mail/hooks/use-categories.ts +++ b/apps/mail/hooks/use-categories.ts @@ -1,10 +1,8 @@ import { useSettings } from '@/hooks/use-settings'; -import { useTRPC } from '@/providers/query-provider'; -import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; export interface CategorySetting { - id: 'Important' | 'All Mail' | 'Personal' | 'Promotions' | 'Updates' | 'Unread'; + id: string; name: string; searchValue: string; order: number; @@ -15,29 +13,33 @@ export interface CategorySetting { export function useCategorySettings(): CategorySetting[] { const { data } = useSettings(); - const trpc = useTRPC(); - const { data: defaultCategories = [] } = useQuery( - trpc.categories.defaults.queryOptions(void 0, { staleTime: Infinity }), - ); - - if (!defaultCategories.length) return []; - const merged = useMemo(() => { const overrides = (data?.settings.categories as CategorySetting[] | undefined) ?? []; - const overridden = defaultCategories.map((cat) => { - const custom = overrides.find((c) => c.id === cat.id); - return custom - ? { - ...cat, - ...custom, - } - : cat; - }); - - const sorted = overridden.sort((a, b) => a.order - b.order); + const sorted = overrides.sort((a, b) => a.order - b.order); + + // If no categories are defined, provide default ones + if (sorted.length === 0) { + return [ + { + id: 'All Mail', + name: 'All Mail', + searchValue: '', + order: 0, + isDefault: true, + }, + { + id: 'Unread', + name: 'Unread', + searchValue: 'UNREAD', + order: 1, + isDefault: false, + }, + ]; + } + return sorted; - }, [data?.settings.categories, defaultCategories]); + }, [data?.settings.categories]); return merged; } @@ -46,4 +48,4 @@ export function useDefaultCategoryId(): string { const categories = useCategorySettings(); const defaultCat = categories.find((c) => c.isDefault) ?? categories[0]; return defaultCat?.id ?? 'All Mail'; -} \ No newline at end of file +} diff --git a/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx b/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx index bdcf6e153c..ed0dc70ab5 100644 --- a/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx +++ b/apps/mail/lib/hotkeys/mail-list-hotkeys.tsx @@ -9,7 +9,6 @@ import { useShortcuts } from './use-hotkey-utils'; import { useThreads } from '@/hooks/use-threads'; import { cleanSearchValue } from '@/lib/utils'; import { m } from '@/paraglide/messages'; -import { useQueryState } from 'nuqs'; import { toast } from 'sonner'; export function MailListHotkeys() { @@ -18,7 +17,6 @@ export function MailListHotkeys() { const [, items] = useThreads(); const hoveredEmailId = useRef(null); const categories = Categories(); - const [, setCategory] = useQueryState('category'); const [searchValue, setSearchValue] = useSearchValue(); const pathname = useLocation().pathname; const params = useParams<{ folder: string }>(); @@ -179,7 +177,7 @@ export function MailListHotkeys() { if (pathname?.includes('/mail/inbox')) { const cat = categories.find((cat) => cat.id === category); if (!cat) { - setCategory(null); + // setCategory(null); setSearchValue({ value: '', highlight: searchValue.highlight, @@ -187,7 +185,7 @@ export function MailListHotkeys() { }); return; } - setCategory(cat.id); + // setCategory(cat.id); setSearchValue({ value: `${cat.searchValue} ${cleanSearchValue(searchValue.value).trim().length ? `AND ${cleanSearchValue(searchValue.value)}` : ''}`, highlight: searchValue.highlight, @@ -195,7 +193,7 @@ export function MailListHotkeys() { }); } }, - [categories, pathname, searchValue, setCategory, setSearchValue], + [categories, pathname, searchValue, setSearchValue], ); const switchCategoryByIndex = useCallback( diff --git a/apps/mail/messages/en.json b/apps/mail/messages/en.json index e68e5c0419..9a14266af0 100644 --- a/apps/mail/messages/en.json +++ b/apps/mail/messages/en.json @@ -416,7 +416,7 @@ "signatures": "Signatures", "shortcuts": "Shortcuts", "labels": "Labels", - "categories": "Categories", + "categories": "Views", "dangerZone": "Danger Zone", "deleteAccount": "Delete Account", "privacy": "Privacy" diff --git a/apps/server/src/lib/schemas.ts b/apps/server/src/lib/schemas.ts index f666cba464..f350da113c 100644 --- a/apps/server/src/lib/schemas.ts +++ b/apps/server/src/lib/schemas.ts @@ -37,7 +37,12 @@ export const createDraftData = z.object({ export type CreateDraftData = z.infer; export const mailCategorySchema = z.object({ - id: z.enum(['Important', 'All Mail', 'Personal', 'Promotions', 'Updates', 'Unread']), + id: z + .string() + .regex( + /^[a-zA-Z0-9\-_ ]+$/, + 'Category ID must contain only alphanumeric characters, hyphens, underscores, and spaces', + ), name: z.string(), searchValue: z.string(), order: z.number().int(), @@ -51,7 +56,7 @@ export const defaultMailCategories: MailCategory[] = [ { id: 'Important', name: 'Important', - searchValue: 'is:important NOT is:sent NOT is:draft', + searchValue: 'IMPORTANT', order: 0, icon: 'Lightning', isDefault: false, @@ -59,39 +64,15 @@ export const defaultMailCategories: MailCategory[] = [ { id: 'All Mail', name: 'All Mail', - searchValue: 'NOT is:draft (is:inbox OR (is:sent AND to:me))', + searchValue: '', order: 1, icon: 'Mail', isDefault: true, }, - { - id: 'Personal', - name: 'Personal', - searchValue: 'is:personal NOT is:sent NOT is:draft', - order: 2, - icon: 'User', - isDefault: false, - }, - { - id: 'Promotions', - name: 'Promotions', - searchValue: 'is:promotions NOT is:sent NOT is:draft', - order: 3, - icon: 'Tag', - isDefault: false, - }, - { - id: 'Updates', - name: 'Updates', - searchValue: 'is:updates NOT is:sent NOT is:draft', - order: 4, - icon: 'Bell', - isDefault: false, - }, { id: 'Unread', name: 'Unread', - searchValue: 'is:unread NOT is:sent NOT is:draft', + searchValue: 'UNREAD', order: 5, icon: 'ScanEye', isDefault: false, diff --git a/apps/server/src/routes/agent/index.ts b/apps/server/src/routes/agent/index.ts index ef197955da..34682c30e4 100644 --- a/apps/server/src/routes/agent/index.ts +++ b/apps/server/src/routes/agent/index.ts @@ -53,11 +53,11 @@ import type { WSMessage } from 'partyserver'; import { tools as authTools } from './tools'; import { processToolCalls } from './utils'; import { openai } from '@ai-sdk/openai'; +import { Effect, pipe } from 'effect'; import { createDb } from '../../db'; import { DriverRpcDO } from './rpc'; import type { Message } from 'ai'; import { eq } from 'drizzle-orm'; -import { Effect } from 'effect'; const decoder = new TextDecoder(); @@ -1407,181 +1407,206 @@ export class ZeroDriver extends Agent { return folderName; } - async getThreadsFromDB(params: { + private queryThreads(params: { labelIds?: string[]; folder?: string; q?: string; - maxResults?: number; pageToken?: string; - }): Promise { - const { labelIds = [], q, maxResults = 50, pageToken } = params; - let folder = params.folder ?? 'inbox'; - - try { - folder = this.normalizeFolderName(folder); - // TODO: Sometimes the DO storage is resetting - // const folderThreadCount = (await this.count()).find((c) => c.label === folder)?.count; - // const currentThreadCount = await this.getThreadCount(); - - // if (folderThreadCount && folderThreadCount > currentThreadCount && folder) { - // this.ctx.waitUntil(this.syncThreads(folder)); - // } - - // Build WHERE conditions - const whereConditions: string[] = []; + maxResults: number; + }) { + return Effect.sync(() => { + const { labelIds = [], folder, q, pageToken, maxResults } = params; - // Add folder condition (maps to specific label) - if (folder) { - const folderLabel = folder.toUpperCase(); - whereConditions.push(`EXISTS ( - SELECT 1 FROM json_each(latest_label_ids) WHERE value = '${folderLabel}' - )`); - } + console.log('[queryThreads] params:', { labelIds, folder, q, pageToken, maxResults }); - // Add label conditions (OR logic for multiple labels) - if (labelIds.length > 0) { - if (labelIds.length === 1) { - whereConditions.push(`EXISTS ( - SELECT 1 FROM json_each(latest_label_ids) WHERE value = '${labelIds[0]}' - )`); - } else { - // Multiple labels with OR logic - const multiLabelCondition = labelIds - .map( - (labelId) => - `EXISTS (SELECT 1 FROM json_each(latest_label_ids) WHERE value = '${labelId}')`, - ) - .join(' OR '); - whereConditions.push(`(${multiLabelCondition})`); - } + if (!folder && labelIds.length === 0 && !q && !pageToken) { + console.log('[queryThreads] Case: all threads'); + return this.sql` + SELECT id, latest_received_on + FROM threads + ORDER BY latest_received_on DESC + LIMIT ${maxResults} + `; } - // // Add search query condition - if (q) { - const searchTerm = q.replace(/'/g, "''"); // Escape single quotes - whereConditions.push(`( - latest_subject LIKE '%${searchTerm}%' OR - latest_sender LIKE '%${searchTerm}%' - )`); + if (folder && labelIds.length === 0 && !q && !pageToken) { + const folderLabel = folder.toUpperCase(); + console.log('[queryThreads] Case: folder only', { folderLabel }); + return this.sql` + SELECT id, latest_received_on + FROM threads + WHERE EXISTS ( + SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${folderLabel} + ) + ORDER BY latest_received_on DESC + LIMIT ${maxResults} + `; } - // Add cursor condition - if (pageToken) { - whereConditions.push(`latest_received_on < '${pageToken}'`); + if (labelIds.length === 1 && !folder && !q && !pageToken) { + const labelId = labelIds[0]; + console.log('[queryThreads] Case: single label only', { labelId }); + return this.sql` + SELECT id, latest_received_on + FROM threads + WHERE EXISTS ( + SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${labelId} + ) + ORDER BY latest_received_on DESC + LIMIT ${maxResults} + `; } - // Execute query based on conditions - let result; - - if (whereConditions.length === 0) { - // No conditions - result = this.sql` + // Handle folder + labelIds combination (supports pagination) + if (folder && labelIds.length > 0 && !q) { + const folderLabel = folder.toUpperCase(); + + // De-duplicate labelIds and remove folder label if it's already included + // Cap labelIds length to prevent resource exhaustion + const maxLabelIds = 5; + const uniqueLabelIds = [...new Set(labelIds + .filter(id => id.toUpperCase() !== folderLabel) + .slice(0, maxLabelIds) + )]; + + console.log('[queryThreads] Case: folder + labelIds', { + folderLabel, + originalLabelIds: labelIds, + uniqueLabelIds, + pageToken + }); + + if (uniqueLabelIds.length === 0) { + // Only folder filter needed, handle separately + return this.sql` SELECT id, latest_received_on FROM threads + WHERE EXISTS ( + SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${folderLabel} + ) AND latest_received_on < COALESCE(${pageToken || null}, 9223372036854775807) ORDER BY latest_received_on DESC LIMIT ${maxResults} `; - } else if (whereConditions.length === 1) { - // Single condition - const condition = whereConditions[0]; - if (condition.includes('latest_received_on <')) { - const cursorValue = pageToken!; - result = this.sql` - SELECT id, latest_received_on - FROM threads - WHERE latest_received_on < ${cursorValue} - ORDER BY latest_received_on DESC - LIMIT ${maxResults} - `; - } else if (folder) { - // Folder condition - const folderLabel = folder.toUpperCase(); - result = this.sql` - SELECT id, latest_received_on - FROM threads - WHERE EXISTS ( - SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${folderLabel} - ) - ORDER BY latest_received_on DESC - LIMIT ${maxResults} - `; - } else { - // Single label condition - const labelId = labelIds[0]; - result = this.sql` - SELECT id, latest_received_on - FROM threads - WHERE EXISTS ( - SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${labelId} - ) - ORDER BY latest_received_on DESC - LIMIT ${maxResults} - `; } - } else { - // Multiple conditions - handle combinations - if (folder && labelIds.length === 0 && pageToken) { - // Folder + cursor - const folderLabel = folder.toUpperCase(); - result = this.sql` - SELECT id, latest_received_on - FROM threads - WHERE EXISTS ( - SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${folderLabel} - ) AND latest_received_on < ${pageToken} - ORDER BY latest_received_on DESC - LIMIT ${maxResults} - `; - } else if (labelIds.length === 1 && pageToken && !folder) { - // Single label + cursor - const labelId = labelIds[0]; - result = this.sql` - SELECT id, latest_received_on - FROM threads + + // Use improved JSON-based approach that handles any number of labelIds + const labelsJson = JSON.stringify(uniqueLabelIds); + return this.sql` + SELECT id, latest_received_on + FROM threads + WHERE latest_received_on < COALESCE(${pageToken || null}, 9223372036854775807) + AND EXISTS ( + SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${folderLabel} + ) AND ( + SELECT COUNT(DISTINCT required.value) + FROM json_each(${labelsJson}) AS required WHERE EXISTS ( - SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${labelId} - ) AND latest_received_on < ${pageToken} - ORDER BY latest_received_on DESC - LIMIT ${maxResults} - `; - } else { - // For now, fallback to just cursor if complex combinations - const cursorValue = pageToken || ''; - result = this.sql` - SELECT id, latest_received_on - FROM threads - WHERE latest_received_on < ${cursorValue} - ORDER BY latest_received_on DESC - LIMIT ${maxResults} - `; - } + SELECT 1 FROM json_each(latest_label_ids) lbl + WHERE lbl.value = required.value + ) + ) = ${uniqueLabelIds.length} + ORDER BY latest_received_on DESC + LIMIT ${maxResults} + `; + } + + if (folder && labelIds.length === 0 && !q && pageToken) { + const folderLabel = folder.toUpperCase(); + console.log('[queryThreads] Case: folder + pageToken', { folderLabel, pageToken }); + return this.sql` + SELECT id, latest_received_on + FROM threads + WHERE EXISTS ( + SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${folderLabel} + ) AND latest_received_on < ${pageToken} + ORDER BY latest_received_on DESC + LIMIT ${maxResults} + `; } - if (result?.length) { - const threads = result.map((row) => ({ - id: String(row.id), - historyId: null, - })); + if (labelIds.length === 1 && !folder && !q && pageToken) { + const labelId = labelIds[0]; + console.log('[queryThreads] Case: single label + pageToken', { labelId, pageToken }); + return this.sql` + SELECT id, latest_received_on + FROM threads + WHERE EXISTS ( + SELECT 1 FROM json_each(latest_label_ids) WHERE value = ${labelId} + ) AND latest_received_on < ${pageToken} + ORDER BY latest_received_on DESC + LIMIT ${maxResults} + `; + } - // Use latest_received_on for pagination cursor - const nextPageToken = - threads.length === maxResults && result.length > 0 - ? String(result[result.length - 1].latest_received_on) - : null; + if (pageToken) { + console.log('[queryThreads] Case: pageToken fallback', { pageToken }); + return this.sql` + SELECT id, latest_received_on + FROM threads + WHERE latest_received_on < ${pageToken} + ORDER BY latest_received_on DESC + LIMIT ${maxResults} + `; + } + + console.log('[queryThreads] Default case: all threads'); + return this.sql` + SELECT id, latest_received_on + FROM threads + ORDER BY latest_received_on DESC + LIMIT ${maxResults} + `; + }); + } + + async getThreadsFromDB(params: { + labelIds?: string[]; + folder?: string; + q?: string; + maxResults?: number; + pageToken?: string; + }): Promise { + const { maxResults = 50 } = params; + const normalizedParams = { + ...params, + folder: params.folder ? this.normalizeFolderName(params.folder) : undefined, + maxResults, + }; + const program = pipe( + this.queryThreads(normalizedParams), + Effect.map((result) => { + if (result?.length) { + const threads = result.map((row) => ({ + id: String(row.id), + historyId: null, + })); + + // Use latest_received_on for pagination cursor + const nextPageToken = + threads.length === maxResults && result.length > 0 + ? String(result[result.length - 1].latest_received_on) + : null; + + return { + threads, + nextPageToken, + }; + } return { - threads, - nextPageToken, + threads: [], + nextPageToken: '', }; - } - return { - threads: [], - nextPageToken: '', - }; - } catch (error) { - console.error('Failed to get threads from database:', error); - throw error; - } + }), + Effect.catchAll((error) => + Effect.sync(() => { + console.error('Failed to get threads from database:', error); + throw error; + }), + ), + ); + + return await Effect.runPromise(program); } async modifyThreadLabelsByName( diff --git a/package.json b/package.json index ebbc285535..2c7a9ec931 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "prepare": "husky", "nizzy": "tsx ./packages/cli/src/cli.ts", "postinstall": "pnpm nizzy sync", + "precommit": "pnpm dlx oxlint@latest --deny-warnings", "dev": "turbo run dev", "build": "turbo run build", "build:frontend": "pnpm run --filter=@zero/mail build",