mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-28 06:46:15 +00:00
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:
@@ -1 +1 @@
|
||||
pnpm lint-staged
|
||||
pnpm dlx oxlint@1.9.0 --deny-warnings
|
||||
7
AGENT.md
7
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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -416,7 +416,7 @@
|
||||
"signatures": "Signatures",
|
||||
"shortcuts": "Shortcuts",
|
||||
"labels": "Labels",
|
||||
"categories": "Categories",
|
||||
"categories": "Views",
|
||||
"dangerZone": "Danger Zone",
|
||||
"deleteAccount": "Delete Account",
|
||||
"privacy": "Privacy"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user