From 01e2adf492015702b612bfab06c955615b92cb1f Mon Sep 17 00:00:00 2001 From: Adam <13007539+MrgSub@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:34:29 -0700 Subject: [PATCH] Redesign mail categories to use label-based filtering instead of search queries (#1902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Redesigned Mail Categories with Label-Based Filtering ## Description Reimplemented the mail categories feature to use label-based filtering instead of search queries. This change makes it easier for users to customize and manage their inbox views by selecting specific labels rather than writing complex search queries. The PR enables the categories settings page in the navigation and completely redesigns the UI to focus on label selection. Users can now add, delete, reorder, and set default categories with a more intuitive interface. ## Type of Change - ✨ New feature (non-breaking change which adds functionality) - 🎨 UI/UX improvement ## Areas Affected - [x] User Interface/Experience - [x] Data Storage/Management ## Testing Done - [x] Manual testing performed ## Checklist - [x] I have performed a self-review of my code - [x] My changes generate no new warnings - [x] My code follows the project's style guidelines ## Additional Notes The PR also includes improvements to the thread querying logic in the backend to better support label-based filtering. The categories feature is now called "Views" in the UI to better reflect its purpose. --- _By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._ ## Summary by CodeRabbit * **New Features** * Categories settings page is now accessible from navigation. * Categories (now called "Views") can be managed with multi-select label filters, drag-and-drop reordering, add/delete actions, and unsaved changes tracking. * Save and reset options are available for category changes. * **Improvements** * Category selection supports multi-label filtering with a dropdown menu. * UI styling updated for better dark mode support and usability. * Localization updated to rename "Categories" to "Views". * Navigation and mail list no longer use category query parameters, simplifying URL handling. * **Bug Fixes** * Removed unused and AI-related code for category search queries. * **Chores** * Added a pre-commit script to enforce linting before commits. * Refactored internal logic for category and thread management for better maintainability. --- .husky/pre-commit | 2 +- AGENT.md | 7 + .../app/(routes)/settings/categories/page.tsx | 515 ++++++++++-------- 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 | 46 +- 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 | 351 ++++++------ package.json | 1 + 15 files changed, 584 insertions(+), 608 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index e02c24e2b..22c245704 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 68e20f816..e0f5cbfc3 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 c58e905fc..1635d44ab 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 })), - ); - }, - [], - ); + 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); useEffect(() => { - if (!defaultMailCategories.length) return; + setCategories(initialCategories); + setHasUnsavedChanges(false); + }, [data?.settings?.categories]); - const stored = data?.settings?.categories ?? []; - - 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]); - - 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 oldIndex = categories.findIndex((cat) => cat.id === active.id); + const newIndex = categories.findIndex((cat) => cat.id === over.id); - const handleSave = async () => { - if (categories.filter((c) => c.isDefault).length !== 1) { - toast.error('Please mark exactly one category as default'); - return; - } - - 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 29b9244ba..57d9fbe23 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 ced2721e9..660590513 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 7b563c417..7fb7cb7a1 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 542861d7d..f5ab1d91c 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 3835bebd0..392a3bbe7 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 7a538ea39..4b6d4f417 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 f9972c051..199283de2 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 = 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, + }, + ]; + } - const sorted = overridden.sort((a, b) => a.order - b.order); 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 bdcf6e153..ed0dc70ab 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 e68e5c041..9a14266af 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 f666cba46..f350da113 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 ef197955d..34682c30e 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,6 +1407,158 @@ export class ZeroDriver extends Agent { return folderName; } + private queryThreads(params: { + labelIds?: string[]; + folder?: string; + q?: string; + pageToken?: string; + maxResults: number; + }) { + return Effect.sync(() => { + const { labelIds = [], folder, q, pageToken, maxResults } = params; + + console.log('[queryThreads] params:', { labelIds, folder, q, pageToken, maxResults }); + + 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} + `; + } + + 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} + `; + } + + 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} + `; + } + + // 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} + `; + } + + // 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) 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 (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} + `; + } + + 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; @@ -1414,174 +1566,47 @@ export class ZeroDriver extends Agent { maxResults?: number; pageToken?: string; }): Promise { - const { labelIds = [], q, maxResults = 50, pageToken } = params; - let folder = params.folder ?? 'inbox'; + const { maxResults = 50 } = params; + const normalizedParams = { + ...params, + folder: params.folder ? this.normalizeFolderName(params.folder) : undefined, + maxResults, + }; - 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(); + const program = pipe( + this.queryThreads(normalizedParams), + Effect.map((result) => { + if (result?.length) { + const threads = result.map((row) => ({ + id: String(row.id), + historyId: null, + })); - // if (folderThreadCount && folderThreadCount > currentThreadCount && folder) { - // this.ctx.waitUntil(this.syncThreads(folder)); - // } + // Use latest_received_on for pagination cursor + const nextPageToken = + threads.length === maxResults && result.length > 0 + ? String(result[result.length - 1].latest_received_on) + : null; - // Build WHERE conditions - const whereConditions: string[] = []; - - // 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}' - )`); - } - - // 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})`); + return { + threads, + nextPageToken, + }; } - } - - // // 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}%' - )`); - } - - // Add cursor condition - if (pageToken) { - whereConditions.push(`latest_received_on < '${pageToken}'`); - } - - // Execute query based on conditions - let result; - - if (whereConditions.length === 0) { - // No conditions - result = this.sql` - SELECT id, latest_received_on - FROM threads - 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 - 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} - `; - } - } - - 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, + 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 ebbc28553..2c7a9ec93 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",