diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ad4f16ab2e..ebc0a5587f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -79,6 +79,8 @@ Thank you for your interest in contributing to 0.email! We're excited to have yo - Make sure the app runs without errors - Test your feature thoroughly + - Please lint using `pnpm dlx oxlint@latest` or by downloading an IDE extension here: https://oxc.rs/docs/guide/usage/linter.html#vscode-extension + 5. **Commit Your Changes** - Use clear, descriptive commit messages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11c0453b7..952f3a4603 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,6 @@ jobs: - name: Install dependencies 📦 run: pnpm install + + - name: Lint JS + run: pnpm dlx oxlint@latest --deny-warnings diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..2af0b8ba09 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,18 @@ +{ + "plugins": ["react", "unicorn", "typescript", "oxc"], + "rules": { + "no-alert": "error", // Emit an error message when a call to `alert()` is found + "oxc/approx-constant": "warn", // Show a warning when you write a number close to a known constant + "no-plusplus": "off", // Allow using the `++` and `--` operators + "no-useless-call": "error", + "no-accumulating-spread": "error", + "no-array-index-key": "error", + "jsx-no-jsx-as-prop": "error", + "jsx-no-new-array-as-prop": "error", + "jsx-no-new-function-as-prop": "error", + "jsx-no-new-object-as-prop": "error", + "prefer-array-find": "error", + "prefer-set-has": "error", + "exhaustive-deps": "off" + } +} diff --git a/README.md b/README.md index dbd458a7e4..80ca509c00 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ You can set up Zero in two ways: - Go to the [Twilio](https://www.twilio.com/) - Create a Twilio account if you don’t already have one - From the dashboard, locate your: + - Account SID - Auth Token - Phone Number @@ -265,6 +266,15 @@ Zero uses PostgreSQL for storing data. Here's how to set it up: ``` > If you run `pnpm dev` in your terminal, the studio command should be automatically running with the app. +### Sync + +Background: https://x.com/cmdhaus/status/1940886269950902362 +We're now storing the user's emails in their Durable Object & an R2 bucket. This allow us to speed things up, a lot. +This also introduces 3 environment variables, `DROP_AGENT_TABLES`,`THREAD_SYNC_MAX_COUNT`, `THREAD_SYNC_LOOP`. +`DROP_AGENT_TABLES`: should the durable object drop the threads table before starting a sync +`THREAD_SYNC_MAX_COUNT`: how many threads should we sync? max `500` because it's using the same number for the maxResults number from the driver. i.e 500 results per page. +`THREAD_SYNC_LOOP`: should make sure to sync all of the items inside a folder? i.e if THREAD_SYNC_MAX_COUNT=500 it will sync 500 threads per request until the folder is fully synced. (should be true in production) + ## Contribute Please refer to the [contributing guide](.github/CONTRIBUTING.md). diff --git a/apps/mail/app/(full-width)/contributors.tsx b/apps/mail/app/(full-width)/contributors.tsx index b97c35e3ea..7df1f63727 100644 --- a/apps/mail/app/(full-width)/contributors.tsx +++ b/apps/mail/app/(full-width)/contributors.tsx @@ -7,7 +7,6 @@ import { ChartAreaIcon, GitPullRequest, LayoutGrid, - FileCode, } from 'lucide-react'; import { Area, @@ -52,13 +51,13 @@ interface ActivityData { pullRequests: number; } -const excludedUsernames = [ +const excludedUsernames = new Set([ 'bot1', 'dependabot', 'github-actions', 'zerodotemail', 'autofix-ci[bot]', -]; +]); const coreTeamMembers = [ 'nizzyabi', 'ahmetskilinc', @@ -142,7 +141,7 @@ export default function OpenPage() { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [allContributors, setAllContributors] = useState([]); - const [isRendered, setIsRendered] = useState(false); + const [, setIsRendered] = useState(false); useEffect(() => setIsRendered(true), []); @@ -199,7 +198,7 @@ export default function OpenPage() { return allContributors ?.filter( (contributor) => - !excludedUsernames.includes(contributor.login) && + !excludedUsernames.has(contributor.login) && coreTeamMembers.some( (member) => member.toLowerCase() === contributor.login.toLowerCase(), ), @@ -216,7 +215,7 @@ export default function OpenPage() { allContributors ?.filter( (contributor) => - !excludedUsernames.includes(contributor.login) && + !excludedUsernames.has(contributor.login) && !coreTeamMembers.some( (member) => member.toLowerCase() === contributor.login.toLowerCase(), ), @@ -1011,6 +1010,7 @@ export default function OpenPage() { @@ -1019,6 +1019,7 @@ export default function OpenPage() { diff --git a/apps/mail/app/(full-width)/hr.tsx b/apps/mail/app/(full-width)/hr.tsx index 5bef425a92..d7248fc5a1 100644 --- a/apps/mail/app/(full-width)/hr.tsx +++ b/apps/mail/app/(full-width)/hr.tsx @@ -7,7 +7,7 @@ import { } from '@/components/ui/select'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz'; -import { getBrowserTimezone } from '@/lib/timezones'; + import { Plus, Trash2, Clock } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -145,7 +145,7 @@ export default function HRPage() { }, ]); // Company timezone - const [userTimezone, setUserTimezone] = useState('America/Los_Angeles'); + const [userTimezone] = useState('America/Los_Angeles'); const [userWorkingHours, setUserWorkingHours] = useState({ startTime: '09:00', endTime: '17:00', diff --git a/apps/mail/app/(full-width)/pricing.tsx b/apps/mail/app/(full-width)/pricing.tsx index 8cd0f2253f..693c04d6d9 100644 --- a/apps/mail/app/(full-width)/pricing.tsx +++ b/apps/mail/app/(full-width)/pricing.tsx @@ -1,77 +1,12 @@ -import { - NavigationMenu, - NavigationMenuItem, - NavigationMenuLink, - NavigationMenuList, - NavigationMenuTrigger, - NavigationMenuContent, - ListItem, -} from '@/components/ui/navigation-menu'; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { PixelatedBackground } from '@/components/home/pixelated-bg'; import PricingCard from '@/components/pricing/pricing-card'; import Comparision from '@/components/pricing/comparision'; -import { signIn, useSession } from '@/lib/auth-client'; -import { Separator } from '@/components/ui/separator'; -import { Navigation } from '@/components/navigation'; -import { useBilling } from '@/hooks/use-billing'; -import { Link, useNavigate } from 'react-router'; -import { Button } from '@/components/ui/button'; -import Footer from '@/components/home/footer'; -import { useState, useMemo } from 'react'; -import { Menu } from 'lucide-react'; -import { toast } from 'sonner'; -const resources = [ - { - title: 'GitHub', - href: 'https://github.com/Mail-0/Zero', - description: 'Check out our open-source projects and contributions.', - platform: 'github' as const, - }, - { - title: 'Twitter', - href: 'https://x.com/mail0dotcom', - description: 'Follow us for the latest updates and announcements.', - platform: 'twitter' as const, - }, - { - title: 'LinkedIn', - href: 'https://www.linkedin.com/company/mail0/', - description: 'Connect with us professionally and stay updated.', - platform: 'linkedin' as const, - }, - { - title: 'Discord', - href: 'https://discord.gg/mail0', - description: 'Join our community and chat with the team.', - platform: 'discord' as const, - }, -]; +import { Navigation } from '@/components/navigation'; -const aboutLinks = [ - { - title: 'About', - href: '/about', - description: 'Learn more about Zero and our mission.', - }, - { - title: 'Privacy', - href: '/privacy', - description: 'Read our privacy policy and data handling practices.', - }, - { - title: 'Terms of Service', - href: '/terms', - description: 'Review our terms of service and usage guidelines.', - }, -]; +import Footer from '@/components/home/footer'; export default function PricingPage() { - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - const { data: session } = useSession(); - return (
{ diff --git a/apps/mail/app/(full-width)/terms.tsx b/apps/mail/app/(full-width)/terms.tsx index 63d5785d02..99f31b0058 100644 --- a/apps/mail/app/(full-width)/terms.tsx +++ b/apps/mail/app/(full-width)/terms.tsx @@ -5,15 +5,15 @@ import { Navigation } from '@/components/navigation'; import { Button } from '@/components/ui/button'; import Footer from '@/components/home/footer'; import { createSectionId } from '@/lib/utils'; -import { useNavigate } from 'react-router'; -import { toast } from 'sonner'; + + import React from 'react'; const LAST_UPDATED = 'February 13, 2025'; export default function TermsOfService() { const { copiedValue: copiedSection, copyToClipboard } = useCopyToClipboard(); - const navigate = useNavigate(); + const handleCopyLink = (sectionId: string) => { const url = `${window.location.origin}${window.location.pathname}#${sectionId}`; diff --git a/apps/mail/app/(routes)/layout.tsx b/apps/mail/app/(routes)/layout.tsx index 8e3f11f897..c570e2e198 100644 --- a/apps/mail/app/(routes)/layout.tsx +++ b/apps/mail/app/(routes)/layout.tsx @@ -1,18 +1,16 @@ import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper'; import { CommandPaletteProvider } from '@/components/context/command-palette-context'; -import { VoiceProvider } from '@/providers/voice-provider'; + import { Outlet } from 'react-router'; export default function Layout() { return ( - {/* */}
- {/*
*/}
); } diff --git a/apps/mail/app/(routes)/mail/[folder]/page.tsx b/apps/mail/app/(routes)/mail/[folder]/page.tsx index fcbcbf6373..95780ce9e4 100644 --- a/apps/mail/app/(routes)/mail/[folder]/page.tsx +++ b/apps/mail/app/(routes)/mail/[folder]/page.tsx @@ -1,12 +1,12 @@ import { useLoaderData, useNavigate } from 'react-router'; -import { useTRPC } from '@/providers/query-provider'; + import { MailLayout } from '@/components/mail/mail'; import { useLabels } from '@/hooks/use-labels'; import { authProxy } from '@/lib/auth-proxy'; import { useEffect, useState } from 'react'; import type { Route } from './+types/page'; -const ALLOWED_FOLDERS = ['inbox', 'draft', 'sent', 'spam', 'bin', 'archive']; +const ALLOWED_FOLDERS = new Set(['inbox', 'draft', 'sent', 'spam', 'bin', 'archive']); export async function clientLoader({ params, request }: Route.ClientLoaderArgs) { if (!params.folder) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox`); @@ -24,9 +24,9 @@ export default function MailPage() { const navigate = useNavigate(); const [isLabelValid, setIsLabelValid] = useState(true); - const isStandardFolder = ALLOWED_FOLDERS.includes(folder); + const isStandardFolder = ALLOWED_FOLDERS.has(folder); - const { data: userLabels, isLoading: isLoadingLabels } = useLabels(); + const { userLabels, isLoading: isLoadingLabels } = useLabels(); useEffect(() => { if (isStandardFolder) { diff --git a/apps/mail/app/(routes)/mail/layout.tsx b/apps/mail/app/(routes)/mail/layout.tsx index 50c81c51e5..d6805729f4 100644 --- a/apps/mail/app/(routes)/mail/layout.tsx +++ b/apps/mail/app/(routes)/mail/layout.tsx @@ -1,14 +1,13 @@ import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper'; import { OnboardingWrapper } from '@/components/onboarding'; -import { VoiceProvider } from '@/providers/voice-provider'; + import { NotificationProvider } from '@/components/party'; import { AppSidebar } from '@/components/ui/app-sidebar'; -import { Outlet, useLoaderData } from 'react-router'; -import type { Route } from './+types/layout'; +import { Outlet, } from 'react-router'; + export default function MailLayout() { return ( - //
@@ -17,6 +16,5 @@ export default function MailLayout() { - // ); } diff --git a/apps/mail/app/(routes)/settings/appearance/page.tsx b/apps/mail/app/(routes)/settings/appearance/page.tsx index 5264254878..94a444538a 100644 --- a/apps/mail/app/(routes)/settings/appearance/page.tsx +++ b/apps/mail/app/(routes)/settings/appearance/page.tsx @@ -110,7 +110,7 @@ export default function AppearancePage() { ( + render={() => ( {m['pages.settings.appearance.theme']()} diff --git a/apps/mail/app/(routes)/settings/categories/page.tsx b/apps/mail/app/(routes)/settings/categories/page.tsx index c983847470..c58e905fc7 100644 --- a/apps/mail/app/(routes)/settings/categories/page.tsx +++ b/apps/mail/app/(routes)/settings/categories/page.tsx @@ -10,16 +10,11 @@ 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 * as Icons from '@/components/icons/icons'; + import { Sparkles } from '@/components/icons/icons'; import { Loader, GripVertical } from 'lucide-react'; import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, -} from '@/components/ui/select'; + } from '@/components/ui/select'; import { Badge } from '@/components/ui/badge'; import { DndContext, diff --git a/apps/mail/app/(routes)/settings/connections/page.tsx b/apps/mail/app/(routes)/settings/connections/page.tsx index db63098096..189f749f27 100644 --- a/apps/mail/app/(routes)/settings/connections/page.tsx +++ b/apps/mail/app/(routes)/settings/connections/page.tsx @@ -10,7 +10,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { SettingsCard } from '@/components/settings/settings-card'; import { AddConnectionDialog } from '@/components/connection/add'; -import { PricingDialog } from '@/components/ui/pricing-dialog'; + import { useSession, authClient } from '@/lib/auth-client'; import { useConnections } from '@/hooks/use-connections'; import { useTRPC } from '@/providers/query-provider'; @@ -62,9 +62,9 @@ export default function ConnectionsPage() {
{isLoading ? (
- {[...Array(3)].map((_, i) => ( + {[...Array(3)].map((n) => (
diff --git a/apps/mail/app/(routes)/settings/danger-zone/page.tsx b/apps/mail/app/(routes)/settings/danger-zone/page.tsx index 89e8dadd8a..75a787f26e 100644 --- a/apps/mail/app/(routes)/settings/danger-zone/page.tsx +++ b/apps/mail/app/(routes)/settings/danger-zone/page.tsx @@ -15,7 +15,7 @@ import { useMutation } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { AlertTriangle } from 'lucide-react'; -import { useNavigate } from 'react-router'; + import { useForm } from 'react-hook-form'; import { m } from '@/paraglide/messages'; import { clear } from 'idb-keyval'; @@ -33,7 +33,7 @@ const formSchema = z.object({ function DeleteAccountDialog() { const [isOpen, setIsOpen] = useState(false); - const navigate = useNavigate(); + const trpc = useTRPC(); const { refetch } = useSession(); const { mutateAsync: deleteAccount, isPending } = useMutation(trpc.user.delete.mutationOptions()); diff --git a/apps/mail/app/(routes)/settings/general/page.tsx b/apps/mail/app/(routes)/settings/general/page.tsx index 3d42150f2f..b9bf5a2962 100644 --- a/apps/mail/app/(routes)/settings/general/page.tsx +++ b/apps/mail/app/(routes)/settings/general/page.tsx @@ -18,8 +18,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { useForm, type ControllerRenderProps } from 'react-hook-form'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { SettingsCard } from '@/components/settings/settings-card'; -import { Globe, Clock, XIcon, Mail, InfoIcon } from 'lucide-react'; import { useEmailAliases } from '@/hooks/use-email-aliases'; +import { Globe, Clock, Mail, InfoIcon } from 'lucide-react'; import { getLocale, setLocale } from '@/paraglide/runtime'; import { useState, useEffect, useMemo, memo } from 'react'; import { userSettingsSchema } from '@zero/server/schemas'; @@ -28,7 +28,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { zodResolver } from '@hookform/resolvers/zod'; import { useTRPC } from '@/providers/query-provider'; import { getBrowserTimezone } from '@/lib/timezones'; -import { Textarea } from '@/components/ui/textarea'; + import { useSettings } from '@/hooks/use-settings'; import { locales as localesData } from '@/locales'; import { Switch } from '@/components/ui/switch'; @@ -61,7 +61,7 @@ const TimezoneSelect = memo( variant="outline" role="combobox" aria-expanded={open} - className="w-46 flex !h-9 items-center justify-start rounded-md hover:bg-transparent" + className="md:w-46 flex !h-9 w-full items-center justify-start rounded-md hover:bg-transparent" > {field.value} @@ -167,6 +167,7 @@ export default function GeneralPage() { toast.success(m['common.settings.saved']()); } catch (error) { + console.error(error); toast.error(m['common.settings.failedToSave']()); queryClient.setQueryData(trpc.settings.get.queryKey(), (updater) => { if (!updater) return; @@ -190,16 +191,16 @@ export default function GeneralPage() { >
-
+
( - - {m['pages.settings.general.language']()} + + {m['pages.settings.general.language']()} - + (null); @@ -63,7 +52,7 @@ export default function LabelsPage() { const handleDelete = async (id: string) => { toast.promise(deleteLabel({ id }), { - loading: m['common.labels.deletingLabel'](), + loading: m['common.labels.deletingLabel'](), success: m['common.labels.deleteLabelSuccess'](), error: m['common.labels.failedToDeleteLabel'](), finally: async () => { @@ -113,7 +102,7 @@ export default function LabelsPage() {

{error.message}

) : labels?.length === 0 ? (

- {m['common.mail.noLabelsAvailable']()} + {m['common.mail.noLabelsAvailable']()}

) : (
@@ -147,7 +136,7 @@ export default function LabelsPage() { - {m['common.labels.editLabel']()} + {m['common.labels.editLabel']()} @@ -162,7 +151,7 @@ export default function LabelsPage() { - {m['common.labels.deleteLabel']()} + {m['common.labels.deleteLabel']()}
diff --git a/apps/mail/app/(routes)/settings/security/page.tsx b/apps/mail/app/(routes)/settings/security/page.tsx index e3eee76d4e..8ddaefe33f 100644 --- a/apps/mail/app/(routes)/settings/security/page.tsx +++ b/apps/mail/app/(routes)/settings/security/page.tsx @@ -12,7 +12,7 @@ import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { m } from '@/paraglide/messages'; import { useForm } from 'react-hook-form'; -import { KeyRound } from 'lucide-react'; + import { useState } from 'react'; import * as z from 'zod'; diff --git a/apps/mail/app/(routes)/settings/shortcuts/page.tsx b/apps/mail/app/(routes)/settings/shortcuts/page.tsx index 377967fc8a..c4266667e7 100644 --- a/apps/mail/app/(routes)/settings/shortcuts/page.tsx +++ b/apps/mail/app/(routes)/settings/shortcuts/page.tsx @@ -1,19 +1,17 @@ -import { keyboardShortcuts, type Shortcut } from '@/config/shortcuts'; import { SettingsCard } from '@/components/settings/settings-card'; import { formatDisplayKeys } from '@/lib/hotkeys/use-hotkey-utils'; import { useShortcutCache } from '@/lib/hotkeys/use-hotkey-utils'; import { useCategorySettings } from '@/hooks/use-categories'; -import { useState, type ReactNode, useEffect } from 'react'; -import { useSession } from '@/lib/auth-client'; +import { type Shortcut } from '@/config/shortcuts'; import { m } from '@/paraglide/messages'; +import { type ReactNode } from 'react'; export default function ShortcutsPage() { - const { data: session } = useSession(); const { shortcuts, // TODO: Implement shortcuts syncing and caching // updateShortcut, - } = useShortcutCache(session?.user?.id); + } = useShortcutCache(); const categorySettings = useCategorySettings(); return ( @@ -77,13 +75,13 @@ export default function ShortcutsPage() { } return ( - {label} - + ); })}
@@ -95,18 +93,10 @@ export default function ShortcutsPage() { ); } -function Shortcut({ - children, - keys, - action, -}: { - children: ReactNode; - keys: string[]; - action: string; -}) { +function ShortcutItem({ children, keys }: { children: ReactNode; keys: string[] }) { // const [isRecording, setIsRecording] = useState(false); const displayKeys = formatDisplayKeys(keys); - const { data: session } = useSession(); + // const { updateShortcut } = useShortcutCache(session?.user?.id); // const handleHotkeyRecorded = async (newKeys: string[]) => { diff --git a/apps/mail/app/routes.ts b/apps/mail/app/routes.ts index 57d9fbe23f..29b9244baf 100644 --- a/apps/mail/app/routes.ts +++ b/apps/mail/app/routes.ts @@ -42,7 +42,7 @@ export default [ route('/danger-zone', '(routes)/settings/danger-zone/page.tsx'), route('/general', '(routes)/settings/general/page.tsx'), route('/labels', '(routes)/settings/labels/page.tsx'), - route('/categories', '(routes)/settings/categories/page.tsx'), + // route('/categories', '(routes)/settings/categories/page.tsx'), route('/notifications', '(routes)/settings/notifications/page.tsx'), route('/privacy', '(routes)/settings/privacy/page.tsx'), route('/security', '(routes)/settings/security/page.tsx'), diff --git a/apps/mail/components/context/command-palette-context.tsx b/apps/mail/components/context/command-palette-context.tsx index cb1059b094..83e54698ff 100644 --- a/apps/mail/components/context/command-palette-context.tsx +++ b/apps/mail/components/context/command-palette-context.tsx @@ -1,7 +1,6 @@ import { ArrowRight, Calendar as CalendarIcon, - Check, Clock, FileText, Filter, @@ -18,16 +17,6 @@ import { Users, X as XIcon, } from 'lucide-react'; -import { - CommandDialog, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, - CommandShortcut, -} from '@/components/ui/command'; import { createContext, Fragment, @@ -39,6 +28,14 @@ import { useState, type ComponentType, } from 'react'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; import { getMainSearchTerm, parseNaturalLanguageSearch } from '@/lib/utils'; import { DialogDescription, DialogTitle } from '@/components/ui/dialog'; import { useSearchValue } from '@/hooks/use-search-value'; @@ -188,7 +185,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) { const [activeFilters, setActiveFilters] = useState([]); const [recentSearches, setRecentSearches] = useState([]); const [savedSearches, setSavedSearches] = useState([]); - const [selectedLabels, setSelectedLabels] = useState([]); + // const [selectedLabels] = useState([]); const [filterBuilderState, setFilterBuilderState] = useState>({}); const [saveSearchName, setSaveSearchName] = useState(''); const [emailSuggestions, setEmailSuggestions] = useState([]); @@ -197,9 +194,9 @@ export function CommandPalette({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); const { pathname } = useLocation(); - const { data: userLabels = [] } = useLabels(); + const { userLabels = [] } = useLabels(); const trpc = useTRPC(); - const { mutateAsync: generateSearchQuery, isPending } = useMutation( + const { mutateAsync: generateSearchQuery } = useMutation( trpc.ai.generateSearchQuery.mutationOptions(), ); @@ -845,7 +842,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) { )} {allCommands.map((group, groupIndex) => ( - + {group.items.length > 0 && ( {group.items.map((item) => ( @@ -1460,9 +1457,9 @@ export function CommandPalette({ children }: { children: React.ReactNode }) { /> )} {label.name || 'Unnamed Label'} - {selectedLabels.includes(label.id || '') && ( + {/* {selectedLabels.includes(label.id || '') && ( - )} + )} */}
))}
diff --git a/apps/mail/components/context/label-sidebar-context.tsx b/apps/mail/components/context/label-sidebar-context.tsx index 580af18d6a..d98ca528e2 100644 --- a/apps/mail/components/context/label-sidebar-context.tsx +++ b/apps/mail/components/context/label-sidebar-context.tsx @@ -22,15 +22,6 @@ import { Trash } from '../icons/icons'; import { Button } from '../ui/button'; import { toast } from 'sonner'; -interface LabelAction { - id: string; - label: string | ReactNode; - icon?: ReactNode; - shortcut?: string; - action: () => void; - disabled?: boolean; -} - interface LabelSidebarContextMenuProps { children: ReactNode; labelId: string; diff --git a/apps/mail/components/context/sidebar-context.tsx b/apps/mail/components/context/sidebar-context.tsx index ca61dedfa3..967505ac08 100644 --- a/apps/mail/components/context/sidebar-context.tsx +++ b/apps/mail/components/context/sidebar-context.tsx @@ -1,7 +1,6 @@ import { SIDEBAR_COOKIE_MAX_AGE, SIDEBAR_COOKIE_NAME, - SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON, } from '@/lib/constants'; diff --git a/apps/mail/components/context/thread-context.tsx b/apps/mail/components/context/thread-context.tsx index e68effcf20..bb8d42e89f 100644 --- a/apps/mail/components/context/thread-context.tsx +++ b/apps/mail/components/context/thread-context.tsx @@ -12,6 +12,7 @@ import { import { Archive, ArchiveX, + ExternalLink, Forward, Inbox, MailOpen, @@ -58,7 +59,7 @@ interface EmailContextMenuProps { } const LabelsList = ({ threadId, bulkSelected }: { threadId: string; bulkSelected: string[] }) => { - const { data: labels } = useLabels(); + const { userLabels: labels } = useLabels(); const { optimisticToggleLabel } = useOptimisticActions(); const targetThreadIds = bulkSelected.length > 0 ? bulkSelected : [threadId]; @@ -141,35 +142,40 @@ export function ThreadContextMenu({ const [, setMode] = useQueryState('mode'); const [, setThreadId] = useQueryState('threadId'); const { data: threadData } = useThread(threadId); + const [, setActiveReplyId] = useQueryState('activeReplyId'); const optimisticState = useOptimisticThreadState(threadId); - - const isUnread = useMemo(() => { - return threadData?.hasUnread ?? false; - }, [threadData]); - - const isStarred = useMemo(() => { + const { + optimisticMoveThreadsTo, + optimisticToggleStar, + optimisticToggleImportant, + optimisticMarkAsRead, + optimisticMarkAsUnread, + optimisticDeleteThreads, + } = useOptimisticActions(); + + const { isUnread, isStarred, isImportant } = useMemo(() => { + const unread = threadData?.hasUnread ?? false; + + let starred; if (optimisticState.optimisticStarred !== null) { - return optimisticState.optimisticStarred; + starred = optimisticState.optimisticStarred; + } else { + starred = threadData?.messages.some((message) => + message.tags?.some((tag) => tag.name.toLowerCase() === 'starred'), + ); } - return threadData?.messages.some((message) => - message.tags?.some((tag) => tag.name.toLowerCase() === 'starred'), - ); - }, [threadData, optimisticState.optimisticStarred]); - const isImportant = useMemo(() => { + let important; if (optimisticState.optimisticImportant !== null) { - return optimisticState.optimisticImportant; + important = optimisticState.optimisticImportant; + } else { + important = threadData?.messages.some((message) => + message.tags?.some((tag) => tag.name.toLowerCase() === 'important'), + ); } - return threadData?.messages.some((message) => - message.tags?.some((tag) => tag.name.toLowerCase() === 'important'), - ); - }, [threadData]); - const noopAction = () => async () => { - toast.info(m['common.actions.featureNotImplemented']()); - }; - - const { optimisticMoveThreadsTo } = useOptimisticActions(); + return { isUnread: unread, isStarred: starred, isImportant: important }; + }, [threadData, optimisticState.optimisticStarred, optimisticState.optimisticImportant]); const handleMove = (from: string, to: string) => () => { try { @@ -197,8 +203,6 @@ export function ThreadContextMenu({ } }; - const { optimisticToggleStar } = useOptimisticActions(); - const handleFavorites = () => { const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId]; @@ -211,8 +215,6 @@ export function ThreadContextMenu({ } }; - const { optimisticToggleImportant } = useOptimisticActions(); - const handleToggleImportant = () => { const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId]; const newImportantState = !isImportant; @@ -226,8 +228,6 @@ export function ThreadContextMenu({ } }; - const { optimisticMarkAsRead, optimisticMarkAsUnread } = useOptimisticActions(); - const handleReadUnread = () => { const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId]; const newReadState = isUnread; // If currently unread, mark as read (true) @@ -246,7 +246,6 @@ export function ThreadContextMenu({ setMail((prev) => ({ ...prev, bulkSelected: [] })); } }; - const [, setActiveReplyId] = useQueryState('activeReplyId'); const handleThreadReply = () => { setMode('reply'); @@ -266,30 +265,43 @@ export function ThreadContextMenu({ if (threadData?.latest) setActiveReplyId(threadData?.latest?.id); }; - const primaryActions: EmailAction[] = [ - { - id: 'reply', - label: m['common.mail.reply'](), - icon: , - action: handleThreadReply, - disabled: false, - }, - { - id: 'reply-all', - label: m['common.mail.replyAll'](), - icon: , - action: handleThreadReplyAll, - disabled: false, - }, - { - id: 'forward', - label: m['common.mail.forward'](), - icon: , - action: handleThreadForward, - disabled: false, - }, - ]; - const { optimisticDeleteThreads } = useOptimisticActions(); + const handleOpenInNewTab = () => { + window.open(`/mail/${folder}?threadId=${threadId}`, '_blank'); + }; + + const primaryActions: EmailAction[] = useMemo( + () => [ + { + id: 'open-in-new-tab', + label: m['common.mail.openInNewTab'](), + icon: , + action: handleOpenInNewTab, + disabled: false, + }, + { + id: 'reply', + label: m['common.mail.reply'](), + icon: , + action: handleThreadReply, + disabled: false, + }, + { + id: 'reply-all', + label: m['common.mail.replyAll'](), + icon: , + action: handleThreadReplyAll, + disabled: false, + }, + { + id: 'forward', + label: m['common.mail.forward'](), + icon: , + action: handleThreadForward, + disabled: false, + }, + ], + [m, handleThreadReply, handleThreadReplyAll, handleThreadForward], + ); const handleDelete = () => () => { const targets = mail.bulkSelected.length ? mail.bulkSelected : [threadId]; @@ -308,7 +320,7 @@ export function ThreadContextMenu({ // } }; - const getActions = () => { + const getActions = useMemo(() => { if (isSpam) { return [ { @@ -408,39 +420,42 @@ export function ThreadContextMenu({ disabled: false, }, ]; - }; + }, [isSpam, isBin, isArchiveFolder, isInbox, isSent, handleMove, handleDelete]); - const otherActions: EmailAction[] = [ - { - id: 'toggle-read', - label: isUnread ? m['common.mail.markAsRead']() : m['common.mail.markAsUnread'](), - icon: !isUnread ? ( - - ) : ( - - ), - action: handleReadUnread, - disabled: false, - }, - { - id: 'toggle-important', - label: isImportant - ? m['common.mail.removeFromImportant']() - : m['common.mail.markAsImportant'](), - icon: , - action: handleToggleImportant, - }, - { - id: 'favorite', - label: isStarred ? m['common.mail.removeFavorite']() : m['common.mail.addFavorite'](), - icon: isStarred ? ( - - ) : ( - - ), - action: handleFavorites, - }, - ]; + const otherActions: EmailAction[] = useMemo( + () => [ + { + id: 'toggle-read', + label: isUnread ? m['common.mail.markAsRead']() : m['common.mail.markAsUnread'](), + icon: !isUnread ? ( + + ) : ( + + ), + action: handleReadUnread, + disabled: false, + }, + { + id: 'toggle-important', + label: isImportant + ? m['common.mail.removeFromImportant']() + : m['common.mail.markAsImportant'](), + icon: , + action: handleToggleImportant, + }, + { + id: 'favorite', + label: isStarred ? m['common.mail.removeFavorite']() : m['common.mail.addFavorite'](), + icon: isStarred ? ( + + ) : ( + + ), + action: handleFavorites, + }, + ], + [isUnread, isImportant, isStarred, m, handleReadUnread, handleToggleImportant, handleFavorites], + ); const renderAction = (action: EmailAction) => { return ( @@ -482,7 +497,7 @@ export function ThreadContextMenu({ - {getActions().map(renderAction as any)} + {getActions.map(renderAction)} diff --git a/apps/mail/components/create/ai-chat.tsx b/apps/mail/components/create/ai-chat.tsx index ce0b000300..e9c4051040 100644 --- a/apps/mail/components/create/ai-chat.tsx +++ b/apps/mail/components/create/ai-chat.tsx @@ -1,7 +1,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; import { useAIFullScreen, useAISidebar } from '../ui/ai-sidebar'; +import { VoiceProvider } from '@/providers/voice-provider'; import useComposeEditor from '@/hooks/use-compose-editor'; import { useRef, useCallback, useEffect } from 'react'; import { Markdown } from '@react-email/components'; @@ -10,6 +10,7 @@ import { TextShimmer } from '../ui/text-shimmer'; import { useThread } from '@/hooks/use-threads'; import { MailLabels } from '../mail/mail-list'; import { cn, getEmailLogo } from '@/lib/utils'; +import { VoiceButton } from '../voice-button'; import { EditorContent } from '@tiptap/react'; import { CurvedArrow } from '../icons/icons'; import { Tools } from '../../types/tools'; @@ -89,9 +90,9 @@ const ExampleQueries = ({ onQueryClick }: { onQueryClick: (query: string) => voi {/* First row */}
- {firstRowQueries.map((query, index) => ( + {firstRowQueries.map((query) => ( - - -
-
-

Attachments

-

- {attachments.length} {attachments.length === 1 ? 'file' : 'files'} -

-
- -
-
- {attachments.map((file, index) => ( -
- - onAttachmentRemove && onAttachmentRemove(index) - } - index={index} - file={file} - /> -
-

{truncateFileName(file.name, 20)}

-

- {(file.size / (1024 * 1024)).toFixed(2)} MB -

-
-
- ))} -
-
-
-
- - )} - - {/* Add Attachment Button */} - -
- ); -}; - export default () => { return }>; }; diff --git a/apps/mail/components/create/editor.colors.tsx b/apps/mail/components/create/editor.colors.tsx index 1f55686ba1..a3422452d4 100644 --- a/apps/mail/components/create/editor.colors.tsx +++ b/apps/mail/components/create/editor.colors.tsx @@ -127,9 +127,9 @@ export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => { >
Color
- {TEXT_COLORS.map(({ name, color }, index) => ( + {TEXT_COLORS.map(({ name, color }) => ( { // editor.commands.unsetColor(); name !== 'Default' && @@ -152,9 +152,9 @@ export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
Background
- {HIGHLIGHT_COLORS.map(({ name, color }, index) => ( + {HIGHLIGHT_COLORS.map(({ name, color }) => ( { editor.commands.unsetHighlight(); name !== 'Default' && editor.commands.setHighlight({ color }); diff --git a/apps/mail/components/create/editor.link-selector.tsx b/apps/mail/components/create/editor.link-selector.tsx deleted file mode 100644 index 44b67079dc..0000000000 --- a/apps/mail/components/create/editor.link-selector.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { PopoverContent, Popover, PopoverTrigger } from '@/components/ui/popover'; -import { Button } from '@/components/ui/button'; -import { Check, Trash } from 'lucide-react'; -import { useEffect, useRef } from 'react'; -import { useEditor } from 'novel'; -import { cn } from '@/lib/utils'; - -export function isValidUrl(url: string) { - try { - new URL(url); - return true; - } catch (e) { - return false; - } -} -export function getUrlFromString(str: string) { - if (isValidUrl(str)) return str; - try { - if (str.includes('.') && !str.includes(' ')) { - return new URL(`https://${str}`).toString(); - } - } catch (e) { - return null; - } -} -interface LinkSelectorProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => { - const inputRef = useRef(null); - const { editor } = useEditor(); - - // Autofocus on input by default - useEffect(() => { - inputRef.current && inputRef.current?.focus(); - }); - if (!editor) return null; - - return ( - - - - - - { - const target = e.currentTarget as HTMLFormElement; - e.preventDefault(); - const input = target[0] as HTMLInputElement; - const url = getUrlFromString(input.value); - url && editor.chain().focus().setLink({ href: url }).run(); - }} - className="flex p-1" - > - - {editor.getAttributes('link').href ? ( - - ) : ( - - )} - - - - ); -}; diff --git a/apps/mail/components/create/editor.node-selector.tsx b/apps/mail/components/create/editor.node-selector.tsx deleted file mode 100644 index 6bca3870f3..0000000000 --- a/apps/mail/components/create/editor.node-selector.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { - Check, - ChevronDown, - Heading1, - Heading2, - Heading3, - TextQuote, - ListOrdered, - TextIcon, - Code, - CheckSquare, - type LucideIcon, -} from 'lucide-react'; -import { PopoverContent, PopoverTrigger, Popover } from '@/components/ui/popover'; -import { EditorBubbleItem, useEditor } from 'novel'; -import { Button } from '@/components/ui/button'; -import { type Editor } from '@tiptap/react'; - -export type SelectorItem = { - name: string; - icon: LucideIcon; - command: (editor: Editor) => void; - isActive: (editor: Editor) => boolean; -}; - -const items: SelectorItem[] = [ - { - name: 'Text', - icon: TextIcon, - command: (editor) => editor.chain().focus().toggleNode('paragraph', 'paragraph').run(), - // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! - isActive: (editor) => - editor.isActive('paragraph') && - !editor.isActive('bulletList') && - !editor.isActive('orderedList'), - }, - { - name: 'Heading 1', - icon: Heading1, - command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(), - isActive: (editor) => editor.isActive('heading', { level: 1 }), - }, - { - name: 'Heading 2', - icon: Heading2, - command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(), - isActive: (editor) => editor.isActive('heading', { level: 2 }), - }, - { - name: 'Heading 3', - icon: Heading3, - command: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(), - isActive: (editor) => editor.isActive('heading', { level: 3 }), - }, - { - name: 'To-do List', - icon: CheckSquare, - command: (editor) => editor.chain().focus().toggleTaskList().run(), - isActive: (editor) => editor.isActive('taskItem'), - }, - { - name: 'Bullet List', - icon: ListOrdered, - command: (editor) => editor.chain().focus().toggleBulletList().run(), - isActive: (editor) => editor.isActive('bulletList'), - }, - { - name: 'Numbered List', - icon: ListOrdered, - command: (editor) => editor.chain().focus().toggleOrderedList().run(), - isActive: (editor) => editor.isActive('orderedList'), - }, - { - name: 'Quote', - icon: TextQuote, - command: (editor) => - editor.chain().focus().toggleNode('paragraph', 'paragraph').toggleBlockquote().run(), - isActive: (editor) => editor.isActive('blockquote'), - }, - { - name: 'Code', - icon: Code, - command: (editor) => editor.chain().focus().toggleCodeBlock().run(), - isActive: (editor) => editor.isActive('codeBlock'), - }, -]; -interface NodeSelectorProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => { - const { editor } = useEditor(); - if (!editor) return null; - const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? { - name: 'Multiple', - }; - - return ( - - - - - - {items.map((item, index) => ( - { - item.command(editor); - onOpenChange(false); - }} - className="hover:bg-accent flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm" - > -
-
- -
- {item.name} -
- {activeItem.name === item.name && } -
- ))} -
-
- ); -}; diff --git a/apps/mail/components/create/editor.text-buttons.tsx b/apps/mail/components/create/editor.text-buttons.tsx index adb241dbce..ee903f81ef 100644 --- a/apps/mail/components/create/editor.text-buttons.tsx +++ b/apps/mail/components/create/editor.text-buttons.tsx @@ -41,9 +41,9 @@ export const TextButtons = () => { ]; return (
- {items.map((item, index) => ( + {items.map((item) => ( { item.command(editor); }} diff --git a/apps/mail/components/create/editor.tsx b/apps/mail/components/create/editor.tsx index fbf8b925cf..dcd64c5ddb 100644 --- a/apps/mail/components/create/editor.tsx +++ b/apps/mail/components/create/editor.tsx @@ -1,18 +1,3 @@ -import { - Bold, - Italic, - Strikethrough, - Underline, - Code, - Link as LinkIcon, - List, - ListOrdered, - Heading1, - Heading2, - Heading3, - Paperclip, - Plus, -} from 'lucide-react'; import { EditorCommand, EditorCommandEmpty, @@ -20,39 +5,25 @@ import { EditorCommandList, EditorContent, EditorRoot, - useEditor, type JSONContent, } from 'novel'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { useEditor as useEditorContext } from '@/components/providers/editor-provider'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Editor as TiptapEditor, useCurrentEditor } from '@tiptap/react'; + import { suggestionItems } from '@/components/create/slash-command'; import { defaultExtensions } from '@/components/create/extensions'; -import { ImageResizer, handleCommandNavigation } from 'novel'; -import { handleImageDrop, handleImagePaste } from 'novel'; import EditorMenu from '@/components/create/editor-menu'; -import { UploadedFileIcon } from './uploaded-file-icon'; -import { Separator } from '@/components/ui/separator'; -import { useReducer, useRef, useEffect } from 'react'; +import { Editor as TiptapEditor } from '@tiptap/react'; +import { handleCommandNavigation } from 'novel'; +import { handleImageDrop } from 'novel'; + import { AutoComplete } from './editor-autocomplete'; -import { Editor as CoreEditor } from '@tiptap/core'; -import { cn, truncateFileName } from '@/lib/utils'; +import { useReducer, useRef } from 'react'; + import { TextSelection } from 'prosemirror-state'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { EditorView } from 'prosemirror-view'; + +import { cn } from '@/lib/utils'; + import { Markdown } from 'tiptap-markdown'; -import { Slice } from 'prosemirror-model'; -import { m } from '@/paraglide/messages'; + import { useState } from 'react'; import React from 'react'; @@ -121,217 +92,6 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState { } } -// Update the MenuBar component with icons -interface MenuBarProps { - onAttachmentsChange?: (attachments: File[]) => void; - includeSignature?: boolean; - onSignatureToggle?: (include: boolean) => void; - hasSignature?: boolean; -} - -const MenuBar = () => { - const { editor } = useCurrentEditor(); - - const [linkDialogOpen, setLinkDialogOpen] = useState(false); - const [linkUrl, setLinkUrl] = useState(''); - - if (!editor) { - return null; - } - - // Replace the old setLink function with this new implementation - const handleLinkDialogOpen = () => { - // If a link is already active, pre-fill the input with the current URL - if (editor.isActive('link')) { - const attrs = editor.getAttributes('link'); - setLinkUrl(attrs.href || ''); - } else { - setLinkUrl(''); - } - setLinkDialogOpen(true); - }; - - const handleSaveLink = () => { - // empty - if (linkUrl === '') { - editor.chain().focus().unsetLink().run(); - } else { - // Format the URL with proper protocol if missing - let formattedUrl = linkUrl; - if (formattedUrl && !/^https?:\/\//i.test(formattedUrl)) { - formattedUrl = `https://${formattedUrl}`; - } - // set link - editor.chain().focus().setLink({ href: formattedUrl }).run(); - } - setLinkDialogOpen(false); - }; - - const handleRemoveLink = () => { - editor.chain().focus().unsetLink().run(); - setLinkDialogOpen(false); - }; - - return ( - <> - -
-
-
- - - - - {m.pages.createEmail.editor.menuBar.bold()} - - - - - - {m.pages.createEmail.editor.menuBar.italic()} - - - - - - - {m.pages.createEmail.editor.menuBar.strikethrough()} - - - - - - - {m.pages.createEmail.editor.menuBar.underline()} - - - - - - {m.pages.createEmail.editor.menuBar.link()} - -
- - - -
- - - - - {m.pages.createEmail.editor.menuBar.bulletList()} - - - - - - {m.pages.createEmail.editor.menuBar.orderedList()} - -
-
-
-
- - - - - {m.pages.createEmail.addLink()} - {m.pages.createEmail.addUrlToCreateALink()} - -
-
- - setLinkUrl(e.target.value)} - placeholder="https://example.com" - /> -
-
- - - - -
-
- - ); -}; - export default function Editor({ initialValue, onChange, @@ -345,7 +105,6 @@ export default function Editor({ senderInfo, myInfo, readOnly, - hideToolbar, }: EditorProps) { const [state, dispatch] = useReducer(editorReducer, { openNode: false, @@ -358,7 +117,7 @@ export default function Editor({ const [editor, setEditor] = useState(null); const containerRef = useRef(null); - const { openNode, openColor, openLink, openAI } = state; + const { openAI } = state; // Function to focus the editor const focusEditor = () => { diff --git a/apps/mail/components/create/email-composer.tsx b/apps/mail/components/create/email-composer.tsx index e46a379fc8..5a1f676476 100644 --- a/apps/mail/components/create/email-composer.tsx +++ b/apps/mail/components/create/email-composer.tsx @@ -33,7 +33,7 @@ import { Avatar, AvatarFallback } from '../ui/avatar'; import { useTRPC } from '@/providers/query-provider'; import { useMutation } from '@tanstack/react-query'; import { useSettings } from '@/hooks/use-settings'; -import { useIsMobile } from '@/hooks/use-mobile'; + import { cn, formatFileSize } from '@/lib/utils'; import { useThread } from '@/hooks/use-threads'; import { serializeFiles } from '@/lib/schemas'; @@ -114,10 +114,8 @@ export function EmailComposer({ className, autofocus = false, settingsLoading = false, - replyingTo, editorClassName, }: EmailComposerProps) { - const isMobile = useIsMobile(); const { data: aliases } = useEmailAliases(); const { data: settings } = useSettings(); const [showCc, setShowCc] = useState(initialCc.length > 0); @@ -465,6 +463,8 @@ export function EmailComposer({ setIsLoading(true); setAiGeneratedMessage(null); + // Save draft before sending, we want to send drafts instead of sending new emails + if (hasUnsavedChanges) await saveDraft(); await onSendEmail({ to: values.to, @@ -556,20 +556,12 @@ export function EmailComposer({ if (!hasUnsavedChanges) return; const messageText = editor.getText(); - console.log({ - messageText, - editorText: editor.getText(), - initialMessage, - editorHTML: editor.getHTML(), - }); if (messageText.trim() === initialMessage.trim()) return; if (editor.getHTML() === initialMessage.trim()) return; if (!values.to.length || !values.subject.length || !messageText.length) return; if (aiGeneratedMessage || aiIsLoading || isGeneratingSubject) return; - console.log('editor.getHTML()', editor.getHTML()); - try { setIsSavingDraft(true); const draftData = { @@ -736,7 +728,7 @@ export function EmailComposer({
{toEmails.map((email, index) => (
@@ -870,7 +862,7 @@ export function EmailComposer({
{toEmails.slice(0, 3).map((email, index) => (
@@ -924,7 +916,7 @@ export function EmailComposer({ > Bcc - {onClose && isMobile && ( + {onClose && ( - - -
{ - const target = e.currentTarget as HTMLFormElement; - e.preventDefault(); - const input = target[0] as HTMLInputElement; - const url = getUrlFromString(input.value); - if (url) { - editor.chain().focus().setLink({ href: url }).run(); - onOpenChange(false); - } - }} - className="flex p-1" - > - - {editor.getAttributes('link').href ? ( - - ) : ( - - )} -
-
- - ); -}; diff --git a/apps/mail/components/create/selectors/math-selector.tsx b/apps/mail/components/create/selectors/math-selector.tsx deleted file mode 100644 index 0b9d4e081c..0000000000 --- a/apps/mail/components/create/selectors/math-selector.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Button } from '@/components/ui/button'; -import { SigmaIcon } from 'lucide-react'; -import { useEditor } from 'novel'; -import { cn } from '@/lib/utils'; - -export const MathSelector = () => { - const { editor } = useEditor(); - - if (!editor) return null; - - return ( - - ); -}; diff --git a/apps/mail/components/create/selectors/node-selector.tsx b/apps/mail/components/create/selectors/node-selector.tsx deleted file mode 100644 index d44bb17f37..0000000000 --- a/apps/mail/components/create/selectors/node-selector.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { - Check, - CheckSquare, - ChevronDown, - Code, - Heading1, - Heading2, - Heading3, - ListOrdered, - type LucideIcon, - TextIcon, - TextQuote, -} from 'lucide-react'; -import { PopoverContent, PopoverTrigger, Popover } from '@/components/ui/popover'; -import { EditorBubbleItem, useEditor } from 'novel'; -import { Button } from '@/components/ui/button'; - -export type SelectorItem = { - name: string; - icon: LucideIcon; - command: (editor: ReturnType['editor']) => void; - isActive: (editor: ReturnType['editor']) => boolean; -}; - -const items: SelectorItem[] = [ - { - name: 'Text', - icon: TextIcon, - command: (editor) => editor?.chain().focus().clearNodes().run(), - // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! - isActive: (editor) => - editor - ? editor.isActive('paragraph') && - !editor.isActive('bulletList') && - !editor.isActive('orderedList') - : false, - }, - { - name: 'Heading 1', - icon: Heading1, - command: (editor) => editor?.chain().focus().clearNodes().toggleHeading({ level: 1 }).run(), - isActive: (editor) => (editor ? editor.isActive('heading', { level: 1 }) : false), - }, - { - name: 'Heading 2', - icon: Heading2, - command: (editor) => editor?.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(), - isActive: (editor) => (editor ? editor.isActive('heading', { level: 2 }) : false), - }, - { - name: 'Heading 3', - icon: Heading3, - command: (editor) => editor?.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(), - isActive: (editor) => (editor ? editor.isActive('heading', { level: 3 }) : false), - }, - { - name: 'To-do List', - icon: CheckSquare, - command: (editor) => editor?.chain().focus().clearNodes().toggleTaskList().run(), - isActive: (editor) => (editor ? editor.isActive('taskItem') : false), - }, - { - name: 'Bullet List', - icon: ListOrdered, - command: (editor) => editor?.chain().focus().clearNodes().toggleBulletList().run(), - isActive: (editor) => (editor ? editor.isActive('bulletList') : false), - }, - { - name: 'Numbered List', - icon: ListOrdered, - command: (editor) => editor?.chain().focus().clearNodes().toggleOrderedList().run(), - isActive: (editor) => (editor ? editor.isActive('orderedList') : false), - }, - { - name: 'Quote', - icon: TextQuote, - command: (editor) => editor?.chain().focus().clearNodes().toggleBlockquote().run(), - isActive: (editor) => (editor ? editor.isActive('blockquote') : false), - }, - { - name: 'Code', - icon: Code, - command: (editor) => editor?.chain().focus().clearNodes().toggleCodeBlock().run(), - isActive: (editor) => (editor ? editor.isActive('codeBlock') : false), - }, -]; -interface NodeSelectorProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => { - const { editor } = useEditor(); - if (!editor) return null; - const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? { - name: 'Multiple', - }; - - return ( - - - - - - {items.map((item) => ( - { - item.command(editor); - onOpenChange(false); - }} - className="hover:bg-accent flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm" - > -
-
- -
- {item.name} -
- {activeItem.name === item.name && } -
- ))} -
-
- ); -}; diff --git a/apps/mail/components/create/selectors/text-buttons.tsx b/apps/mail/components/create/selectors/text-buttons.tsx deleted file mode 100644 index 61b110ff86..0000000000 --- a/apps/mail/components/create/selectors/text-buttons.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { MessageSquare, FileText, Edit } from 'lucide-react'; -import type { SelectorItem } from './node-selector'; -import { EditorBubbleItem, useEditor } from 'novel'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -export const TextButtons = () => { - const { editor } = useEditor(); - if (!editor) return null; - - // Define AI action handlers - const handleChatWithAI = () => { - // Get selected text - const selection = editor.state.selection; - const selectedText = selection.empty - ? '' - : editor.state.doc.textBetween(selection.from, selection.to); - - console.log('Chat with AI about:', selectedText); - // Implement chat with AI functionality - }; - - const items = [ - { - name: 'chat-with-zero', - label: 'Chat with Zero', - action: handleChatWithAI, - useImage: true, - imageSrc: '/ai.svg', - }, - ]; - - return ( -
- {items.map((item) => ( - { - item.action(); - }} - > - - - ))} -
- ); -}; diff --git a/apps/mail/components/create/slash-command.tsx b/apps/mail/components/create/slash-command.tsx index fb51b52803..713449d2c2 100644 --- a/apps/mail/components/create/slash-command.tsx +++ b/apps/mail/components/create/slash-command.tsx @@ -1,15 +1,11 @@ import { - CheckSquare, - Code, Heading1, Heading2, Heading3, - ImageIcon, List, ListOrdered, Text, - TextQuote, -} from 'lucide-react'; + } from 'lucide-react'; import { createSuggestionItems } from 'novel'; export const suggestionItems = createSuggestionItems([ diff --git a/apps/mail/components/create/toolbar.tsx b/apps/mail/components/create/toolbar.tsx index 1c090f6661..1730bfce36 100644 --- a/apps/mail/components/create/toolbar.tsx +++ b/apps/mail/components/create/toolbar.tsx @@ -3,8 +3,6 @@ import { Italic, Strikethrough, Underline, - Code, - Link as LinkIcon, List, ListOrdered, Heading1, diff --git a/apps/mail/components/create/uploaded-file-icon.tsx b/apps/mail/components/create/uploaded-file-icon.tsx index db54360f4d..5e79f871f1 100644 --- a/apps/mail/components/create/uploaded-file-icon.tsx +++ b/apps/mail/components/create/uploaded-file-icon.tsx @@ -1,5 +1,5 @@ import { Button } from '@/components/ui/button'; -import { FileIcon, X } from 'lucide-react'; +import { X } from 'lucide-react'; import React from 'react'; const getLogo = (mimetype: string): string => { diff --git a/apps/mail/components/home/HomeContent.tsx b/apps/mail/components/home/HomeContent.tsx index 02884caad6..49741635b3 100644 --- a/apps/mail/components/home/HomeContent.tsx +++ b/apps/mail/components/home/HomeContent.tsx @@ -1,11 +1,7 @@ import { - ArrowRight, ChevronDown, CurvedArrow, - Discord, GitHub, - LinkedIn, - Twitter, Plus, Cube, MediumStack, @@ -30,15 +26,15 @@ import { Expand, } from '../icons/icons'; import { PixelatedBackground, PixelatedLeft, PixelatedRight } from '@/components/home/pixelated-bg'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tabs, TabsContent } from '@/components/ui/tabs'; +import { signIn, useSession } from '@/lib/auth-client'; +import { Link, useNavigate } from 'react-router'; import { Button } from '@/components/ui/button'; import { Balancer } from 'react-wrap-balancer'; -import { signIn, useSession } from '@/lib/auth-client'; import { Navigation } from '../navigation'; import { useTheme } from 'next-themes'; -import { use, useEffect } from 'react'; import { motion } from 'motion/react'; -import { Link, useNavigate } from 'react-router'; +import { useEffect } from 'react'; import { toast } from 'sonner'; import Footer from './footer'; import React from 'react'; @@ -70,7 +66,7 @@ export default function HomeContent() { }, [setTheme]); return ( -
+
- + {/* Get Started button only visible for mobile screens */}
-
+
-
-
-
+
+
+
@@ -531,8 +527,8 @@ export default function HomeContent() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} > -
-
+
+
@@ -764,8 +760,8 @@ export default function HomeContent() { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} > -
-
+
+
@@ -1044,7 +1040,7 @@ export default function HomeContent() {
-
+
@@ -1183,7 +1179,7 @@ export default function HomeContent() {
-
+
@@ -1201,7 +1197,7 @@ export default function HomeContent() {
-
+
chat
- {firstRowQueries.map((query, i) => ( + {firstRowQueries.map((query) => (
@@ -1241,9 +1237,9 @@ export default function HomeContent() { {/* Second row */}
- {secondRowQueries.map((query, i) => ( + {secondRowQueries.map((query) => (
@@ -1258,7 +1254,7 @@ export default function HomeContent() {
-
+
@@ -1334,24 +1330,3 @@ export default function HomeContent() {
); } -const CustomTabGlow = ({ glowStyle }: { glowStyle: { left: number; width: number } }) => { - return ( -
-
-
-
- ); -}; diff --git a/apps/mail/components/home/footer.tsx b/apps/mail/components/home/footer.tsx index 5efc310ec7..1b056250b4 100644 --- a/apps/mail/components/home/footer.tsx +++ b/apps/mail/components/home/footer.tsx @@ -1,5 +1,5 @@ import { LinkedIn, Twitter, Discord } from '../icons/icons'; -import { motion, useInView } from 'motion/react'; +import { motion } from 'motion/react'; import { Button } from '../ui/button'; import { Link } from 'react-router'; import { useRef } from 'react'; @@ -121,26 +121,26 @@ export default function Footer() {
-
diff --git a/apps/mail/components/icons/icons.tsx b/apps/mail/components/icons/icons.tsx index ac459a50d5..725e8423d9 100644 --- a/apps/mail/components/icons/icons.tsx +++ b/apps/mail/components/icons/icons.tsx @@ -308,7 +308,7 @@ export const Inbox = ({ className }: { className?: string }) => ( ); -export const PaperPlane = ({ className }: { className?: string }) => ( +export const PaperPlane = () => ( ( ); diff --git a/apps/mail/components/labels/label-dialog.tsx b/apps/mail/components/labels/label-dialog.tsx index 0b552136e5..1084ea4ed3 100644 --- a/apps/mail/components/labels/label-dialog.tsx +++ b/apps/mail/components/labels/label-dialog.tsx @@ -17,12 +17,12 @@ import { CurvedArrow } from '@/components/icons/icons'; import { LABEL_COLORS } from '@/lib/label-colors'; import type { Label as LabelType } from '@/types'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; -import { Command } from 'lucide-react'; import { m } from '@/paraglide/messages'; +import { Command } from 'lucide-react'; interface LabelDialogProps { trigger?: React.ReactNode; @@ -94,7 +94,9 @@ export function LabelDialog({ {trigger && {trigger}} - {editingLabel ? m['common.labels.editLabel']() : m['common.mail.createNewLabel']()} + + {editingLabel ? m['common.labels.editLabel']() : m['common.mail.createNewLabel']()} +
{m['common.labels.color']()}
- {LABEL_COLORS.map((color, index) => ( + {LABEL_COLORS.map((color) => ( - ); - }, -); +>(({ value, className, isSelectable = true, isSelect, fileIcon, children, ...props }, ref) => { + const { direction, selectedId, selectItem } = useTree(); + const isSelected = isSelect ?? selectedId === value; + return ( + + ); +}); File.displayName = 'File'; @@ -368,7 +357,7 @@ const CollapseButton = forwardRef< elements: TreeViewElement[]; expandAll?: boolean; } & React.HTMLAttributes ->(({ className, elements, expandAll = false, children, ...props }, ref) => { +>(({ elements, expandAll = false, children, ...props }, ref) => { const { expandedItems, setExpandedItems } = useTree(); const expendAllTree = useCallback((elements: TreeViewElement[]) => { diff --git a/apps/mail/components/mail/mail-content.tsx b/apps/mail/components/mail/mail-content.tsx index 9f4c02b0f8..14496a2a9e 100644 --- a/apps/mail/components/mail/mail-content.tsx +++ b/apps/mail/components/mail/mail-content.tsx @@ -200,7 +200,7 @@ export function MailContent({ id, html, senderEmail }: MailContentProps) {
)} -
+
); } diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx index c0285c8c14..25fd702890 100644 --- a/apps/mail/components/mail/mail-display.tsx +++ b/apps/mail/components/mail/mail-display.tsx @@ -23,7 +23,6 @@ import { HardDriveDownload, Loader2, CopyIcon, - SearchIcon, } from 'lucide-react'; import { DropdownMenu, @@ -31,12 +30,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '../ui/dropdown-menu'; -import { cn, getEmailLogo, formatDate, formatTime, shouldShowSeparateTime } from '@/lib/utils'; +import { cn, formatDate, formatTime, shouldShowSeparateTime } from '@/lib/utils'; import { Dialog, DialogTitle, DialogHeader, DialogContent } from '../ui/dialog'; import { memo, useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; -import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; import type { Sender, ParsedMessage, Attachment } from '@/types'; import { useActiveConnection } from '@/hooks/use-connections'; import { useBrainState } from '../../hooks/use-summary'; @@ -46,16 +44,16 @@ import { useMutation } from '@tanstack/react-query'; import { Markdown } from '@react-email/components'; import { useSummary } from '@/hooks/use-summary'; import { TextShimmer } from '../ui/text-shimmer'; +import { useThread } from '@/hooks/use-threads'; +import { BimiAvatar } from '../ui/bimi-avatar'; import { RenderLabels } from './render-labels'; import { MailContent } from './mail-content'; import { m } from '@/paraglide/messages'; import { useParams } from 'react-router'; import { FileText } from 'lucide-react'; -import { Button } from '../ui/button'; import { useQueryState } from 'nuqs'; import { Badge } from '../ui/badge'; import { format } from 'date-fns'; -import { cleanHtml } from '@/lib/email-utils'; import { toast } from 'sonner'; // HTML escaping function to prevent XSS attacks @@ -66,135 +64,6 @@ function escapeHtml(text: string): string { return div.innerHTML; } -function TextSelectionPopover({ - children, - onSearch, -}: { - children: React.ReactNode; - onSearch: (query: string) => void; -}) { - const [selectionCoords, setSelectionCoords] = useState<{ x: number; y: number } | null>(null); - const [selectedText, setSelectedText] = useState(''); - const popoverTriggerRef = useRef(null); - const popoverRef = useRef(null); - - const handleSelectionChange = useCallback(() => { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) { - setSelectionCoords(null); - setSelectedText(''); - return; - } - - try { - const range = selection.getRangeAt(0); - const rect = range.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2 + window.scrollX - window.innerWidth / 2; - const y = rect.top + window.scrollY; - - setSelectionCoords({ x: centerX, y }); - setSelectedText(selection.toString().trim()); - } catch (error) { - console.error('Error handling text selection:', error); - setSelectionCoords(null); - setSelectedText(''); - } - }, []); - - // const handleClickOutside = useCallback((event: MouseEvent) => { - // if ( - // popoverRef.current && - // !popoverRef.current.contains(event.target as Node) && - // !popoverTriggerRef.current?.contains(event.target as Node) - // ) { - // setSelectionCoords(null); - // setSelectedText(''); - // } - // }, []); - - useEffect(() => { - document.addEventListener('mouseup', handleSelectionChange); - // document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - setSelectionCoords(null); - setSelectedText(''); - } - }); - - return () => { - document.removeEventListener('mouseup', handleSelectionChange); - // document.removeEventListener('mousedown', handleClickOutside); - }; - }, [handleSelectionChange]); - - return ( -
- {children} - {selectionCoords && ( -
- (open ? undefined : setSelectedText(''))} - > - - - -
-
- - -
- )} -
- ); -} - // Add formatFileSize utility function const formatFileSize = (size: number) => { const sizeInMB = (size / (1024 * 1024)).toFixed(2); @@ -377,13 +246,6 @@ const MailDisplayLabels = ({ labels }: { labels: string[] }) => { ); }; -// Helper function to get first letter character -const getFirstLetterCharacter = (name?: string) => { - if (!name) return ''; - const match = name.match(/[a-zA-Z]/); - return match ? match[0].toUpperCase() : ''; -}; - // Helper function to clean email display const cleanEmailDisplay = (email?: string) => { if (!email) return ''; @@ -404,7 +266,7 @@ const ThreadAttachments = ({ attachments }: { attachments: Attachment[] }) => { try { // Convert base64 to blob const byteCharacters = atob(attachment.body); - const byteNumbers = new Array(byteCharacters.length); + const byteNumbers: number[] = Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } @@ -519,7 +381,7 @@ const ActionButton = ({ onClick, icon, text, shortcut }: ActionButtonProps) => { const downloadAttachment = (attachment: { body: string; mimeType: string; filename: string }) => { try { const byteCharacters = atob(attachment.body); - const byteNumbers = new Array(byteCharacters.length); + const byteNumbers: number[] = Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } @@ -551,7 +413,7 @@ const handleDownloadAllAttachments = attachments.forEach((attachment) => { try { const byteCharacters = atob(attachment.body); - const byteNumbers = new Array(byteCharacters.length); + const byteNumbers: number[] = Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } @@ -596,7 +458,7 @@ const handleDownloadAllAttachments = const openAttachment = (attachment: { body: string; mimeType: string; filename: string }) => { try { const byteCharacters = atob(attachment.body); - const byteNumbers = new Array(byteCharacters.length); + const byteNumbers: number[] = Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } @@ -627,7 +489,6 @@ const openAttachment = (attachment: { body: string; mimeType: string; filename: const MoreAboutPerson = ({ person, - extra, open, onOpenChange, }: { @@ -776,6 +637,7 @@ const MoreAboutQuery = ({ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: Props) => { const [isCollapsed, setIsCollapsed] = useState(false); + const { data: threadData } = useThread(emailData.threadId ?? null); // const [unsubscribed, setUnsubscribed] = useState(false); // const [isUnsubscribing, setIsUnsubscribing] = useState(false); const [preventCollapse, setPreventCollapse] = useState(false); @@ -799,7 +661,10 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: const [researchSender, setResearchSender] = useState(null); const [searchQuery, setSearchQuery] = useState(null); - const isLastEmail = totalEmails && index === totalEmails - 1; + const isLastEmail = useMemo( + () => emailData.id === threadData?.latest?.id, + [emailData.id, threadData?.latest?.id], + ); const [, setMode] = useQueryState('mode'); @@ -1272,7 +1137,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: } } catch (error) { console.error('Error printing email:', error); - alert('Failed to print email. Please try again.'); + toast.error('Failed to print email. Please try again.'); } }; @@ -1284,12 +1149,11 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: key={person.email} className="dark:bg-panelDark inline-flex items-center justify-start gap-1.5 overflow-hidden rounded-full border bg-white p-1 pr-2" > - - - - {getFirstLetterCharacter(person.name || person.email)} - - +
{person.name || person.email}
@@ -1297,12 +1161,11 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
- - - - {getFirstLetterCharacter(person.name || person.email)} - - +

{person.name || 'Unknown'}

@@ -1350,7 +1213,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: } }} > - + <> {searchQuery && ( - {people.slice(2).map((person, index) => ( -
{renderPerson(person)}
+ {people.slice(2).map((person) => ( +
{renderPerson(person)}
))}
@@ -1438,15 +1301,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: >
- - - - {getFirstLetterCharacter(emailData?.sender?.name)} - - +
@@ -1463,7 +1318,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: // extra: emailData?.sender?.extra || '', }); }} - className="hover:bg-muted font-semibold" + className="hover:bg-muted max-w-36 truncate whitespace-nowrap font-semibold md:max-w-none" > {cleanNameDisplay(emailData?.sender?.name)} @@ -1597,12 +1452,12 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
-
-
- ) : null; + ); + }, [ + latestMessage, + getThreadData, + optimisticState, + idToUse, + folder, + isFolderBin, + isFolderSent, + isFolderSpam, + isFolderInbox, + onClick, + searchValue, + displayUnread, + isMailSelected, + isMailBulkSelected, + threadLabels, + optimisticLabels, + emailContent, + ]); return latestMessage ? ( !optimisticState.shouldHide && idToUse ? ( @@ -880,8 +822,8 @@ export const MailList = memo( const clickedIndex = itemsRef.current.findIndex((item) => item.id === messageThreadId); setFocusedIndex(clickedIndex); if (message.unread && autoRead) optimisticMarkAsRead([messageThreadId], true); - await setThreadId(messageThreadId); - await setDraftId(null); + setThreadId(messageThreadId); + setDraftId(null); // Don't clear activeReplyId - let ThreadDisplay handle Reply All auto-opening }, [ @@ -917,8 +859,6 @@ export const MailList = memo( }); }; - const { resolvedTheme } = useTheme(); - const filteredItems = useMemo(() => items.filter((item) => item.id), [items]); const Comp = useMemo(() => (folder === FOLDERS.DRAFT ? Draft : Thread), [folder]); @@ -993,16 +933,15 @@ export const MailList = memo( { if (!vListRef.current) return; const endIndex = vListRef.current.findEndIndex(); if ( // if the shown items are last 5 items, load more - Math.abs(filteredItems.length - 1 - endIndex) < 5 && + Math.abs(filteredItems.length - 1 - endIndex) < 7 && !isLoading && !isFetchingNextPage && !isFetchingMail && @@ -1011,7 +950,9 @@ export const MailList = memo( void loadMore(); } }} - /> + > + {vListRenderer} +
)} @@ -1078,14 +1019,6 @@ export const MailLabels = memo( }, ); -function getNormalizedLabelKey(label: string) { - return label.toLowerCase().replace(/^category_/i, ''); -} - -function capitalize(str: string) { - return str.substring(0, 1).toUpperCase() + str.substring(1).toLowerCase(); -} - function getLabelIcon(label: string) { const normalizedLabel = label.toLowerCase().replace(/^category_/i, ''); diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx index 353df3b5f7..0f275cedb9 100644 --- a/apps/mail/components/mail/mail.tsx +++ b/apps/mail/components/mail/mail.tsx @@ -1,18 +1,3 @@ -import { - Archive2, - Bell, - CurvedArrow, - Eye, - Lightning, - Mail, - ScanEye, - Star2, - Tag, - Trash, - User, - X, - Search, -} from '../icons/icons'; import { Dialog, DialogContent, @@ -22,20 +7,28 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { Bell, Lightning, Mail, ScanEye, Tag, Trash, User, X, Search } from '../icons/icons'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; import { useCategorySettings, useDefaultCategoryId } from '@/hooks/use-categories'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useActiveConnection, useConnections } from '@/hooks/use-connections'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useCommandPalette } from '../context/command-palette-context'; -import { useOptimisticActions } from '@/hooks/use-optimistic-actions'; +import { Check, ChevronDown, RefreshCcw } from 'lucide-react'; + import { ThreadDisplay } from '@/components/mail/thread-display'; -import { trpcClient, useTRPC } from '@/providers/query-provider'; -import { backgroundQueueAtom } from '@/store/backgroundQueue'; -import { handleUnsubscribe } from '@/lib/email-utils.client'; +import { useActiveConnection } from '@/hooks/use-connections'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useTRPC } from '@/providers/query-provider'; + import { useMediaQuery } from '../../hooks/use-media-query'; -import { useSearchValue } from '@/hooks/use-search-value'; + +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'; @@ -49,20 +42,20 @@ 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 { Command, RefreshCcw } from 'lucide-react'; -import { cleanSearchValue, cn } from '@/lib/utils'; 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 { useStats } from '@/hooks/use-stats'; -import type { IConnection } from '@/types'; + +import { cn } from '@/lib/utils'; + import { m } from '@/paraglide/messages'; import { useQueryState } from 'nuqs'; import { useAtom } from 'jotai'; @@ -111,9 +104,17 @@ export const defaultLabels = [ const AutoLabelingSettings = () => { const trpc = useTRPC(); const [open, setOpen] = useState(false); - const { data: storedLabels } = useQuery(trpc.brain.getLabels.queryOptions()); + const { data: storedLabels, refetch: refetchStoredLabels } = useQuery( + trpc.brain.getLabels.queryOptions(void 0, { + staleTime: 1000 * 60 * 60, // 1 hour + }), + ); const { mutateAsync: updateLabels, isPending } = useMutation( - trpc.brain.updateLabels.mutationOptions(), + trpc.brain.updateLabels.mutationOptions({ + onSuccess: () => { + refetchStoredLabels(); + }, + }), ); const [, setPricingDialog] = useQueryState('pricingDialog'); const [labels, setLabels] = useState([]); @@ -407,7 +408,7 @@ export function MailLayout() { const [{ isFetching, refetch: refetchThreads }] = useThreads(); const isDesktop = useMediaQuery('(min-width: 768px)'); - const [threadId, setThreadId] = useQueryState('threadId'); + const [threadId] = useQueryState('threadId'); useEffect(() => { if (threadId) { @@ -435,8 +436,6 @@ export function MailLayout() { disableScope('mail-list'); }, [disableScope]); - const [, setActiveReplyId] = useQueryState('activeReplyId'); - // Add mailto protocol handler registration useEffect(() => { // Register as a mailto protocol handler if browser supports it @@ -457,7 +456,7 @@ export function MailLayout() { }, []); const defaultCategoryId = useDefaultCategoryId(); - const [category, setCategory] = useQueryState('category', { defaultValue: defaultCategoryId }); + const [category] = useQueryState('category', { defaultValue: defaultCategoryId }); return ( @@ -473,7 +472,7 @@ export function MailLayout() { minSize={35} maxSize={35} className={cn( - `bg-panelLight dark:bg-panelDark mb-1 mr-[3px] w-fit shadow-sm md:rounded-2xl lg:flex lg:h-[calc(100dvh-8px)] lg:shadow-sm`, + `bg-panelLight dark:bg-panelDark mb-1 w-fit shadow-sm md:mr-[3px] md:rounded-2xl lg:flex lg:h-[calc(100dvh-8px)] lg:shadow-sm`, isDesktop && threadId && 'hidden lg:block', )} onMouseEnter={handleMailListMouseEnter} @@ -482,13 +481,13 @@ export function MailLayout() {
-
+
- +
@@ -528,11 +527,11 @@ export function MailLayout() {
-
+
)} - + - {/*
- {activeAccount?.providerId === 'google' && folder === 'inbox' && ( - 0} /> - )} -
*/} + {activeConnection?.providerId === 'google' && folder === 'inbox' && ( + 0} /> + )}
-
+ {/* removed 88px because there is no category picker add it back in height dvh calc when category picker is added */} +
@@ -637,186 +635,6 @@ export function MailLayout() { ); } -function BulkSelectActions() { - const [isLoading, setIsLoading] = useState(false); - const [isUnsub, setIsUnsub] = useState(false); - const [mail, setMail] = useMail(); - const params = useParams<{ folder: string }>(); - const folder = params?.folder ?? 'inbox'; - const [{ refetch: refetchThreads }] = useThreads(); - const { refetch: refetchStats } = useStats(); - const { - optimisticMarkAsRead, - optimisticToggleStar, - optimisticMoveThreadsTo, - optimisticDeleteThreads, - } = useOptimisticActions(); - - const handleMassUnsubscribe = async () => { - setIsLoading(true); - toast.promise( - Promise.all( - mail.bulkSelected.filter(Boolean).map(async (bulkSelected) => { - await new Promise((resolve) => setTimeout(resolve, 499)); - const emailData = await trpcClient.mail.get.query({ id: bulkSelected }); - if (emailData) { - const firstEmail = emailData.latest; - if (firstEmail) - return handleUnsubscribe({ emailData: firstEmail }).catch((e) => { - toast.error(e.message ?? 'Unknown error while unsubscribing'); - }); - } - }), - ).then(async () => { - setIsUnsub(false); - setIsLoading(false); - await refetchThreads(); - await refetchStats(); - setMail({ ...mail, bulkSelected: [] }); - }), - { - loading: 'Unsubscribing...', - success: 'All done! you will no longer receive emails from these mailing lists.', - error: 'Something went wrong!', - }, - ); - }; - - return ( -
- - - - - - - {m['common.mail.starAll']()} - - - - - - - {m['common.mail.archive']()} - - - - - - - - - - {m['common.mail.unSubscribeFromAll']()} - - - { - if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleMassUnsubscribe(); - } - }} - > - - Mass Unsubscribe - - We will remove you from all of the mailing lists in the selected threads. If your - action is required to unsubscribe from certain threads, you will be notified. - - - - - - - - - - - - - - - {m['common.mail.moveToBin']()} - -
- ); -} - export const Categories = () => { const defaultCategoryIdInner = useDefaultCategoryId(); const categorySettings = useCategorySettings(); @@ -921,384 +739,74 @@ export const Categories = () => { return categories; }; - -type CategoryType = ReturnType[0]; - -function getCategoryColor(categoryId: string): string { - switch (categoryId.toLowerCase()) { - case 'primary': - return 'bg-[#006FFE]'; - case 'all mail': - return 'bg-[#006FFE]'; - case 'important': - return 'bg-[#F59E0D]'; - case 'promotions': - return 'bg-[#F43F5E]'; - case 'personal': - return 'bg-[#39ae4a]'; - case 'updates': - return 'bg-[#8B5CF6]'; - case 'unread': - return 'bg-[#FF4800]'; - default: - return 'bg-base-primary-500'; - } +interface CategoryDropdownProps { + isMultiSelectMode?: boolean; } -function CategorySelect({ isMultiSelectMode }: { isMultiSelectMode: boolean }) { - const [mail, setMail] = useMail(); - const [searchValue, setSearchValue] = useSearchValue(); - const categories = Categories(); +function CategoryDropdown({ isMultiSelectMode }: CategoryDropdownProps) { + const { systemLabels } = useLabels(); + const { setLabels, labels } = useSearchLabels(); const params = useParams<{ folder: string }>(); const folder = params?.folder ?? 'inbox'; - const defaultCategoryIdInner = useDefaultCategoryId(); - const [category, setCategory] = useQueryState('category', { - defaultValue: defaultCategoryIdInner, - }); - const containerRef = useRef(null); - const activeTabElementRef = useRef(null); - const overlayContainerRef = useRef(null); - const [textSize, setTextSize] = useState<'normal' | 'small' | 'xs' | 'hidden'>('normal'); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const [isOpen, setIsOpen] = useState(false); - if (folder !== 'inbox') return
; - - useEffect(() => { - const checkTextSize = () => { - const container = containerRef.current; - if (!container) return; - - const containerWidth = container.offsetWidth; - const selectedCategory = categories.find((cat) => cat.id === category); - - // Calculate approximate widths needed for different text sizes - const baseIconWidth = (categories.length - 1) * 40; // unselected icons + gaps - const selectedTextLength = selectedCategory ? selectedCategory.name.length : 10; - - // Estimate width needed for different text sizes - const normalTextWidth = selectedTextLength * 8 + 60; // normal text - const smallTextWidth = selectedTextLength * 7 + 50; // smaller text - const xsTextWidth = selectedTextLength * 6 + 40; // extra small text - const minIconWidth = 40; // minimum width for icon-only selected button - - const totalNormal = baseIconWidth + normalTextWidth; - const totalSmall = baseIconWidth + smallTextWidth; - const totalXs = baseIconWidth + xsTextWidth; - const totalIconOnly = baseIconWidth + minIconWidth; - - if (containerWidth >= totalNormal) { - setTextSize('normal'); - } else if (containerWidth >= totalSmall) { - setTextSize('small'); - } else if (containerWidth >= totalXs) { - setTextSize('xs'); - } else if (containerWidth >= totalIconOnly) { - setTextSize('hidden'); // Hide text but keep button wide - } else { - setTextSize('hidden'); // Hide text in very tight spaces - } - }; - - checkTextSize(); - - // Use ResizeObserver to handle container size changes - const resizeObserver = new ResizeObserver(() => { - checkTextSize(); - }); - - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [category, categories]); - - const renderCategoryButton = (cat: CategoryType, isOverlay = false, idx: number) => { - const isSelected = cat.id === (category || 'Primary'); - const bgColor = getCategoryColor(cat.id); - - // Determine text classes based on current text size - const getTextClasses = () => { - switch (textSize) { - case 'normal': - return 'text-sm'; - case 'small': - return 'text-xs'; - case 'xs': - return 'text-[10px]'; - case 'hidden': - return 'text-sm'; // Doesn't matter since text is hidden - default: - return 'text-sm'; - } - }; + if (folder !== 'inbox' || isMultiSelectMode) return null; - // Determine padding based on text size - const getPaddingClasses = () => { - switch (textSize) { - case 'normal': - return 'px-3'; - case 'small': - return 'px-2.5'; - case 'xs': - return 'px-2'; - case 'hidden': - return 'px-2'; // Just enough padding for the icon - default: - return 'px-3'; - } - }; - - const showText = textSize !== 'hidden'; - - const button = ( - - ); - - if (!isDesktop) { - return React.cloneElement(button, { key: cat.id }); + const handleLabelChange = (labelId: string) => { + const index = labels.indexOf(labelId); + if (index !== -1) { + const newLabels = [...labels]; + newLabels.splice(index, 1); + setLabels(newLabels); + } else { + setLabels([...labels, labelId]); } - - return ( - - {button} - - {cat.name} - - {idx + 1} - - - - ); }; - // Update clip path when category changes - useEffect(() => { - const container = overlayContainerRef.current; - const activeTabElement = activeTabElementRef.current; - - if (category && container && activeTabElement) { - setMail({ ...mail, bulkSelected: [] }); - const { offsetLeft, offsetWidth } = activeTabElement; - const clipLeft = Math.max(0, offsetLeft - 2); - const clipRight = Math.min(container.offsetWidth, offsetLeft + offsetWidth + 2); - const containerWidth = container.offsetWidth; - - if (containerWidth) { - container.style.clipPath = `inset(0 ${Number(100 - (clipRight / containerWidth) * 100).toFixed(2)}% 0 ${Number((clipLeft / containerWidth) * 100).toFixed(2)}%)`; - } - } - }, [category, textSize]); // Changed from showText to textSize - - if (isMultiSelectMode) { - return ; - } - return ( -
-
- {categories.map((cat, idx) => renderCategoryButton(cat, false, idx))} -
- -
+ + + + -
- {categories.map((cat, idx) => renderCategoryButton(cat, true, idx))} -
-
-
- ); -} - -function MailCategoryTabs({ - iconsOnly = false, - onCategoryChange, - initialCategory, -}: { - iconsOnly?: boolean; - onCategoryChange?: (category: string) => void; - initialCategory?: string; -}) { - const [, setSearchValue] = useSearchValue(); - const categories = Categories(); - - // Initialize with just the initialCategory or "Primary" - const [activeCategory, setActiveCategory] = useState(initialCategory || 'Primary'); - - const containerRef = useRef(null); - const activeTabElementRef = useRef(null); - - const activeTab = useMemo( - () => categories.find((cat) => cat.id === activeCategory), - [activeCategory], - ); - - // Save to localStorage when activeCategory changes - useEffect(() => { - if (onCategoryChange) { - onCategoryChange(activeCategory); - } - }, [activeCategory, onCategoryChange]); - - useEffect(() => { - if (activeTab) { - setSearchValue({ - value: activeTab.searchValue, - highlight: '', - folder: '', - }); - } - }, [activeCategory, setSearchValue]); - - // Cleanup on unmount - useEffect(() => { - return () => { - setSearchValue({ - value: '', - highlight: '', - folder: '', - }); - }; - }, [setSearchValue]); - - // Function to update clip path - const updateClipPath = useCallback(() => { - const container = containerRef.current; - const activeTabElement = activeTabElementRef.current; - - if (activeCategory && container && activeTabElement) { - const { offsetLeft, offsetWidth } = activeTabElement; - const clipLeft = Math.max(0, offsetLeft - 2); - const clipRight = Math.min(container.offsetWidth, offsetLeft + offsetWidth + 2); - const containerWidth = container.offsetWidth; - - if (containerWidth) { - container.style.clipPath = `inset(0 ${Number(100 - (clipRight / containerWidth) * 100).toFixed(2)}% 0 ${Number((clipLeft / containerWidth) * 100).toFixed(2)}%)`; - } - } - }, [activeCategory]); - - // Update clip path when active category changes - useEffect(() => { - updateClipPath(); - }, [activeCategory, updateClipPath]); - - // Update clip path when iconsOnly changes - useEffect(() => { - // Small delay to ensure DOM has updated with new sizes - const timer = setTimeout(() => { - updateClipPath(); - }, 10); - - return () => clearTimeout(timer); - }, [iconsOnly, updateClipPath]); - - // Update clip path on window resize - useEffect(() => { - const handleResize = () => { - updateClipPath(); - }; - - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [updateClipPath]); - - return ( -
-
    - {categories.map((category) => ( -
  • - - - - - {iconsOnly && ( - - {category.name} - - )} - -
  • + {systemLabels.map((label) => ( + { + e.preventDefault(); + e.stopPropagation(); + handleLabelChange(label.id); + }} + role="menuitemcheckbox" + aria-checked={labels.includes(label.id)} + > + {label.name.toLowerCase()} + {labels.includes(label.id) && } + ))} -
- -
-
    - {categories.map((category) => ( -
  • - -
  • - ))} -
-
-
+ + ); } diff --git a/apps/mail/components/mail/navbar.tsx b/apps/mail/components/mail/navbar.tsx index 3f999e1c43..acb13f3b55 100644 --- a/apps/mail/components/mail/navbar.tsx +++ b/apps/mail/components/mail/navbar.tsx @@ -20,9 +20,9 @@ export function Nav({ links, isCollapsed }: NavProps) { className="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2" >