Redesign mail categories to use label-based filtering instead of search queries (#1902)

# 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._

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Adam
2025-08-04 10:34:29 -07:00
committed by GitHub
parent c72d2fb39a
commit 01e2adf492
15 changed files with 584 additions and 608 deletions

View File

@@ -1 +1 @@
pnpm lint-staged
pnpm dlx oxlint@1.9.0 --deny-warnings

View File

@@ -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

View File

@@ -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<HTMLInputElement>) => {
handleFieldChange(cat.id, 'name', e.target.value);
},
[cat.id, handleFieldChange],
);
return (
<div
ref={setNodeRef}
style={style}
className={`rounded-lg border border-border bg-card p-4 shadow-sm ${
isDragging ? 'opacity-50 scale-95' : ''
className={`border-border bg-card rounded-lg border p-4 shadow-sm ${
isDragging ? 'scale-95 opacity-50' : ''
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted/50 transition-colors"
className="hover:bg-muted/50 cursor-grab rounded p-1 transition-colors active:cursor-grabbing"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
<Badge variant="outline" className="text-xs font-normal bg-background">
<GripVertical className="text-muted-foreground h-4 w-4" />
</button>
<Badge variant="outline" className="bg-background text-xs font-normal">
{cat.id}
</Badge>
{cat.isDefault && (
<Badge className="bg-blue-500/10 text-blue-500 border-blue-200 text-xs">
Default
</Badge>
<Badge className="border-blue-200 bg-blue-500/10 text-xs text-blue-500">Default</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleDeleteClick}
className="text-destructive hover:bg-destructive/10 h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
<Switch
id={`default-${cat.id}`}
checked={!!cat.isDefault}
onCheckedChange={() => toggleDefault(cat.id)}
onCheckedChange={handleToggleDefault}
/>
<Label htmlFor={`default-${cat.id}`} className="text-xs font-normal cursor-pointer">
<Label htmlFor={`default-${cat.id}`} className="cursor-pointer text-xs font-normal">
Set as Default
</Label>
</div>
</div>
<div className="grid grid-cols-12 gap-4 items-start">
<div className="grid grid-cols-12 items-start gap-4">
<div className="col-span-12 sm:col-span-6">
<Label className="text-xs mb-1.5 block">Display Name</Label>
<Input
className="h-8 text-sm"
value={cat.name}
onChange={(e) => handleFieldChange(cat.id, 'name', e.target.value)}
/>
<Label className="mb-1.5 block text-xs">Display Name</Label>
<Input className="h-8 text-sm" value={cat.name} onChange={handleNameChange} />
</div>
<div className="col-span-12 sm:col-span-6">
<Label className="text-xs mb-1.5 block">Search Query</Label>
<div className="relative">
<Input
className="pr-8 h-8 text-sm font-mono"
value={cat.searchValue}
onChange={(e) => handleFieldChange(cat.id, 'searchValue', e.target.value)}
/>
<Popover
open={isActiveAi}
onOpenChange={(open) => {
if (open) {
setActiveAiCat(cat.id);
} else {
setActiveAiCat(null);
}
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 bg-background hover:bg-secondary rounded-full p-1"
aria-label="Generate search query with AI"
>
{isGeneratingQuery && isActiveAi ? (
<Loader className="h-3 w-3 animate-spin" />
) : (
<Sparkles className="h-3 w-3 fill-[#8B5CF6]" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3 space-y-3" sideOffset={4} align="end">
<div className="space-y-1">
<Label className="text-xs">Natural Language Query</Label>
<Input
className="h-8 text-sm"
placeholder="Describe the emails to include…"
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
/>
</div>
<div className="text-xs text-muted-foreground">
Example: "emails that mention quarterly reports"
</div>
<Button
size="sm"
className="w-full"
disabled={!promptValue.trim() || isGeneratingQuery}
onClick={async () => {
const prompt = promptValue.trim();
if (!prompt) return;
try {
const res = await generateSearchQuery({ query: prompt });
handleFieldChange(cat.id, 'searchValue', res.query);
toast.success('Search query generated');
setActiveAiCat(null);
} catch (err) {
console.error(err);
toast.error('Failed to generate query');
<div className="col-span-6">
<Label className="mb-1.5 block text-xs">Label Filters</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-8 w-full justify-between text-sm">
<span>
{(() => {
const selectedLabels = cat.searchValue
? cat.searchValue.split(',').filter(Boolean)
: [];
if (selectedLabels.length === 0) return 'Select labels...';
if (selectedLabels.length === 1) {
const label = allLabels.find((l) => l.id === selectedLabels[0]);
return label?.name || 'Unknown label';
}
}}
>
{isGeneratingQuery && isActiveAi ? (
<Loader className="h-3 w-3 animate-spin mr-1" />
) : (
<Sparkles className="h-3 w-3 fill-white mr-1" />
)}
Generate Query
</Button>
</PopoverContent>
</Popover>
</div>
return `${selectedLabels.length} labels selected`;
})()}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-64 w-56 overflow-y-auto">
{allLabels.map((label) => {
const selectedLabels = cat.searchValue
? cat.searchValue.split(',').filter(Boolean)
: [];
const isSelected = selectedLabels.includes(label.id);
return (
<DropdownMenuItem
key={label.id}
className="flex cursor-pointer items-center justify-between"
onClick={handleLabelToggle(label.id, isSelected)}
>
<div className="flex items-center gap-2">
<Badge
variant={label.type === 'system' ? 'secondary' : 'outline'}
className="text-xs"
>
{label.name}
</Badge>
</div>
{isSelected && <Check className="h-4 w-4" />}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
@@ -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<CategorySetting[]>([]);
const [activeAiCat, setActiveAiCat] = useState<string | null>(null);
const [promptValues, setPromptValues] = useState<Record<string, string>>({});
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<CategorySetting[]>(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 <div className="text-muted-foreground p-6">Loading...</div>;
}
return (
<div className="grid gap-6 max-w-[900px] mx-auto">
<SettingsCard
title="Mail Categories"
description="Customise how Zero shows the category tabs in your inbox. Drag and drop to reorder."
footer={
<div className="px-6">
<Button type="button" disabled={isPending} onClick={handleSave}>
{isPending ? 'Saving…' : 'Save Changes'}
<SettingsCard
title={m['navigation.settings.categories']()}
description="Customise how Zero shows the category tabs in your inbox. Drag and drop to reorder."
footer={
<div className="flex justify-between">
<Button type="button" variant="outline" onClick={handleResetToDefaults}>
Reset to Defaults
</Button>
<div className="flex gap-2">
{hasUnsavedChanges && (
<span className="flex items-center text-sm text-amber-600">Unsaved changes</span>
)}
<Button type="button" onClick={handleSave} disabled={!hasUnsavedChanges}>
Save Changes
</Button>
</div>
}
>
<div className="space-y-4 px-6">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={categories.map((cat) => cat.id)}
strategy={verticalListSortingStrategy}
>
{categories.map((cat) => (
<SortableCategoryItem
key={cat.id}
cat={cat}
isActiveAi={activeAiCat === cat.id}
promptValue={promptValues[cat.id] ?? ''}
setPromptValue={(val) =>
setPromptValues((prev) => ({ ...prev, [cat.id]: val }))
}
setActiveAiCat={setActiveAiCat}
isGeneratingQuery={isGeneratingQuery}
generateSearchQuery={generateSearchQuery}
handleFieldChange={handleFieldChange}
toggleDefault={toggleDefault}
/>
))}
</SortableContext>
</DndContext>
</div>
</SettingsCard>
</div>
}
>
<div className="space-y-4">
<div className="flex justify-end">
<Button onClick={handleAddCategory} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Add Category
</Button>
</div>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={categories.map((cat) => cat.id)}
strategy={verticalListSortingStrategy}
>
{categories.map((cat) => (
<SortableCategoryItem
key={cat.id}
cat={cat}
handleFieldChange={handleFieldChange}
toggleDefault={toggleDefault}
handleDeleteCategory={handleDeleteCategory}
allLabels={allLabels}
/>
))}
</SortableContext>
</DndContext>
</div>
</SettingsCard>
);
}
}

View File

@@ -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'),

View File

@@ -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(
>
<div
className={cn(
'dark:bg-panelDark absolute right-2 z-25 flex -translate-y-1/2 items-center gap-1 rounded-xl border bg-white p-1 opacity-0 shadow-sm group-hover:opacity-100',
'dark:bg-panelDark z-25 absolute right-2 flex -translate-y-1/2 items-center gap-1 rounded-xl border bg-white p-1 opacity-0 shadow-sm group-hover:opacity-100',
index === 0 ? 'top-4' : 'top-[-1]',
)}
>
@@ -610,7 +604,7 @@ const Draft = memo(({ message }: { message: { id: string } }) => {
<div
key={message.id}
className={cn(
'hover:bg-offsetLight hover:bg-primary/5 group relative mx-[8px] flex cursor-pointer flex-col items-start overflow-clip rounded-[10px] border-transparent py-3 text-left text-sm transition-all hover:opacity-100',
'hover:bg-offsetLight dark:hover:bg-primary/5 group relative mx-[8px] flex cursor-pointer flex-col items-start overflow-clip rounded-[10px] border-transparent py-3 text-left text-sm transition-all hover:opacity-100',
)}
>
<div
@@ -667,7 +661,6 @@ export const MailList = memo(
const { data: settingsData } = useSettings();
const [, setThreadId] = useQueryState('threadId');
const [, setDraftId] = useQueryState('draftId');
const [category, setCategory] = useQueryState('category');
const [searchValue, setSearchValue] = useSearchValue();
const [{ refetch, isLoading, isFetching, isFetchingNextPage, hasNextPage }, items, , loadMore] =
useThreads();
@@ -681,28 +674,6 @@ export const MailList = memo(
itemsRef.current = items;
}, [items]);
const allCategories = Categories();
// Skip category filtering for drafts, spam, sent, archive, and bin pages
const shouldFilter = !['draft', 'spam', 'sent', 'archive', 'bin'].includes(folder || '');
// Set initial category search value only if not in special folders
useEffect(() => {
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: '',

View File

@@ -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 (
<TooltipProvider delayDuration={0}>
<PricingDialog />
<div className="rounded-inherit relative z-5 flex p-0 md:mr-0.5 md:mt-1">
<div className="rounded-inherit z-5 relative flex p-0 md:mr-0.5 md:mt-1">
<ResizablePanelGroup
direction="horizontal"
autoSaveId="mail-panel-layout"
@@ -481,11 +415,11 @@ export function MailLayout() {
<div className="w-full md:h-[calc(100dvh-10px)]">
<div
className={cn(
'sticky top-0 z-15 flex items-center justify-between gap-1.5 p-2 pb-0 transition-colors',
'z-15 sticky top-0 flex items-center justify-between gap-1.5 p-2 pb-0 transition-colors',
)}
>
<div className="w-full">
<div className="grid grid-cols-12 gap-2 mt-1">
<div className="mt-1 grid grid-cols-12 gap-2">
<SidebarToggle className="col-span-1 h-fit px-2" />
{mail.bulkSelected.length === 0 ? (
<div className="col-span-10 flex gap-2">
@@ -528,16 +462,16 @@ export function MailLayout() {
Clear
</Button>
)}
<kbd className="bg-muted text-md pointer-events-none mr-0.5 hidden h-7 select-none flex-row items-center gap-1 rounded-md border-none px-2 font-medium leading-[0]! opacity-100 sm:flex dark:bg-[#262626] dark:text-[#929292]">
<kbd className="bg-muted text-md leading-[0]! pointer-events-none mr-0.5 hidden h-7 select-none flex-row items-center gap-1 rounded-md border-none px-2 font-medium opacity-100 sm:flex dark:bg-[#262626] dark:text-[#929292]">
<span
className={cn(
'h-min leading-[0.2]!',
'leading-[0.2]! h-min',
isMac ? 'mt-px text-lg' : 'text-sm',
)}
>
{isMac ? '⌘' : 'Ctrl'}{' '}
</span>
<span className="h-min text-sm leading-[0.2]!"> K</span>
<span className="leading-[0.2]! h-min text-sm"> K</span>
</kbd>
</span>
</Button>
@@ -582,11 +516,11 @@ export function MailLayout() {
<div
className={cn(
`${category === 'Important' ? 'bg-[#F59E0D]' : category === 'All Mail' ? 'bg-[#006FFE]' : category === 'Personal' ? 'bg-[#39ae4a]' : category === 'Updates' ? 'bg-[#8B5CF6]' : category === 'Promotions' ? 'bg-[#F43F5E]' : category === 'Unread' ? 'bg-[#FF4800]' : 'bg-[#F59E0D]'}`,
'relative z-5 h-0.5 w-full transition-opacity',
'z-5 relative h-0.5 w-full transition-opacity',
isFetching ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="relative z-1 h-[calc(100dvh-(2px+2px))] overflow-hidden pt-0 md:h-[calc(100dvh-4rem)]">
<div className="z-1 relative h-[calc(100dvh-(2px+2px))] overflow-hidden pt-0 md:h-[calc(100dvh-4rem)]">
<MailList />
</div>
</div>
@@ -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"
>
<span className="text-xs font-medium">Categories</span>
<span className="text-xs font-medium">
{labels.length > 0
? `${labels.length} View${labels.length > 1 ? 's' : ''}`
: m['navigation.settings.categories']()}
</span>
<ChevronDown
className={`black:text-white text-muted-foreground h-2 w-2 transition-transform duration-200 ${isOpen ? 'rotate-180' : 'rotate-0'}`}
/>
@@ -781,20 +739,25 @@ function CategoryDropdown({ isMultiSelectMode }: CategoryDropdownProps) {
role="menu"
aria-label="Label filter options"
>
{systemLabels.map((label) => (
{categorySettings.map((category) => (
<DropdownMenuItem
key={label.id}
key={category.id}
className="flex cursor-pointer items-center gap-2 hover:bg-white/10"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleLabelChange(label.id);
handleLabelChange(category.searchValue);
}}
role="menuitemcheckbox"
aria-checked={labels.includes(label.id)}
aria-checked={labels.includes(category.id)}
>
<span className="text-muted-foreground capitalize">{label.name.toLowerCase()}</span>
{labels.includes(label.id) && <Check className="ml-auto h-3 w-3" />}
<span className="text-muted-foreground capitalize">{category.name.toLowerCase()}</span>
{/* Special case: empty searchValue means "All Mail" - shows everything */}
{(category.searchValue === ''
? labels.length === 0
: category.searchValue.split(',').some((val) => labels.includes(val))) && (
<Check className="ml-auto h-3 w-3" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -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<HTMLDivElement> {
interface SettingsCardProps extends HTMLAttributes<HTMLDivElement> {
title: string;
description?: string;
children: React.ReactNode;
footer?: React.ReactNode;
action?: React.ReactNode;
children: ReactNode;
footer?: ReactNode;
action?: ReactNode;
}
export function SettingsCard({

View File

@@ -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();
},
});
};

View File

@@ -173,11 +173,11 @@ export const navigationConfig: Record<string, NavConfig> = {
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',

View File

@@ -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';
}
}

View File

@@ -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<string | null>(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(

View File

@@ -416,7 +416,7 @@
"signatures": "Signatures",
"shortcuts": "Shortcuts",
"labels": "Labels",
"categories": "Categories",
"categories": "Views",
"dangerZone": "Danger Zone",
"deleteAccount": "Delete Account",
"privacy": "Privacy"

View File

@@ -37,7 +37,12 @@ export const createDraftData = z.object({
export type CreateDraftData = z.infer<typeof createDraftData>;
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,

View File

@@ -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<ZeroEnv> {
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<ZeroEnv> {
maxResults?: number;
pageToken?: string;
}): Promise<IGetThreadsResponse> {
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(

View File

@@ -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",