mirror of
https://github.com/Mail-0/Zero.git
synced 2026-03-03 00:27:01 +00:00
cleanup on isle zero (#1699)
Ran oxc (https://oxc.rs/docs/guide/usage/linter.html#vscode-extension) and fixed all the issues that came up, set it up to run as a PR check and added steps to the README.md asking users to use it. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced JavaScript linting using oxlint in development guidelines and CI workflow for improved code quality. * Added oxlint configuration and dependencies to the project. * **Bug Fixes** * Improved error logging in various components and utilities for better debugging. * Enhanced React list rendering by updating keys to use unique values instead of array indices, reducing rendering issues. * Replaced browser alerts with toast notifications for a smoother user experience. * **Refactor** * Simplified component logic and state management by removing unused code, imports, props, and components across multiple files. * Updated function and component signatures for clarity and maintainability. * Improved efficiency of certain operations by switching from arrays to sets for membership checks. * **Chores** * Cleaned up and reorganized import statements throughout the codebase. * Removed deprecated files, components, and middleware to streamline the codebase. * **Documentation** * Updated contribution guidelines to include linting requirements for code submissions. * **Style** * Minor formatting and readability improvements in JSX and code structure. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -23,3 +23,6 @@ jobs:
|
||||
|
||||
- name: Install dependencies 📦
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint JS
|
||||
run: pnpm dlx oxlint@latest --deny-warnings
|
||||
|
||||
18
.oxlintrc.json
Normal file
18
.oxlintrc.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [allContributors, setAllContributors] = useState<Contributor[]>([]);
|
||||
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() {
|
||||
<a
|
||||
href="https://discord.gg/mail0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-500 transition-colors hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
|
||||
aria-label="Join our Discord"
|
||||
>
|
||||
@@ -1019,6 +1019,7 @@ export default function OpenPage() {
|
||||
<a
|
||||
href="https://x.com/mail0dotcom"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-500 transition-colors hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
|
||||
aria-label="Follow us on X (Twitter)"
|
||||
>
|
||||
|
||||
@@ -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<WorkingHours>({
|
||||
startTime: '09:00',
|
||||
endTime: '17:00',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
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.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-screen flex-1 flex-col overflow-x-hidden bg-[#0F0F0F]">
|
||||
<PixelatedBackground
|
||||
|
||||
@@ -5,14 +5,14 @@ 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 = 'May 16, 2025';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { copiedValue: copiedSection, copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
const handleCopyLink = (sectionId: string) => {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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() {
|
||||
|
||||
@@ -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,7 +24,7 @@ export default function MailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [isLabelValid, setIsLabelValid] = useState<boolean | null>(true);
|
||||
|
||||
const isStandardFolder = ALLOWED_FOLDERS.includes(folder);
|
||||
const isStandardFolder = ALLOWED_FOLDERS.has(folder);
|
||||
|
||||
const { userLabels, isLoading: isLoadingLabels } = useLabels();
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 (
|
||||
|
||||
@@ -110,7 +110,7 @@ export default function AppearancePage() {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="colorTheme"
|
||||
render={({ field }) => (
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{m['pages.settings.appearance.theme']()}</FormLabel>
|
||||
<FormControl>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
<div className="space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
{[...Array(3)].map((n) => (
|
||||
<div
|
||||
key={i}
|
||||
key={n}
|
||||
className="bg-popover flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
|
||||
@@ -1,40 +1,29 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
} from '@/components/ui/form';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { SettingsCard } from '@/components/settings/settings-card';
|
||||
import { LabelDialog } from '@/components/labels/label-dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CurvedArrow } from '@/components/icons/icons';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Check, Plus, Pencil } from 'lucide-react';
|
||||
import { Plus, Pencil } from 'lucide-react';
|
||||
import { type Label as LabelType } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
|
||||
import { Bin } from '@/components/icons/icons';
|
||||
import { useLabels } from '@/hooks/use-labels';
|
||||
import { GMAIL_COLORS } from '@/lib/constants';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { Command } from 'lucide-react';
|
||||
import { COLORS } from './colors';
|
||||
|
||||
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Shortcut
|
||||
<ShortcutItem
|
||||
key={`${scope}-${index}`}
|
||||
keys={shortcut.keys}
|
||||
action={shortcut.action}
|
||||
// action={shortcut.action}
|
||||
>
|
||||
{label}
|
||||
</Shortcut>
|
||||
</ShortcutItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -18,16 +18,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 +29,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 +186,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
|
||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([]);
|
||||
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
|
||||
const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
|
||||
// const [selectedLabels] = useState<string[]>([]);
|
||||
const [filterBuilderState, setFilterBuilderState] = useState<Record<string, string>>({});
|
||||
const [saveSearchName, setSaveSearchName] = useState('');
|
||||
const [emailSuggestions, setEmailSuggestions] = useState<string[]>([]);
|
||||
@@ -199,7 +197,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const { userLabels = [] } = useLabels();
|
||||
const trpc = useTRPC();
|
||||
const { mutateAsync: generateSearchQuery, isPending } = useMutation(
|
||||
const { mutateAsync: generateSearchQuery } = useMutation(
|
||||
trpc.ai.generateSearchQuery.mutationOptions(),
|
||||
);
|
||||
|
||||
@@ -845,7 +843,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{allCommands.map((group, groupIndex) => (
|
||||
<Fragment key={groupIndex}>
|
||||
<Fragment key={group.group}>
|
||||
{group.items.length > 0 && (
|
||||
<CommandGroup heading={group.group}>
|
||||
{group.items.map((item) => (
|
||||
@@ -1460,9 +1458,9 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm">{label.name || 'Unnamed Label'}</span>
|
||||
{selectedLabels.includes(label.id || '') && (
|
||||
{/* {selectedLabels.includes(label.id || '') && (
|
||||
<Check className="ml-auto h-4 w-4" />
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
SIDEBAR_COOKIE_MAX_AGE,
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
SIDEBAR_KEYBOARD_SHORTCUT,
|
||||
SIDEBAR_WIDTH,
|
||||
SIDEBAR_WIDTH_ICON,
|
||||
} from '@/lib/constants';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -91,9 +90,9 @@ const ExampleQueries = ({ onQueryClick }: { onQueryClick: (query: string) => voi
|
||||
{/* First row */}
|
||||
<div className="no-scrollbar relative flex w-full justify-center overflow-x-auto">
|
||||
<div className="flex gap-4 px-4">
|
||||
{firstRowQueries.map((query, index) => (
|
||||
{firstRowQueries.map((query) => (
|
||||
<button
|
||||
key={index}
|
||||
key={query}
|
||||
onClick={() => onQueryClick(query)}
|
||||
className="flex-shrink-0 whitespace-nowrap rounded-md bg-[#f0f0f0] p-1 px-2 text-sm text-[#555555] dark:bg-[#262626] dark:text-[#929292]"
|
||||
>
|
||||
@@ -106,9 +105,9 @@ const ExampleQueries = ({ onQueryClick }: { onQueryClick: (query: string) => voi
|
||||
{/* Second row */}
|
||||
<div className="no-scrollbar relative flex w-full justify-center overflow-x-auto">
|
||||
<div className="flex gap-4 px-4">
|
||||
{secondRowQueries.map((query, index) => (
|
||||
{secondRowQueries.map((query) => (
|
||||
<button
|
||||
key={index}
|
||||
key={query}
|
||||
onClick={() => onQueryClick(query)}
|
||||
className="flex-shrink-0 whitespace-nowrap rounded-md bg-[#f0f0f0] p-1 px-2 text-sm text-[#555555] dark:bg-[#262626] dark:text-[#929292]"
|
||||
>
|
||||
@@ -227,12 +226,10 @@ const ToolResponse = ({ toolName, result, args }: { toolName: string; result: an
|
||||
|
||||
export function AIChat({
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
error,
|
||||
handleSubmit,
|
||||
status,
|
||||
stop,
|
||||
}: AIChatProps): React.ReactElement {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -323,20 +320,20 @@ export function AIChat({
|
||||
messages.map((message, index) => {
|
||||
const textParts = message.parts.filter((part) => part.type === 'text');
|
||||
const toolParts = message.parts.filter((part) => part.type === 'tool-invocation');
|
||||
const streamingTools = [Tools.WebSearch];
|
||||
const streamingTools = new Set([Tools.WebSearch]);
|
||||
const doesIncludeStreamingTool = toolParts.some(
|
||||
(part) =>
|
||||
streamingTools.includes(part.toolInvocation?.toolName as Tools) &&
|
||||
streamingTools.has(part.toolInvocation?.toolName as Tools) &&
|
||||
part.toolInvocation?.result,
|
||||
);
|
||||
return (
|
||||
<div key={`${message.id}-${index}`} className="flex flex-col">
|
||||
{toolParts.map((part, idx) =>
|
||||
{toolParts.map((part) =>
|
||||
part.toolInvocation &&
|
||||
part.toolInvocation.result &&
|
||||
!streamingTools.includes(part.toolInvocation.toolName as Tools) ? (
|
||||
!streamingTools.has(part.toolInvocation.toolName as Tools) ? (
|
||||
<ToolResponse
|
||||
key={idx}
|
||||
key={part.toolInvocation.toolName}
|
||||
toolName={part.toolInvocation.toolName}
|
||||
result={part.toolInvocation.result}
|
||||
args={part.toolInvocation.args}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useActiveConnection, useConnections } from '@/hooks/use-connections';
|
||||
import { useActiveConnection } from '@/hooks/use-connections';
|
||||
import { Dialog, DialogClose } from '@/components/ui/dialog';
|
||||
import { useEmailAliases } from '@/hooks/use-email-aliases';
|
||||
import { cleanEmailAddresses } from '@/lib/email-utils';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { EmailComposer } from './email-composer';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { serializeFiles } from '@/lib/schemas';
|
||||
import { useDraft } from '@/hooks/use-drafts';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { Attachment } from '@/types';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { useQueryState } from 'nuqs';
|
||||
@@ -31,15 +31,6 @@ type DraftType = {
|
||||
attachments?: File[];
|
||||
};
|
||||
|
||||
// Define the connection type
|
||||
type Connection = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
picture: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export function CreateEmail({
|
||||
initialTo = '',
|
||||
initialSubject = '',
|
||||
@@ -56,7 +47,7 @@ export function CreateEmail({
|
||||
draftId?: string | null;
|
||||
}) {
|
||||
const { data: session } = useSession();
|
||||
const { data: connections } = useConnections();
|
||||
|
||||
const { data: aliases } = useEmailAliases();
|
||||
const [draftId, setDraftId] = useQueryState('draftId');
|
||||
const {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { EditorProvider, useCurrentEditor } from '@tiptap/react';
|
||||
import { UploadedFileIcon } from './uploaded-file-icon';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { truncateFileName } from '@/lib/utils';
|
||||
import { Paperclip } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const MenuBar = () => {
|
||||
@@ -139,88 +134,6 @@ const MenuBar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Define props interface for AttachmentButtons
|
||||
interface AttachmentButtonsProps {
|
||||
attachments?: File[];
|
||||
onAttachmentAdd?: () => void;
|
||||
onAttachmentRemove?: (index: number) => void;
|
||||
}
|
||||
|
||||
const AttachmentButtons = ({
|
||||
attachments = [],
|
||||
onAttachmentAdd,
|
||||
onAttachmentRemove,
|
||||
}: AttachmentButtonsProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Attachment Counter Button */}
|
||||
{attachments.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="bg-background hover:bg-muted rounded p-1.5"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{attachments.length} Files
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 touch-auto" align="end">
|
||||
<div className="space-y-2">
|
||||
<div className="px-1">
|
||||
<h4 className="font-medium leading-none">Attachments</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{attachments.length} {attachments.length === 1 ? 'file' : 'files'}
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="h-[300px] touch-auto overflow-y-auto overscroll-contain px-1 py-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{attachments.map((file, index) => (
|
||||
<div key={index} className="group relative overflow-hidden rounded-md border">
|
||||
<UploadedFileIcon
|
||||
removeAttachment={(index) =>
|
||||
onAttachmentRemove && onAttachmentRemove(index)
|
||||
}
|
||||
index={index}
|
||||
file={file}
|
||||
/>
|
||||
<div className="bg-muted/10 p-2">
|
||||
<p className="text-xs font-medium">{truncateFileName(file.name, 20)}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
{/* Add Attachment Button */}
|
||||
<button
|
||||
onClick={onAttachmentAdd}
|
||||
className="bg-background hover:bg-muted rounded p-1.5"
|
||||
title="Add attachment"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default () => {
|
||||
return <EditorProvider slotBefore={<MenuBar />}></EditorProvider>;
|
||||
};
|
||||
|
||||
@@ -127,9 +127,9 @@ export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-muted-foreground my-1 px-2 text-sm font-semibold">Color</div>
|
||||
{TEXT_COLORS.map(({ name, color }, index) => (
|
||||
{TEXT_COLORS.map(({ name, color }) => (
|
||||
<EditorBubbleItem
|
||||
key={index}
|
||||
key={name}
|
||||
onSelect={() => {
|
||||
// editor.commands.unsetColor();
|
||||
name !== 'Default' &&
|
||||
@@ -152,9 +152,9 @@ export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground my-1 px-2 text-sm font-semibold">Background</div>
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => (
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }) => (
|
||||
<EditorBubbleItem
|
||||
key={index}
|
||||
key={name}
|
||||
onSelect={() => {
|
||||
editor.commands.unsetHighlight();
|
||||
name !== 'Default' && editor.commands.setHighlight({ color });
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const { editor } = useEditor();
|
||||
|
||||
// Autofocus on input by default
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current?.focus();
|
||||
});
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" className="gap-2 rounded-none border-none">
|
||||
<p className="text-base">↗</p>
|
||||
<p
|
||||
className={cn('underline decoration-stone-400 underline-offset-4', {
|
||||
'text-blue-500': editor.isActive('link'),
|
||||
})}
|
||||
>
|
||||
Link
|
||||
</p>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
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"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Paste a link"
|
||||
className="bg-background flex-1 p-1 text-sm outline-none"
|
||||
defaultValue={editor.getAttributes('link').href || ''}
|
||||
/>
|
||||
{editor.getAttributes('link').href ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" className="h-8">
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
className="hover:bg-accent gap-2 rounded-none border-none focus:ring-0"
|
||||
>
|
||||
<Button variant="ghost" className="gap-2">
|
||||
<span className="whitespace-nowrap text-sm">{activeItem.name}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent sideOffset={5} align="start" className="w-48 p-1">
|
||||
{items.map((item, index) => (
|
||||
<EditorBubbleItem
|
||||
key={index}
|
||||
onSelect={(editor) => {
|
||||
item.command(editor);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="hover:bg-accent flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border p-1">
|
||||
<item.icon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -41,9 +41,9 @@ export const TextButtons = () => {
|
||||
];
|
||||
return (
|
||||
<div className="flex">
|
||||
{items.map((item, index) => (
|
||||
{items.map((item) => (
|
||||
<EditorBubbleItem
|
||||
key={index}
|
||||
key={item.name}
|
||||
onSelect={(editor) => {
|
||||
item.command(editor);
|
||||
}}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<div className="control-group mb-2 overflow-x-auto">
|
||||
<div className="button-group ml-0 mt-1 flex flex-wrap gap-1 border-b pb-2">
|
||||
<div className="mr-2 flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||
className={`h-auto w-auto rounded p-1.5 ${editor.isActive('bold') ? 'bg-muted' : 'bg-background'}`}
|
||||
title="Bold"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.pages.createEmail.editor.menuBar.bold()}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
className={`h-auto w-auto rounded p-1.5 ${editor.isActive('italic') ? 'bg-muted' : 'bg-background'}`}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.pages.createEmail.editor.menuBar.italic()}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
||||
className={`h-auto w-auto rounded p-1.5 ${editor.isActive('strike') ? 'bg-muted' : 'bg-background'}`}
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{m.pages.createEmail.editor.menuBar.strikethrough()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
className={`h-auto w-auto rounded p-1.5 ${editor.isActive('underline') ? 'bg-muted' : 'bg-background'}`}
|
||||
>
|
||||
<Underline className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.pages.createEmail.editor.menuBar.underline()}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLinkDialogOpen}
|
||||
className={`h-auto w-auto rounded p-1.5 ${editor.isActive('link') ? 'bg-muted' : 'bg-background'}`}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.pages.createEmail.editor.menuBar.link()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="relative right-1 top-0.5 h-6" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={`h-auto w-auto rounded p-1.5 ${editor.isActive('bulletList') ? 'bg-muted' : 'bg-background'}`}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.pages.createEmail.editor.menuBar.bulletList()}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={`h-auto w-auto rounded p-1.5 ${editor.isActive('orderedList') ? 'bg-muted' : 'bg-background'}`}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m.pages.createEmail.editor.menuBar.orderedList()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<Dialog open={linkDialogOpen} onOpenChange={setLinkDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{m.pages.createEmail.addLink()}</DialogTitle>
|
||||
<DialogDescription>{m.pages.createEmail.addUrlToCreateALink()}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="url" className="text-sm font-medium">
|
||||
URL
|
||||
</label>
|
||||
<Input
|
||||
id="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex justify-between sm:justify-between">
|
||||
<Button variant="outline" onClick={handleRemoveLink} type="button">
|
||||
{m.common.actions.cancel()}
|
||||
</Button>
|
||||
<Button onClick={handleSaveLink} type="button">
|
||||
{m.common.actions.save()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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<TiptapEditor | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { openNode, openColor, openLink, openAI } = state;
|
||||
const { openAI } = state;
|
||||
|
||||
// Function to focus the editor
|
||||
const focusEditor = () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -730,7 +728,7 @@ export function EmailComposer({
|
||||
<div ref={toWrapperRef} className="flex flex-wrap items-center gap-2">
|
||||
{toEmails.map((email, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={email}
|
||||
className="flex items-center gap-1 rounded-full border px-1 py-0.5 pr-2"
|
||||
>
|
||||
<span className="flex gap-1 py-0.5 text-sm text-black dark:text-white">
|
||||
@@ -864,7 +862,7 @@ export function EmailComposer({
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{toEmails.slice(0, 3).map((email, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={email}
|
||||
className="flex items-center gap-1 rounded-full border px-1 py-0.5 pr-2"
|
||||
>
|
||||
<span className="flex gap-1 py-0.5 text-sm text-black dark:text-white">
|
||||
@@ -949,7 +947,7 @@ export function EmailComposer({
|
||||
<div ref={ccWrapperRef} className="flex flex-1 flex-wrap items-center gap-2">
|
||||
{ccEmails?.map((email, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={email}
|
||||
className="flex items-center gap-1 rounded-full border px-2 py-0.5"
|
||||
>
|
||||
<span className="flex gap-1 py-0.5 text-sm text-black dark:text-white">
|
||||
@@ -1040,7 +1038,7 @@ export function EmailComposer({
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{ccEmails.slice(0, 3).map((email, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={email}
|
||||
className="flex items-center gap-1 rounded-full border px-1 py-0.5 pr-2"
|
||||
>
|
||||
<span className="flex gap-1 py-0.5 text-sm text-black dark:text-white">
|
||||
@@ -1095,7 +1093,7 @@ export function EmailComposer({
|
||||
<div ref={bccWrapperRef} className="flex flex-1 flex-wrap items-center gap-2">
|
||||
{bccEmails?.map((email, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={email}
|
||||
className="flex items-center gap-1 rounded-full border px-2 py-0.5"
|
||||
>
|
||||
<span className="flex gap-1 py-0.5 text-sm text-black dark:text-white">
|
||||
@@ -1186,7 +1184,7 @@ export function EmailComposer({
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{bccEmails.slice(0, 3).map((email, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={email}
|
||||
className="flex items-center gap-1 rounded-full border px-1 py-0.5 pr-2"
|
||||
>
|
||||
<span className="flex gap-1 py-0.5 text-sm text-black dark:text-white">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Settings, Image, FileImage, Zap } from 'lucide-react';
|
||||
import type { ImageQuality } from '@/lib/image-compression';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { m } from '@/paraglide/messages';
|
||||
|
||||
|
||||
@@ -1,101 +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<HTMLInputElement>(null);
|
||||
const { editor } = useEditor();
|
||||
|
||||
// Autofocus on input by default
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="gap-2 rounded-none border-none">
|
||||
<p className="text-base">↗</p>
|
||||
<p
|
||||
className={cn('underline decoration-stone-400 underline-offset-4', {
|
||||
'text-blue-500': editor.isActive('link'),
|
||||
})}
|
||||
>
|
||||
Link
|
||||
</p>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
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"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Paste a link"
|
||||
className="bg-background flex-1 p-1 text-sm outline-none"
|
||||
defaultValue={editor.getAttributes('link').href || ''}
|
||||
/>
|
||||
{editor.getAttributes('link').href ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" className="h-8">
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-12 rounded-none"
|
||||
onClick={(evt) => {
|
||||
if (editor.isActive('math')) {
|
||||
editor.chain().focus().unsetLatex().run();
|
||||
} else {
|
||||
const { from, to } = editor.state.selection;
|
||||
const latex = editor.state.doc.textBetween(from, to);
|
||||
|
||||
if (!latex) return;
|
||||
|
||||
editor.chain().focus().setLatex({ latex }).run();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SigmaIcon
|
||||
className={cn('size-4', { 'text-blue-500': editor.isActive('math') })}
|
||||
strokeWidth={2.3}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -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<typeof useEditor>['editor']) => void;
|
||||
isActive: (editor: ReturnType<typeof useEditor>['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 (
|
||||
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
className="hover:bg-accent gap-2 rounded-none border-none focus:ring-0"
|
||||
>
|
||||
<Button size="sm" variant="ghost" className="gap-2">
|
||||
<span className="whitespace-nowrap text-sm">{activeItem.name}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent sideOffset={5} align="start" className="w-48 p-1">
|
||||
{items.map((item) => (
|
||||
<EditorBubbleItem
|
||||
key={item.name}
|
||||
onSelect={(editor) => {
|
||||
item.command(editor);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="hover:bg-accent flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border p-1">
|
||||
<item.icon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -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([
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Underline,
|
||||
Code,
|
||||
Link as LinkIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
Heading1,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 { Navigation } from '../navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { use, useEffect } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import Footer from './footer';
|
||||
import React from 'react';
|
||||
@@ -1221,9 +1217,9 @@ export default function HomeContent() {
|
||||
{/* First row */}
|
||||
<div className="no-scrollbar relative flex w-full justify-center">
|
||||
<div className="flex items-center justify-start gap-2 whitespace-nowrap">
|
||||
{firstRowQueries.map((query, i) => (
|
||||
{firstRowQueries.map((query) => (
|
||||
<div
|
||||
key={i}
|
||||
key={query}
|
||||
className="flex h-7 flex-shrink-0 items-center justify-start gap-1.5 overflow-hidden rounded-md bg-[#303030] px-2 py-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-start gap-1 px-0.5">
|
||||
@@ -1241,9 +1237,9 @@ export default function HomeContent() {
|
||||
{/* Second row */}
|
||||
<div className="no-scrollbar relative flex w-full justify-center">
|
||||
<div className="flex items-center justify-start gap-2 whitespace-nowrap">
|
||||
{secondRowQueries.map((query, i) => (
|
||||
{secondRowQueries.map((query) => (
|
||||
<div
|
||||
key={i}
|
||||
key={query}
|
||||
className="flex h-7 flex-shrink-0 items-center justify-start gap-1.5 overflow-hidden rounded-md bg-[#303030] px-2 py-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-start gap-1 px-0.5">
|
||||
@@ -1334,24 +1330,3 @@ export default function HomeContent() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
const CustomTabGlow = ({ glowStyle }: { glowStyle: { left: number; width: number } }) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute w-1/3 transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
left: `${glowStyle.left}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${glowStyle.width}px`,
|
||||
}}
|
||||
className="bottom-0 h-12 translate-y-1/2 rounded-full bg-[radial-gradient(ellipse_at_center,_rgba(255,255,255,0.3)_0%,_transparent_70%)] blur-md"
|
||||
/>
|
||||
<div
|
||||
style={{ width: `${glowStyle.width}px` }}
|
||||
className="bottom-0 h-px rounded-full bg-gradient-to-r from-transparent via-white/90 to-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-start justify-end gap-10 opacity-0 md:opacity-100">
|
||||
<div className="inline-flex flex-col items-start justify-start gap-5">
|
||||
<div className="inline-flex flex-col items-start justify-start gap-5">
|
||||
<div className="justify-start self-stretch text-sm font-normal text-white/40">
|
||||
Resources
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-4 self-stretch">
|
||||
<a target="_blank" href="https://trust.inc/zero" className="w-full">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://trust.inc/zero"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="justify-start self-stretch text-base font-normal leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
SOC2
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="/privacy"
|
||||
className="w-full"
|
||||
target="_blank"
|
||||
>
|
||||
<a href="/privacy" className="w-full" target="_blank">
|
||||
<div className="justify-start self-stretch text-base leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
Privacy Policy
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex flex-col items-start justify-start gap-5">
|
||||
@@ -152,6 +152,7 @@ export default function Footer() {
|
||||
href="https://x.com/nizzyabi/status/1918064165530550286"
|
||||
className="w-full"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="justify-start self-stretch text-base leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
Chat with Zero
|
||||
@@ -161,6 +162,7 @@ export default function Footer() {
|
||||
href="https://x.com/nizzyabi/status/1918051282881069229"
|
||||
className="w-full"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="justify-start self-stretch text-base leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
Zero AI
|
||||
@@ -170,6 +172,7 @@ export default function Footer() {
|
||||
href="https://x.com/nizzyabi/status/1919292505260249486"
|
||||
className="w-full"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="justify-start self-stretch text-base leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
Shortcuts
|
||||
@@ -192,14 +195,18 @@ export default function Footer() {
|
||||
About
|
||||
</div>
|
||||
</a>
|
||||
<a target="_blank" href="https://github.com/Mail-0/Zero" className="w-full">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/Mail-0/Zero"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="justify-start self-stretch text-base font-normal leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
Github
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-0.5 self-stretch bg-white/20" />
|
||||
|
||||
@@ -308,7 +308,7 @@ export const Inbox = ({ className }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PaperPlane = ({ className }: { className?: string }) => (
|
||||
export const PaperPlane = () => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
|
||||
@@ -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 && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
<DialogContent showOverlay={true}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingLabel ? m['common.labels.editLabel']() : m['common.mail.createNewLabel']()}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editingLabel ? m['common.labels.editLabel']() : m['common.mail.createNewLabel']()}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -125,9 +127,9 @@ export function LabelDialog({
|
||||
<Label>{m['common.labels.color']()}</Label>
|
||||
<div className="w-full">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LABEL_COLORS.map((color, index) => (
|
||||
{LABEL_COLORS.map((color) => (
|
||||
<button
|
||||
key={index}
|
||||
key={color.backgroundColor}
|
||||
type="button"
|
||||
className={`h-10 w-10 rounded-[4px] border-[0.5px] border-white/10 transition-all ${
|
||||
formColor?.backgroundColor.toString() === color.backgroundColor &&
|
||||
@@ -153,7 +155,9 @@ export function LabelDialog({
|
||||
{m['common.actions.cancel']()}
|
||||
</Button>
|
||||
<Button className="h-8 [&_svg]:size-4" type="submit">
|
||||
{editingLabel ? m['common.actions.saveChanges']() : m['common.labels.createLabel']()}
|
||||
{editingLabel
|
||||
? m['common.actions.saveChanges']()
|
||||
: m['common.labels.createLabel']()}
|
||||
<div className="flex h-5 items-center justify-center gap-1 rounded-sm bg-white/10 px-1 dark:bg-black/10">
|
||||
<Command className="h-3 w-3 text-white dark:text-[#929292]" />
|
||||
<CurvedArrow className="mt-1.5 h-3.5 w-3.5 fill-white dark:fill-[#929292]" />
|
||||
|
||||
@@ -198,125 +198,119 @@ type FolderProps = {
|
||||
color?: string;
|
||||
} & FolderComponentProps;
|
||||
|
||||
const Folder = forwardRef<HTMLDivElement, FolderProps & React.HTMLAttributes<HTMLDivElement>>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
element,
|
||||
count,
|
||||
value,
|
||||
isSelectable = true,
|
||||
isSelect,
|
||||
children,
|
||||
onFolderClick,
|
||||
hasChildren,
|
||||
color,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const childrenCount = React.Children.count(children);
|
||||
const canExpand = hasChildren !== undefined ? hasChildren : childrenCount > 0;
|
||||
const {
|
||||
direction,
|
||||
handleExpand,
|
||||
expandedItems,
|
||||
indicator,
|
||||
setExpandedItems,
|
||||
openIcon,
|
||||
closeIcon,
|
||||
} = useTree();
|
||||
const Folder = ({
|
||||
className,
|
||||
element,
|
||||
count,
|
||||
value,
|
||||
isSelectable = true,
|
||||
isSelect,
|
||||
children,
|
||||
onFolderClick,
|
||||
hasChildren,
|
||||
...props
|
||||
}: FolderProps) => {
|
||||
const childrenCount = React.Children.count(children);
|
||||
const canExpand = hasChildren !== undefined ? hasChildren : childrenCount > 0;
|
||||
const {
|
||||
direction,
|
||||
handleExpand,
|
||||
expandedItems,
|
||||
indicator,
|
||||
setExpandedItems,
|
||||
openIcon,
|
||||
closeIcon,
|
||||
} = useTree();
|
||||
|
||||
return (
|
||||
<Accordion.Item {...props} value={value} className="relative h-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
`flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm hover:bg-black/10 dark:hover:bg-[#202020]`,
|
||||
className,
|
||||
{
|
||||
'bg-sidebar-accent rounded-md': isSelect && isSelectable,
|
||||
'cursor-pointer': isSelectable,
|
||||
'cursor-not-allowed opacity-50': !isSelectable,
|
||||
},
|
||||
)}
|
||||
{...(!canExpand && isSelectable && onFolderClick
|
||||
return (
|
||||
<Accordion.Item {...props} value={value} className="relative h-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
`flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm hover:bg-black/10 dark:hover:bg-[#202020]`,
|
||||
className,
|
||||
{
|
||||
'bg-sidebar-accent rounded-md': isSelect && isSelectable,
|
||||
'cursor-pointer': isSelectable,
|
||||
'cursor-not-allowed opacity-50': !isSelectable,
|
||||
},
|
||||
)}
|
||||
{...(!canExpand && isSelectable && onFolderClick
|
||||
? {
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onFolderClick(value);
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{canExpand ? (
|
||||
<Accordion.Trigger
|
||||
className="flex cursor-[ns-resize] items-center"
|
||||
disabled={!isSelectable}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExpand(value);
|
||||
}}
|
||||
>
|
||||
{expandedItems?.includes(value)
|
||||
? (openIcon ?? <FolderOpenIcon className="relative mr-3 size-4" />)
|
||||
: (closeIcon ?? <FolderIcon className="relative mr-3 size-4" />)}
|
||||
</Accordion.Trigger>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Bookmark className={cn(`relative mr-3 size-4`)} />
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={cn('max-w-[124px] flex-1 truncate', {
|
||||
'cursor-pointer': canExpand && isSelectable && onFolderClick,
|
||||
'font-bold': isSelect,
|
||||
})}
|
||||
{...(canExpand && isSelectable && onFolderClick
|
||||
? {
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onFolderClick(value);
|
||||
if (onFolderClick) {
|
||||
onFolderClick(value);
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{canExpand ? (
|
||||
<Accordion.Trigger
|
||||
className="flex cursor-[ns-resize] items-center"
|
||||
disabled={!isSelectable}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExpand(value);
|
||||
}}
|
||||
>
|
||||
{expandedItems?.includes(value)
|
||||
? (openIcon ?? <FolderOpenIcon className="relative mr-3 size-4" />)
|
||||
: (closeIcon ?? <FolderIcon className="relative mr-3 size-4" />)}
|
||||
</Accordion.Trigger>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<Bookmark className={cn(`relative mr-3 size-4`)} />
|
||||
</div>
|
||||
)}
|
||||
{element}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn('max-w-[124px] flex-1 truncate', {
|
||||
'cursor-pointer': canExpand && isSelectable && onFolderClick,
|
||||
'font-bold': isSelect,
|
||||
})}
|
||||
{...(canExpand && isSelectable && onFolderClick
|
||||
? {
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
if (onFolderClick) {
|
||||
onFolderClick(value);
|
||||
}
|
||||
},
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
}
|
||||
: {})}
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto shrink-0 rounded-full bg-transparent px-2 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{element}
|
||||
{count}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto shrink-0 rounded-full bg-transparent px-2 py-0.5 text-xs font-medium',
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Accordion.Content className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down relative h-full overflow-hidden text-sm">
|
||||
{element && indicator && <TreeIndicator aria-hidden="true" />}
|
||||
<Accordion.Root
|
||||
dir={direction}
|
||||
type="multiple"
|
||||
className="ml-5 flex flex-col gap-1 py-1 rtl:mr-5"
|
||||
defaultValue={expandedItems}
|
||||
value={expandedItems}
|
||||
onValueChange={(value) => {
|
||||
setExpandedItems?.((prev) => [...(prev ?? []), value[0]]);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Accordion.Root>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
)}
|
||||
</div>
|
||||
<Accordion.Content className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down relative h-full overflow-hidden text-sm">
|
||||
{element && indicator && <TreeIndicator aria-hidden="true" />}
|
||||
<Accordion.Root
|
||||
dir={direction}
|
||||
type="multiple"
|
||||
className="ml-5 flex flex-col gap-1 py-1 rtl:mr-5"
|
||||
defaultValue={expandedItems}
|
||||
value={expandedItems}
|
||||
onValueChange={(value) => {
|
||||
setExpandedItems?.((prev) => [...(prev ?? []), value[0]]);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Accordion.Root>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
);
|
||||
};
|
||||
|
||||
Folder.displayName = 'Folder';
|
||||
|
||||
@@ -329,36 +323,31 @@ const File = forwardRef<
|
||||
isSelect?: boolean;
|
||||
fileIcon?: React.ReactNode;
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
>(
|
||||
(
|
||||
{ value, className, handleSelect, isSelectable = true, isSelect, fileIcon, children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const { direction, selectedId, selectItem } = useTree();
|
||||
const isSelected = isSelect ?? selectedId === value;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={!isSelectable}
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-1 rounded-md pr-1 text-sm duration-200 ease-in-out rtl:pl-1 rtl:pr-0',
|
||||
{
|
||||
'bg-muted': isSelected && isSelectable,
|
||||
},
|
||||
isSelectable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50',
|
||||
direction === 'rtl' ? 'rtl' : 'ltr',
|
||||
className,
|
||||
)}
|
||||
onClick={() => selectItem(value)}
|
||||
{...props}
|
||||
>
|
||||
{fileIcon ?? <FileIcon className="size-4" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
>(({ value, className, isSelectable = true, isSelect, fileIcon, children, ...props }, ref) => {
|
||||
const { direction, selectedId, selectItem } = useTree();
|
||||
const isSelected = isSelect ?? selectedId === value;
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
disabled={!isSelectable}
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-1 rounded-md pr-1 text-sm duration-200 ease-in-out rtl:pl-1 rtl:pr-0',
|
||||
{
|
||||
'bg-muted': isSelected && isSelectable,
|
||||
},
|
||||
isSelectable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50',
|
||||
direction === 'rtl' ? 'rtl' : 'ltr',
|
||||
className,
|
||||
)}
|
||||
onClick={() => selectItem(value)}
|
||||
{...props}
|
||||
>
|
||||
{fileIcon ?? <FileIcon className="size-4" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
File.displayName = 'File';
|
||||
|
||||
@@ -368,7 +357,7 @@ const CollapseButton = forwardRef<
|
||||
elements: TreeViewElement[];
|
||||
expandAll?: boolean;
|
||||
} & React.HTMLAttributes<HTMLButtonElement>
|
||||
>(({ className, elements, expandAll = false, children, ...props }, ref) => {
|
||||
>(({ elements, expandAll = false, children, ...props }, ref) => {
|
||||
const { expandedItems, setExpandedItems } = useTree();
|
||||
|
||||
const expendAllTree = useCallback((elements: TreeViewElement[]) => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
HardDriveDownload,
|
||||
Loader2,
|
||||
CopyIcon,
|
||||
SearchIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -31,30 +30,27 @@ 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 { BimiAvatar, getFirstLetterCharacter } from '../ui/bimi-avatar';
|
||||
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 { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useBrainState } from '../../hooks/use-summary';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useThreadLabels } from '@/hooks/use-labels';
|
||||
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 { cleanHtml } from '@/lib/email-utils';
|
||||
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';
|
||||
@@ -68,140 +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<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSelectionChange = useCallback((e: MouseEvent) => {
|
||||
if (window.getSelection()?.toString().trim()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="relative" ref={popoverTriggerRef}>
|
||||
{children}
|
||||
{selectionCoords && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute z-50"
|
||||
style={{
|
||||
top: selectionCoords.y,
|
||||
left: selectionCoords.x,
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label="Text selection options"
|
||||
>
|
||||
<Popover
|
||||
open={!!selectedText.trim().length}
|
||||
onOpenChange={(open) => (open ? undefined : setSelectedText(''))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="invisible h-0 w-0" aria-label="Text selection options" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
className="break-words rounded-lg p-0"
|
||||
onInteractOutside={() => {
|
||||
setSelectionCoords(null);
|
||||
setSelectedText('');
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-2">
|
||||
<p className="text-muted-foreground max-w-[200px] truncate text-sm">
|
||||
{selectedText}
|
||||
</p>
|
||||
<div className="flex">
|
||||
<Button
|
||||
size="icon"
|
||||
className="scale-75 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onSearch(selectedText);
|
||||
}}
|
||||
>
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="scale-75 rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(selectedText);
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add formatFileSize utility function
|
||||
const formatFileSize = (size: number) => {
|
||||
const sizeInMB = (size / (1024 * 1024)).toFixed(2);
|
||||
@@ -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,7 +637,7 @@ const MoreAboutQuery = ({
|
||||
|
||||
const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }: Props) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
|
||||
const { data: threadData } = useThread(emailData.threadId);
|
||||
const { data: threadData } = useThread(emailData.threadId ?? null);
|
||||
// const [unsubscribed, setUnsubscribed] = useState(false);
|
||||
// const [isUnsubscribing, setIsUnsubscribing] = useState(false);
|
||||
const [preventCollapse, setPreventCollapse] = useState(false);
|
||||
@@ -1276,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.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1414,8 +1275,8 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex flex-col gap-1">
|
||||
{people.slice(2).map((person, index) => (
|
||||
<div key={index}>{renderPerson(person)}</div>
|
||||
{people.slice(2).map((person) => (
|
||||
<div key={person.email}>{renderPerson(person)}</div>
|
||||
))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -1779,8 +1640,8 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
|
||||
{/* mail attachments */}
|
||||
{emailData?.attachments && emailData?.attachments.length > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 px-4 pt-4">
|
||||
{emailData?.attachments.map((attachment, index) => (
|
||||
<div key={index} className="flex">
|
||||
{emailData?.attachments.map((attachment) => (
|
||||
<div key={attachment.filename} className="flex">
|
||||
<button
|
||||
className="flex cursor-pointer items-center gap-1 rounded-[5px] bg-[#FAFAFA] px-1.5 py-1 text-sm font-medium hover:bg-[#F0F0F0] dark:bg-[#262626] dark:hover:bg-[#303030]"
|
||||
onClick={() => openAttachment(attachment)}
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import {
|
||||
cn,
|
||||
FOLDERS,
|
||||
formatDate,
|
||||
getEmailLogo,
|
||||
getMainSearchTerm,
|
||||
parseNaturalLanguageSearch,
|
||||
} from '@/lib/utils';
|
||||
import {
|
||||
Archive2,
|
||||
ExclamationCircle,
|
||||
@@ -14,37 +6,29 @@ import {
|
||||
Trash,
|
||||
PencilCompose,
|
||||
} from '../icons/icons';
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from 'react';
|
||||
import { useIsFetching, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ComponentProps } from 'react';
|
||||
import { useOptimisticThreadState } from '@/components/mail/optimistic-thread-state';
|
||||
import { focusedIndexAtom, useMailNavigation } from '@/hooks/use-mail-navigation';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useIsFetching, type UseQueryResult } from '@tanstack/react-query';
|
||||
import type { MailSelectMode, ParsedMessage, ThreadProps } from '@/types';
|
||||
import type { ParsedDraft } from '../../../server/src/lib/driver/types';
|
||||
import { ThreadContextMenu } from '@/components/context/thread-context';
|
||||
import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||
import { useMail, type Config } from '@/components/mail/use-mail';
|
||||
import { type ThreadDestination } from '@/lib/thread-actions';
|
||||
import { useThread, useThreads } from '@/hooks/use-threads';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { EmptyStateIcon } from '../icons/empty-state-svg';
|
||||
import { highlightText } from '@/lib/email-utils.client';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
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 { template } from '@/lib/email-utils.client';
|
||||
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { useThreadNotes } from '@/hooks/use-notes';
|
||||
|
||||
import { useKeyState } from '@/hooks/use-hot-key';
|
||||
import { VList, type VListHandle } from 'virtua';
|
||||
import { BimiAvatar } from '../ui/bimi-avatar';
|
||||
@@ -53,10 +37,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { useDraft } from '@/hooks/use-drafts';
|
||||
import { Check, Star } from 'lucide-react';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import { StickyNote } from 'lucide-react';
|
||||
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { useParams } from 'react-router';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { Categories } from './mail';
|
||||
@@ -69,15 +53,11 @@ const Thread = memo(
|
||||
isKeyboardFocused,
|
||||
index,
|
||||
}: ThreadProps & { index?: number }) {
|
||||
const [searchValue, setSearchValue] = useSearchValue();
|
||||
const [searchValue] = useSearchValue();
|
||||
const { folder } = useParams<{ folder: string }>();
|
||||
const [, threads] = useThreads();
|
||||
const [threadId] = useQueryState('threadId');
|
||||
const {
|
||||
data: getThreadData,
|
||||
isGroupThread,
|
||||
latestDraft,
|
||||
} = useThread(message.id, message.historyId);
|
||||
const { data: getThreadData, isGroupThread, latestDraft } = useThread(message.id);
|
||||
const [id, setThreadId] = useQueryState('threadId');
|
||||
const [focusedIndex, setFocusedIndex] = useAtom(focusedIndexAtom);
|
||||
|
||||
@@ -251,7 +231,7 @@ const Thread = memo(
|
||||
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',
|
||||
(isMailSelected || isMailBulkSelected || isKeyboardFocused) &&
|
||||
'border-border bg-primary/5 opacity-100',
|
||||
'border-border bg-primary/5 opacity-100',
|
||||
isKeyboardFocused && 'ring-primary/50',
|
||||
'relative',
|
||||
'group',
|
||||
@@ -956,7 +936,6 @@ export const MailList = memo(
|
||||
overscan={5}
|
||||
itemSize={100}
|
||||
className="scrollbar-none flex-1 overflow-x-hidden"
|
||||
children={vListRenderer}
|
||||
onScroll={() => {
|
||||
if (!vListRef.current) return;
|
||||
const endIndex = vListRef.current.findEndIndex();
|
||||
@@ -971,7 +950,9 @@ export const MailList = memo(
|
||||
void loadMore();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{vListRenderer}
|
||||
</VList>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -1038,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, '');
|
||||
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
import {
|
||||
Archive2,
|
||||
Bell,
|
||||
CurvedArrow,
|
||||
Eye,
|
||||
Lightning,
|
||||
Mail,
|
||||
ScanEye,
|
||||
Star2,
|
||||
Tag,
|
||||
Trash,
|
||||
User,
|
||||
X,
|
||||
Search,
|
||||
} from '../icons/icons';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -28,21 +13,21 @@ import {
|
||||
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 { Check, ChevronDown, Command, RefreshCcw } from 'lucide-react';
|
||||
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';
|
||||
@@ -57,7 +42,6 @@ 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 { cleanSearchValue, cn } from '@/lib/utils';
|
||||
import { useThreads } from '@/hooks/use-threads';
|
||||
import { useBilling } from '@/hooks/use-billing';
|
||||
import AIToggleButton from '../ai-toggle-button';
|
||||
@@ -69,8 +53,9 @@ 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';
|
||||
@@ -423,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) {
|
||||
@@ -451,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
|
||||
@@ -473,7 +456,7 @@ export function MailLayout() {
|
||||
}, []);
|
||||
|
||||
const defaultCategoryId = useDefaultCategoryId();
|
||||
const [category, setCategory] = useQueryState('category', { defaultValue: defaultCategoryId });
|
||||
const [category] = useQueryState('category', { defaultValue: defaultCategoryId });
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
@@ -652,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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="flex h-8 flex-1 items-center justify-center gap-1 overflow-hidden rounded-md border bg-white px-3 text-sm transition-all duration-300 ease-out hover:bg-gray-100 dark:border-none dark:bg-[#313131] dark:hover:bg-[#313131]/80"
|
||||
onClick={() => {
|
||||
if (mail.bulkSelected.length === 0) return;
|
||||
optimisticMarkAsRead(mail.bulkSelected);
|
||||
}}
|
||||
>
|
||||
<div className="relative overflow-visible">
|
||||
<Eye className="fill-[#9D9D9D] dark:fill-[#9D9D9D]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2.5">
|
||||
<div className="justify-start leading-none">Mark all as read</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex aspect-square h-8 items-center justify-center gap-1 overflow-hidden rounded-md border bg-white px-2 text-sm transition-all duration-300 ease-out hover:bg-gray-100 dark:border-none dark:bg-[#313131] dark:hover:bg-[#313131]/80"
|
||||
onClick={() => {
|
||||
if (mail.bulkSelected.length === 0) return;
|
||||
optimisticToggleStar(mail.bulkSelected, true);
|
||||
}}
|
||||
>
|
||||
<div className="relative overflow-visible">
|
||||
<Star2 className="fill-[#9D9D9D] stroke-[#9D9D9D] dark:stroke-[#9D9D9D]" />
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m['common.mail.starAll']()}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex aspect-square h-8 items-center justify-center gap-1 overflow-hidden rounded-md border bg-white px-2 text-sm transition-all duration-300 ease-out hover:bg-gray-100 dark:border-none dark:bg-[#313131] dark:hover:bg-[#313131]/80"
|
||||
onClick={() => {
|
||||
if (mail.bulkSelected.length === 0) return;
|
||||
optimisticMoveThreadsTo(mail.bulkSelected, folder, 'archive');
|
||||
}}
|
||||
>
|
||||
<div className="relative overflow-visible">
|
||||
<Archive2 className="fill-[#9D9D9D]" />
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m['common.mail.archive']()}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Dialog onOpenChange={setIsUnsub} open={isUnsub}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<button className="flex aspect-square h-8 items-center justify-center gap-1 overflow-hidden rounded-md border bg-white px-2 text-sm transition-all duration-300 ease-out hover:bg-gray-100 dark:border-none dark:bg-[#313131] dark:hover:bg-[#313131]/80">
|
||||
<div className="relative overflow-visible">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2.3}
|
||||
stroke="currentColor"
|
||||
className="size-4"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636"
|
||||
strokeOpacity={0.6}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m['common.mail.unSubscribeFromAll']()}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DialogContent
|
||||
showOverlay
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleMassUnsubscribe();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Mass Unsubscribe</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="mt-3 h-8" onClick={() => setIsUnsub(false)}>
|
||||
<span>Cancel</span>{' '}
|
||||
</Button>
|
||||
<Button
|
||||
className="mt-3 h-8 [&_svg]:size-3.5"
|
||||
disabled={isLoading}
|
||||
onClick={handleMassUnsubscribe}
|
||||
>
|
||||
<span>Unsubscribe</span>
|
||||
<div className="flex h-5 items-center justify-center gap-1 rounded-sm bg-white/10 px-1 dark:bg-black/10">
|
||||
<Command className="h-2 w-3 text-white dark:text-[#929292]" />
|
||||
<CurvedArrow className="mt-1.5 h-5 w-3.5 fill-white dark:fill-[#929292]" />
|
||||
</div>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex aspect-square h-8 items-center justify-center gap-1 overflow-hidden rounded-md border border-[#FCCDD5] bg-[#FDE4E9] px-2 text-sm transition-all duration-300 ease-out hover:bg-[#FDE4E9]/80 dark:border-[#6E2532] dark:bg-[#411D23] dark:hover:bg-[#313131]/80 hover:dark:bg-[#411D23]/60"
|
||||
onClick={() => {
|
||||
if (mail.bulkSelected.length === 0) return;
|
||||
optimisticDeleteThreads(mail.bulkSelected, folder);
|
||||
}}
|
||||
>
|
||||
<div className="relative overflow-visible">
|
||||
<Trash className="fill-[#F43F5E]" />
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{m['common.mail.moveToBin']()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Categories = () => {
|
||||
const defaultCategoryIdInner = useDefaultCategoryId();
|
||||
const categorySettings = useCategorySettings();
|
||||
@@ -936,30 +739,6 @@ export const Categories = () => {
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
type CategoryType = ReturnType<typeof Categories>[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;
|
||||
}
|
||||
@@ -1031,363 +810,3 @@ function CategoryDropdown({ isMultiSelectMode }: CategoryDropdownProps) {
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function CategorySelect({ isMultiSelectMode }: { isMultiSelectMode: boolean }) {
|
||||
const [mail, setMail] = useMail();
|
||||
const { setLabels } = useSearchLabels();
|
||||
const categories = Categories();
|
||||
const params = useParams<{ folder: string }>();
|
||||
const folder = params?.folder ?? 'inbox';
|
||||
const defaultCategoryIdInner = useDefaultCategoryId();
|
||||
const [category, setCategory] = useQueryState('category', {
|
||||
defaultValue: defaultCategoryIdInner,
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeTabElementRef = useRef<HTMLButtonElement>(null);
|
||||
const overlayContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [textSize, setTextSize] = useState<'normal' | 'small' | 'xs' | 'hidden'>('normal');
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
// const categories
|
||||
|
||||
if (folder !== 'inbox') return <div className="h-8"></div>;
|
||||
|
||||
// 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';
|
||||
}
|
||||
};
|
||||
|
||||
// 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 = (
|
||||
<button
|
||||
ref={!isOverlay ? activeTabElementRef : null}
|
||||
onClick={() => {
|
||||
setCategory(cat.id);
|
||||
// setSearchValue({
|
||||
// value: `${cat.searchValue} ${cleanSearchValue(searchValue.value).trim().length ? `AND ${cleanSearchValue(searchValue.value)}` : ''}`,
|
||||
// highlight: searchValue.highlight,
|
||||
// folder: '',
|
||||
// });
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 items-center justify-center gap-1 overflow-hidden rounded-lg border transition-all duration-300 ease-out dark:border-none',
|
||||
isSelected
|
||||
? cn('flex-1 border-none text-white', getPaddingClasses(), bgColor)
|
||||
: 'w-8 bg-white hover:bg-gray-100 dark:bg-[#313131] dark:hover:bg-[#313131]/80',
|
||||
)}
|
||||
tabIndex={isOverlay ? -1 : undefined}
|
||||
>
|
||||
<div className="relative overflow-visible">{cat.icon}</div>
|
||||
{isSelected && showText && (
|
||||
<div className="flex items-center justify-center gap-2.5 px-0.5">
|
||||
<div className={cn('justify-start truncate leading-none text-white', getTextClasses())}>
|
||||
{cat.name}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!isDesktop) {
|
||||
return React.cloneElement(button, { key: cat.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={cat.id}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align={idx === 0 ? 'start' : idx === categories.length - 1 ? 'end' : 'center'}
|
||||
>
|
||||
<span className="mr-2">{cat.name}</span>
|
||||
<kbd
|
||||
className={cn(
|
||||
'border-muted-foreground/10 bg-accent h-6 rounded-[6px] border px-1.5 font-mono text-xs leading-6',
|
||||
'-me-1 ms-auto inline-flex max-h-full items-center',
|
||||
)}
|
||||
>
|
||||
{idx + 1}
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 <BulkSelectActions />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<div className="flex w-full items-start justify-start gap-2">
|
||||
{categories.map((cat, idx) => renderCategoryButton(cat, false, idx))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-10 overflow-hidden transition-[clip-path] duration-300 ease-in-out"
|
||||
ref={overlayContainerRef}
|
||||
>
|
||||
<div className="flex w-full items-start justify-start gap-2">
|
||||
{categories.map((cat, idx) => renderCategoryButton(cat, true, idx))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const activeTabElementRef = useRef<HTMLButtonElement>(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 (
|
||||
<div className="relative mx-auto w-fit">
|
||||
<ul className="flex justify-center gap-1.5">
|
||||
{categories.map((category) => (
|
||||
<li key={category.name}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
ref={activeCategory === category.id ? activeTabElementRef : null}
|
||||
data-tab={category.id}
|
||||
onClick={() => {
|
||||
setActiveCategory(category.id);
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-7 items-center gap-1.5 rounded-full px-2 text-xs font-medium transition-all duration-200',
|
||||
activeCategory === category.id
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<div className="relative overflow-visible">{category.icon}</div>
|
||||
<span className={cn('hidden', !iconsOnly && 'md:inline')}>{category.name}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{iconsOnly && (
|
||||
<TooltipContent>
|
||||
<span>{category.name}</span>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 z-10 overflow-hidden transition-[clip-path] duration-300 ease-in-out"
|
||||
ref={containerRef}
|
||||
>
|
||||
<ul className="flex justify-center gap-1.5">
|
||||
{categories.map((category) => (
|
||||
<li key={category.id}>
|
||||
<button
|
||||
data-tab={category.id}
|
||||
onClick={() => {
|
||||
setActiveCategory(category.id);
|
||||
}}
|
||||
className={cn('flex items-center gap-1.5 rounded-full px-2 text-xs font-medium')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="relative overflow-visible">{category.icon}</div>
|
||||
<span className={cn('hidden', !iconsOnly && 'md:inline')}>{category.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ export function Nav({ links, isCollapsed }: NavProps) {
|
||||
className="group flex flex-col gap-4 py-2 data-[collapsed=true]:py-2"
|
||||
>
|
||||
<nav className="grid gap-1 px-2 group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
|
||||
{links.map((link, index) =>
|
||||
{links.map((link) =>
|
||||
isCollapsed ? (
|
||||
<Tooltip key={index} delayDuration={0}>
|
||||
<Tooltip key={link.title} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href="#"
|
||||
@@ -44,7 +44,7 @@ export function Nav({ links, isCollapsed }: NavProps) {
|
||||
</Tooltip>
|
||||
) : (
|
||||
<a
|
||||
key={index}
|
||||
key={link.title}
|
||||
href="#"
|
||||
className={cn(
|
||||
buttonVariants({ variant: link.variant, size: 'sm' }),
|
||||
|
||||
@@ -55,14 +55,6 @@ import {
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -250,8 +242,6 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
|
||||
const [isAddingNewNote, setIsAddingNewNote] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedColor, setSelectedColor] = useState('default');
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [noteToDelete, setNoteToDelete] = useState<string | null>(null);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import type { Label } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -10,10 +10,10 @@ import { useThread } from '@/hooks/use-threads';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { serializeFiles } from '@/lib/schemas';
|
||||
import { useDraft } from '@/hooks/use-drafts';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import type { Sender } from '@/types';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useEffect } from 'react';
|
||||
import posthog from 'posthog-js';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -24,13 +24,13 @@ interface ReplyComposeProps {
|
||||
export default function ReplyCompose({ messageId }: ReplyComposeProps) {
|
||||
const [mode, setMode] = useQueryState('mode');
|
||||
const { enableScope, disableScope } = useHotkeysContext();
|
||||
const { data: aliases, isLoading: isLoadingAliases } = useEmailAliases();
|
||||
const { data: aliases } = useEmailAliases();
|
||||
|
||||
const [draftId, setDraftId] = useQueryState('draftId');
|
||||
const [threadId] = useQueryState('threadId');
|
||||
const [, setActiveReplyId] = useQueryState('activeReplyId');
|
||||
const { data: emailData, refetch, latestDraft } = useThread(threadId);
|
||||
const { data: draft, isLoading: isDraftLoading } = useDraft(draftId ?? null);
|
||||
const { data: draft } = useDraft(draftId ?? null);
|
||||
const trpc = useTRPC();
|
||||
const { mutateAsync: sendEmail } = useMutation(trpc.mail.send.mutationOptions());
|
||||
const { data: activeConnection } = useActiveConnection();
|
||||
@@ -49,12 +49,6 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) {
|
||||
const senderEmail = replyToMessage.sender.email.toLowerCase();
|
||||
|
||||
// Set subject based on mode
|
||||
const subject =
|
||||
mode === 'forward'
|
||||
? `Fwd: ${replyToMessage.subject || ''}`
|
||||
: replyToMessage.subject?.startsWith('Re:')
|
||||
? replyToMessage.subject
|
||||
: `Re: ${replyToMessage.subject || ''}`;
|
||||
|
||||
if (mode === 'reply') {
|
||||
// Reply to sender
|
||||
@@ -173,14 +167,14 @@ export default function ReplyCompose({ messageId }: ReplyComposeProps) {
|
||||
new Date(replyToMessage.receivedOn || '').toLocaleString(),
|
||||
{ ...replyToMessage.sender, subject: replyToMessage.subject },
|
||||
toRecipients,
|
||||
replyToMessage.decodedBody,
|
||||
// replyToMessage.decodedBody,
|
||||
)
|
||||
: constructReplyBody(
|
||||
data.message + zeroSignature,
|
||||
new Date(replyToMessage.receivedOn || '').toLocaleString(),
|
||||
replyToMessage.sender,
|
||||
toRecipients,
|
||||
replyToMessage.decodedBody,
|
||||
// replyToMessage.decodedBody,
|
||||
);
|
||||
|
||||
await sendEmail({
|
||||
|
||||
@@ -83,6 +83,7 @@ export default function SelectAllCheckbox({ className }: { className?: string })
|
||||
const allIds = allIdsCache.current ?? [];
|
||||
setMail((prev) => ({ ...prev, bulkSelected: allIds }));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setIsFetchingIds(false);
|
||||
toast.error('Failed to select all conversations');
|
||||
}
|
||||
|
||||
@@ -24,12 +24,10 @@ import { EmptyStateIcon } from '../icons/empty-state-svg';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useOptimisticThreadState } from '@/components/mail/optimistic-thread-state';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { useOptimisticActions } from '@/hooks/use-optimistic-actions';
|
||||
import { focusedIndexAtom } from '@/hooks/use-mail-navigation';
|
||||
import { backgroundQueueAtom } from '@/store/backgroundQueue';
|
||||
|
||||
import { type ThreadDestination } from '@/lib/thread-actions';
|
||||
import { handleUnsubscribe } from '@/lib/email-utils.client';
|
||||
import { useThread, useThreads } from '@/hooks/use-threads';
|
||||
@@ -38,16 +36,18 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { ParsedMessage, Attachment } from '@/types';
|
||||
import { MailDisplaySkeleton } from './mail-skeleton';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cleanHtml } from '@/lib/email-utils';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import ReplyCompose from './reply-composer';
|
||||
import { NotesPanel } from './note-panel';
|
||||
import { cn, FOLDERS } from '@/lib/utils';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import MailDisplay from './mail-display';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { format } from 'date-fns';
|
||||
@@ -64,14 +64,6 @@ const cleanNameDisplay = (name?: string) => {
|
||||
return name.replace(/["<>]/g, '');
|
||||
};
|
||||
|
||||
// HTML escaping function to prevent XSS attacks
|
||||
const escapeHtml = (text: string): string => {
|
||||
if (!text) return text;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
interface ThreadDisplayProps {
|
||||
threadParam?: any;
|
||||
onClose?: () => void;
|
||||
@@ -165,18 +157,16 @@ function ThreadActionButton({
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const isFullscreen = false;
|
||||
export function ThreadDisplay() {
|
||||
const isMobile = useIsMobile();
|
||||
const { toggleOpen: toggleAISidebar, open: isSidebarOpen } = useAISidebar();
|
||||
const { toggleOpen: toggleAISidebar } = useAISidebar();
|
||||
const params = useParams<{ folder: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const folder = params?.folder ?? 'inbox';
|
||||
const [id, setThreadId] = useQueryState('threadId');
|
||||
const { data: emailData, isLoading, refetch: refetchThread, latestDraft } = useThread(id ?? null);
|
||||
const [{ refetch: mutateThreads }, items] = useThreads();
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const { data: emailData, isLoading, refetch: refetchThread } = useThread(id ?? null);
|
||||
const [, items] = useThreads();
|
||||
const [isStarred, setIsStarred] = useState(false);
|
||||
const [isImportant, setIsImportant] = useState(false);
|
||||
|
||||
@@ -185,18 +175,16 @@ export function ThreadDisplay() {
|
||||
if (!emailData?.messages) return [];
|
||||
return emailData.messages.reduce<Attachment[]>((acc, message) => {
|
||||
if (message.attachments && message.attachments.length > 0) {
|
||||
return [...acc, ...message.attachments];
|
||||
acc.push(...message.attachments);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, [emailData?.messages]);
|
||||
|
||||
const { refetch: refetchStats } = useStats();
|
||||
const [mode, setMode] = useQueryState('mode');
|
||||
const [, setBackgroundQueue] = useAtom(backgroundQueueAtom);
|
||||
const [activeReplyId, setActiveReplyId] = useQueryState('activeReplyId');
|
||||
const [, setDraftId] = useQueryState('draftId');
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const [focusedIndex, setFocusedIndex] = useAtom(focusedIndexAtom);
|
||||
const trpc = useTRPC();
|
||||
const { mutateAsync: toggleImportant } = useMutation(trpc.mail.toggleImportant.mutationOptions());
|
||||
@@ -514,23 +502,24 @@ export function ThreadDisplay() {
|
||||
</head>
|
||||
<body>
|
||||
${emailData?.messages
|
||||
?.map(
|
||||
(message, index) => `
|
||||
?.map(
|
||||
(message, index) => `
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
${index === 0 ? `<h1 class="email-title">${message.subject || 'No Subject'}</h1>` : ''}
|
||||
|
||||
|
||||
${message?.tags && message.tags.length > 0
|
||||
? `
|
||||
${
|
||||
message?.tags && message.tags.length > 0
|
||||
? `
|
||||
<div class="labels-section">
|
||||
${message.tags
|
||||
.map((tag) => `<span class="label-badge">${tag.name}</span>`)
|
||||
.join('')}
|
||||
.map((tag) => `<span class="label-badge">${tag.name}</span>`)
|
||||
.join('')}
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
|
||||
<div class="email-meta">
|
||||
@@ -543,58 +532,61 @@ export function ThreadDisplay() {
|
||||
</div>
|
||||
|
||||
|
||||
${message.to && message.to.length > 0
|
||||
? `
|
||||
${
|
||||
message.to && message.to.length > 0
|
||||
? `
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">To:</span>
|
||||
<span class="meta-value">
|
||||
${message.to
|
||||
.map(
|
||||
(recipient) =>
|
||||
`${cleanNameDisplay(recipient.name)} <${recipient.email}>`,
|
||||
)
|
||||
.join(', ')}
|
||||
.map(
|
||||
(recipient) =>
|
||||
`${cleanNameDisplay(recipient.name)} <${recipient.email}>`,
|
||||
)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
|
||||
${message.cc && message.cc.length > 0
|
||||
? `
|
||||
${
|
||||
message.cc && message.cc.length > 0
|
||||
? `
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">CC:</span>
|
||||
<span class="meta-value">
|
||||
${message.cc
|
||||
.map(
|
||||
(recipient) =>
|
||||
`${cleanNameDisplay(recipient.name)} <${recipient.email}>`,
|
||||
)
|
||||
.join(', ')}
|
||||
.map(
|
||||
(recipient) =>
|
||||
`${cleanNameDisplay(recipient.name)} <${recipient.email}>`,
|
||||
)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
|
||||
${message.bcc && message.bcc.length > 0
|
||||
? `
|
||||
${
|
||||
message.bcc && message.bcc.length > 0
|
||||
? `
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">BCC:</span>
|
||||
<span class="meta-value">
|
||||
${message.bcc
|
||||
.map(
|
||||
(recipient) =>
|
||||
`${cleanNameDisplay(recipient.name)} <${recipient.email}>`,
|
||||
)
|
||||
.join(', ')}
|
||||
.map(
|
||||
(recipient) =>
|
||||
`${cleanNameDisplay(recipient.name)} <${recipient.email}>`,
|
||||
)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
: ''
|
||||
}
|
||||
|
||||
|
||||
<div class="meta-row">
|
||||
@@ -613,29 +605,30 @@ export function ThreadDisplay() {
|
||||
</div>
|
||||
|
||||
|
||||
${message.attachments && message.attachments.length > 0
|
||||
? `
|
||||
${
|
||||
message.attachments && message.attachments.length > 0
|
||||
? `
|
||||
<div class="attachments-section">
|
||||
<h2 class="attachments-title">Attachments (${message.attachments.length})</h2>
|
||||
${message.attachments
|
||||
.map(
|
||||
(attachment, index) => `
|
||||
.map(
|
||||
(attachment) => `
|
||||
<div class="attachment-item">
|
||||
<span class="attachment-name">${attachment.filename}</span>
|
||||
${formatFileSize(attachment.size) ? ` - <span class="attachment-size">${formatFileSize(attachment.size)}</span>` : ''}
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
${index < emailData.messages.length - 1 ? '<div class="separator"></div>' : ''}
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
)
|
||||
.join('')}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -674,7 +667,7 @@ export function ThreadDisplay() {
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error printing thread:', error);
|
||||
alert('Failed to print thread. Please try again.');
|
||||
toast.error('Failed to print thread. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -980,7 +973,7 @@ export function ThreadDisplay() {
|
||||
<span>{m['common.threadDisplay.moveToSpam']()}</span>
|
||||
</DropdownMenuItem>
|
||||
{emailData.latest?.listUnsubscribe ||
|
||||
emailData.latest?.listUnsubscribePost ? (
|
||||
emailData.latest?.listUnsubscribePost ? (
|
||||
<DropdownMenuItem onClick={handleUnsubscribeProcess}>
|
||||
<Folders className="fill-iconLight dark:fill-iconDark mr-2" />
|
||||
<span>{m['common.mailDisplay.unsubscribe']()}</span>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRef } from 'react';
|
||||
|
||||
|
||||
@@ -215,11 +215,11 @@ export function TextEffect({
|
||||
const baseDuration = 0.3 / speedSegment;
|
||||
|
||||
const customStagger = hasTransition(variants?.container?.visible ?? {})
|
||||
? (variants?.container?.visible as TargetAndTransition).transition?.staggerChildren
|
||||
? ((variants?.container?.visible ?? {}) as TargetAndTransition).transition?.staggerChildren
|
||||
: undefined;
|
||||
|
||||
const customDelay = hasTransition(variants?.container?.visible ?? {})
|
||||
? (variants?.container?.visible as TargetAndTransition).transition?.delayChildren
|
||||
? ((variants?.container?.visible ?? {}) as TargetAndTransition).transition?.delayChildren
|
||||
: undefined;
|
||||
|
||||
const computedVariants = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuContent,
|
||||
@@ -251,7 +250,12 @@ export function Navigation() {
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<a target="_blank" href="https://cal.com/team/0" className="font-medium">
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://cal.com/team/0"
|
||||
className="font-medium"
|
||||
>
|
||||
Contact Us
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useState, useEffect } from 'react';
|
||||
import confetti from 'canvas-confetti';
|
||||
|
||||
const steps = [
|
||||
@@ -84,7 +84,7 @@ export function OnboardingDialog({
|
||||
(step, index) =>
|
||||
step.video && (
|
||||
<div
|
||||
key={index}
|
||||
key={step.title}
|
||||
className={`absolute inset-0 transition-opacity duration-300 ${
|
||||
index === currentStep ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
@@ -128,7 +128,7 @@ export function OnboardingDialog({
|
||||
<div className="flex gap-1">
|
||||
{steps.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={_.title}
|
||||
className={`h-1 w-4 rounded-full md:w-10 ${
|
||||
index === currentStep ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
|
||||
@@ -4,9 +4,9 @@ import useSearchLabels from '@/hooks/use-labels-search';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { usePartySocket } from 'partysocket/react';
|
||||
import { funnel } from 'remeda';
|
||||
|
||||
const DEBOUNCE_DELAY = 10_000; // 10 seconds is appropriate for real-time notifications
|
||||
|
||||
// 10 seconds is appropriate for real-time notifications
|
||||
|
||||
export enum IncomingMessageType {
|
||||
UseChatRequest = 'cf_agent_use_chat_request',
|
||||
@@ -32,14 +32,8 @@ export const NotificationProvider = () => {
|
||||
const [searchValue] = useSearchValue();
|
||||
const { labels } = useSearchLabels();
|
||||
|
||||
const labelsDebouncer = funnel(
|
||||
() => queryClient.invalidateQueries({ queryKey: trpc.labels.list.queryKey() }),
|
||||
{ minQuietPeriodMs: DEBOUNCE_DELAY },
|
||||
);
|
||||
const threadsDebouncer = funnel(
|
||||
() => queryClient.invalidateQueries({ queryKey: trpc.mail.listThreads.queryKey() }),
|
||||
{ minQuietPeriodMs: DEBOUNCE_DELAY },
|
||||
);
|
||||
|
||||
|
||||
|
||||
usePartySocket({
|
||||
party: 'zero-agent',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Plus, PurpleThickCheck, ThickCheck } from '../icons/icons';
|
||||
import { Plus, PurpleThickCheck, ThickCheck } from '../icons/icons';
|
||||
import { useSession, signIn } from '@/lib/auth-client';
|
||||
import { useBilling } from '@/hooks/use-billing';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
@@ -89,6 +89,7 @@ export const SetupInboxDialog = () => {
|
||||
toast.error('Please enter a valid OTP');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
showOtpInput ? 'Failed to verify phone number' : 'Failed to send verification code',
|
||||
);
|
||||
|
||||
@@ -13,12 +13,12 @@ import { PromptsDialog } from './prompts-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useLabels } from '@/hooks/use-labels';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
import { useAgentChat } from 'agents/ai-react';
|
||||
import { X, Expand, Plus } from 'lucide-react';
|
||||
import { Gauge } from '@/components/ui/gauge';
|
||||
import { useParams } from 'react-router';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
|
||||
import { useAgent } from 'agents/react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -337,8 +337,6 @@ function AISidebar({ className }: AISidebarProps) {
|
||||
const {
|
||||
open,
|
||||
setOpen,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
isFullScreen,
|
||||
setIsFullScreen,
|
||||
toggleViewMode,
|
||||
|
||||
@@ -1,37 +1,15 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { SquarePenIcon, type SquarePenIconHandle } from '../icons/animated/square-pen';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp';
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader } from '@/components/ui/sidebar';
|
||||
import { navigationConfig, bottomNavItems } from '@/config/navigation';
|
||||
import { useSession, authClient } from '@/lib/auth-client';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
import { CreateEmail } from '../create/create-email';
|
||||
import { PencilCompose, X } from '../icons/icons';
|
||||
@@ -41,15 +19,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { useAIFullScreen } from './ai-sidebar';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { FOLDERS } from '@/lib/utils';
|
||||
import { NavUser } from './nav-user';
|
||||
import { NavMain } from './nav-main';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { Input } from './input';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { isPro, isLoading } = useBilling();
|
||||
@@ -66,7 +41,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { data: stats } = useStats();
|
||||
|
||||
const location = useLocation();
|
||||
const { data: session, isPending: isSessionPending } = useSession();
|
||||
const { data: session } = useSession();
|
||||
const { currentSection, navItems } = useMemo(() => {
|
||||
// Find which section we're in based on the pathname
|
||||
const section = Object.entries(navigationConfig).find(([, config]) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import { X } from '../icons/icons';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ export const Gauge = ({
|
||||
value: number;
|
||||
size: 'small' | 'medium' | 'large';
|
||||
showValue: boolean;
|
||||
color?: String;
|
||||
bgcolor?: String;
|
||||
color?: string;
|
||||
bgcolor?: string;
|
||||
max?: number;
|
||||
}) => {
|
||||
const circumference = 332; //2 * Math.PI * 53; // 2 * pi * radius
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from './sidebar';
|
||||
import { Collapsible, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useActiveConnection, useConnections } from '@/hooks/use-connections';
|
||||
import { useActiveConnection, } from '@/hooks/use-connections';
|
||||
import { LabelDialog } from '@/components/labels/label-dialog';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { Link, useLocation, } from 'react-router';
|
||||
import Intercom, { show } from '@intercom/messenger-js-sdk';
|
||||
import { MessageSquare, OldPhone } from '../icons/icons';
|
||||
import { useSidebar } from '../context/sidebar-context';
|
||||
@@ -55,9 +55,9 @@ export function NavMain({ items }: NavMainProps) {
|
||||
const pathname = location.pathname;
|
||||
const searchParams = new URLSearchParams();
|
||||
const [category] = useQueryState('category');
|
||||
const { data: connections } = useConnections();
|
||||
const { data: stats } = useStats();
|
||||
const { data: activeConnection } = useActiveConnection();
|
||||
|
||||
|
||||
|
||||
const trpc = useTRPC();
|
||||
const { data: intercomToken } = useQuery(trpc.user.getIntercomToken.queryOptions());
|
||||
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
import {
|
||||
HelpCircle,
|
||||
LogIn,
|
||||
LogOut,
|
||||
MoonIcon,
|
||||
Settings,
|
||||
Plus,
|
||||
BrainIcon,
|
||||
CopyCheckIcon,
|
||||
BadgeCheck,
|
||||
BanknoteIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -17,25 +5,31 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
MoonIcon,
|
||||
Settings,
|
||||
Plus,
|
||||
CopyCheckIcon,
|
||||
BadgeCheck,
|
||||
BanknoteIcon,
|
||||
} from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useActiveConnection, useConnections } from '@/hooks/use-connections';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useLocation, useRevalidator, useSearchParams } from 'react-router';
|
||||
import { CircleCheck, Danger, OldPhone, ThreeDots } from '../icons/icons';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { CallInboxDialog, SetupInboxDialog } from '../setup-phone';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLoading } from '../context/loading-context';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
import { AddConnectionDialog } from '../connection/add';
|
||||
import { CircleCheck, ThreeDots } from '../icons/icons';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useSidebar } from '@/components/ui/sidebar';
|
||||
import { useBrainState } from '@/hooks/use-summary';
|
||||
import { useThreads } from '@/hooks/use-threads';
|
||||
import { useBilling } from '@/hooks/use-billing';
|
||||
import { SunIcon } from '../icons/animated/sun';
|
||||
import { clear as idbClear } from 'idb-keyval';
|
||||
import { useLocation } from 'react-router';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useQueryState } from 'nuqs';
|
||||
@@ -44,8 +38,8 @@ import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function NavUser() {
|
||||
const { data: session, refetch: refetchSession, isPending: isSessionPending } = useSession();
|
||||
const { data, refetch: refetchConnections } = useConnections();
|
||||
const { data: session } = useSession();
|
||||
const { data } = useConnections();
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { state } = useSidebar();
|
||||
@@ -56,7 +50,6 @@ export function NavUser() {
|
||||
);
|
||||
const { openBillingPortal, customer: billingCustomer, isPro } = useBilling();
|
||||
const pathname = useLocation().pathname;
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: activeConnection, refetch: refetchActiveConnection } = useActiveConnection();
|
||||
const [, setPricingDialog] = useQueryState('pricingDialog');
|
||||
@@ -147,9 +140,9 @@ export function NavUser() {
|
||||
/>
|
||||
|
||||
<AvatarFallback className="rounded-[5px] text-[10px]">
|
||||
{(activeAccount?.name || activeAccount?.email)
|
||||
{(activeAccount?.name || activeAccount?.email || '')
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.map((n: string) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
@@ -267,7 +260,12 @@ export function NavUser() {
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<a href="https://discord.gg/mail0" target="_blank" className="w-full">
|
||||
<a
|
||||
href="https://discord.gg/mail0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpCircle size={16} className="opacity-60" />
|
||||
<p className="text-[13px] opacity-60">
|
||||
@@ -485,7 +483,12 @@ export function NavUser() {
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<a href="https://discord.gg/mail0" target="_blank" className="w-full">
|
||||
<a
|
||||
href="https://discord.gg/mail0"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpCircle size={16} className="opacity-60" />
|
||||
<p className="text-[13px] opacity-60">
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { CircleCheck, PurpleThickCheck } from '@/components/icons/icons';
|
||||
import { PurpleThickCheck } from '@/components/icons/icons';
|
||||
import { useBilling } from '@/hooks/use-billing';
|
||||
import { PricingSwitch } from './pricing-switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { useState, } from 'react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Badge } from './badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useActiveConnection, useConnections } from '@/hooks/use-connections';
|
||||
import { LabelSidebarContextMenu } from '../context/label-sidebar-context';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
|
||||
import type { Label, Label as LabelType } from '@/types';
|
||||
import { useSidebar } from '../context/sidebar-context';
|
||||
import useSearchLabels from '@/hooks/use-labels-search';
|
||||
import { Folder } from '../magicui/file-tree';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQueryState } from 'nuqs';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IConnection, Label as LabelType } from '@/types';
|
||||
import type { Label as LabelType } from '@/types';
|
||||
import { useActiveConnection } from '@/hooks/use-connections';
|
||||
import { RecursiveFolder } from './recursive-folder';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Mic, MicOff, Volume2, VolumeX, X, Loader2, WavesIcon } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Mic, MicOff, Loader2, WavesIcon } from 'lucide-react';
|
||||
import { useVoice } from '@/providers/voice-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function VoiceButton() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
ExclamationCircle,
|
||||
Folder,
|
||||
Inbox,
|
||||
MessageSquare,
|
||||
NotesList,
|
||||
PaperPlane,
|
||||
SettingsGear,
|
||||
Sparkles,
|
||||
Stars,
|
||||
Tabs,
|
||||
Users,
|
||||
|
||||
@@ -13,7 +13,7 @@ const useDelete = () => {
|
||||
const [mail, setMail] = useMail();
|
||||
const [{ refetch: refetchThreads }] = useThreads();
|
||||
const { refetch: refetchStats } = useStats();
|
||||
const { addToQueue, deleteFromQueue } = useBackgroundQueue();
|
||||
const { addToQueue, } = useBackgroundQueue();
|
||||
const trpc = useTRPC();
|
||||
const { mutateAsync: deleteThread } = useMutation(trpc.mail.delete.mutationOptions());
|
||||
|
||||
|
||||
@@ -11,46 +11,6 @@ import { Markdown } from 'tiptap-markdown';
|
||||
import { isObjectType } from 'remeda';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const PreventNavigateOnDragOver = (handleFiles: (files: File[]) => void | Promise<void>) => {
|
||||
return Extension.create({
|
||||
name: 'preventNavigateOnDrop',
|
||||
addProseMirrorPlugins: () => {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('preventNavigateOnDrop'),
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
dragover: (_view, event) => {
|
||||
if (event.dataTransfer?.types?.includes('Files')) {
|
||||
event.preventDefault();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
drop: (_view, event) => {
|
||||
const fileList = event.dataTransfer?.files;
|
||||
if (fileList && fileList.length) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const files = Array.from(fileList);
|
||||
void handleFiles(files);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const CustomModEnter = (onModEnter: KeyboardShortcutCommand) => {
|
||||
return Extension.create({
|
||||
name: 'handleModEnter',
|
||||
@@ -150,7 +110,6 @@ const useComposeEditor = ({
|
||||
isReadOnly,
|
||||
placeholder,
|
||||
onChange,
|
||||
onAttachmentsChange,
|
||||
onLengthChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCommandPalette } from '@/components/context/command-palette-context';
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useOptimisticActions } from './use-optimistic-actions';
|
||||
import { useMail } from '@/components/mail/use-mail';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useActiveConnection } from './use-connections';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
|
||||
import { m } from '@/paraglide/messages';
|
||||
import type { Note } from '@/types';
|
||||
|
||||
export const useThreadNotes = (threadId: string) => {
|
||||
const { data: session } = useSession();
|
||||
|
||||
const trpc = useTRPC();
|
||||
const { data: activeConnection } = useActiveConnection();
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { addOptimisticActionAtom, removeOptimisticActionAtom } from '@/store/optimistic-updates';
|
||||
import { optimisticActionsManager, type PendingAction } from '@/lib/optimistic-actions-manager';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { focusedIndexAtom } from '@/hooks/use-mail-navigation';
|
||||
|
||||
import { backgroundQueueAtom } from '@/store/backgroundQueue';
|
||||
import type { ThreadDestination } from '@/lib/thread-actions';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useMail } from '@/components/mail/use-mail';
|
||||
import { moveThreadsTo } from '@/lib/thread-actions';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import posthog from 'posthog-js';
|
||||
import { useAtom } from 'jotai';
|
||||
@@ -39,15 +39,13 @@ export function useOptimisticActions() {
|
||||
const [, removeOptimisticAction] = useAtom(removeOptimisticActionAtom);
|
||||
const [threadId, setThreadId] = useQueryState('threadId');
|
||||
const [, setActiveReplyId] = useQueryState('activeReplyId');
|
||||
const [, setFocusedIndex] = useAtom(focusedIndexAtom);
|
||||
const [mail, setMail] = useMail();
|
||||
const { mutateAsync: markAsRead } = useMutation(trpc.mail.markAsRead.mutationOptions());
|
||||
const { mutateAsync: markAsUnread } = useMutation(trpc.mail.markAsUnread.mutationOptions());
|
||||
const { mutateAsync: markAsImportant } = useMutation(trpc.mail.markAsImportant.mutationOptions());
|
||||
|
||||
const { mutateAsync: toggleStar } = useMutation(trpc.mail.toggleStar.mutationOptions());
|
||||
const { mutateAsync: toggleImportant } = useMutation(trpc.mail.toggleImportant.mutationOptions());
|
||||
const { mutateAsync: bulkArchive } = useMutation(trpc.mail.bulkArchive.mutationOptions());
|
||||
const { mutateAsync: bulkStar } = useMutation(trpc.mail.bulkStar.mutationOptions());
|
||||
|
||||
const { mutateAsync: bulkDeleteThread } = useMutation(trpc.mail.bulkDelete.mutationOptions());
|
||||
const { mutateAsync: modifyLabels } = useMutation(trpc.mail.modifyLabels.mutationOptions());
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { backgroundQueueAtom, isThreadInBackgroundQueueAtom } from '@/store/backgroundQueue';
|
||||
import type { IGetThreadResponse } from '../../server/src/lib/driver/types';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import useSearchLabels from './use-labels-search';
|
||||
@@ -16,7 +16,7 @@ export const useThreads = () => {
|
||||
const [backgroundQueue] = useAtom(backgroundQueueAtom);
|
||||
const isInQueue = useAtomValue(isThreadInBackgroundQueueAtom);
|
||||
const trpc = useTRPC();
|
||||
const { labels, setLabels } = useSearchLabels();
|
||||
const { labels } = useSearchLabels();
|
||||
|
||||
const threadsQuery = useInfiniteQuery(
|
||||
trpc.mail.listThreads.infiniteQueryOptions(
|
||||
@@ -60,7 +60,7 @@ export const useThreads = () => {
|
||||
return [threadsQuery, threads, isReachingEnd, loadMore] as const;
|
||||
};
|
||||
|
||||
export const useThread = (threadId: string | null, historyId?: string | null) => {
|
||||
export const useThread = (threadId: string | null) => {
|
||||
const { data: session } = useSession();
|
||||
const [_threadId] = useQueryState('threadId');
|
||||
const id = threadId ? threadId : _threadId;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GmailColor, OutlookColor } from '../components/icons/icons';
|
||||
import { GmailColor, } from '../components/icons/icons';
|
||||
|
||||
export const I18N_LOCALE_COOKIE_NAME = 'i18n:locale';
|
||||
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { trpcClient } from '@/providers/query-provider';
|
||||
import { perplexity } from '@ai-sdk/perplexity';
|
||||
import { generateText, tool } from 'ai';
|
||||
|
||||
const getCurrentThreadId = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -130,7 +127,7 @@ export const toolExecutors = {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
deleteEmail: async (params: any) => {
|
||||
deleteEmail: async () => {
|
||||
const threadId = getCurrentThreadId();
|
||||
if (!threadId) {
|
||||
return {
|
||||
@@ -338,11 +335,12 @@ export const toolExecutors = {
|
||||
senderName: senderName,
|
||||
messageCount: messageCount,
|
||||
hasUnread: thread.hasUnread,
|
||||
summary: 'this is a fake summary',
|
||||
summary: text,
|
||||
message: `Successfully summarized email thread: ${threadId}`,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch email for summarization',
|
||||
|
||||
@@ -75,7 +75,7 @@ export const highlightText = (text: string, highlight: string) => {
|
||||
return parts.map((part, i) => {
|
||||
return i % 2 === 1 ? (
|
||||
<span
|
||||
key={i}
|
||||
key={part}
|
||||
className="ring-0.5 bg-primary/10 inline-flex items-center justify-center rounded px-1"
|
||||
>
|
||||
{part}
|
||||
|
||||
@@ -8,14 +8,14 @@ import { Categories } from '@/components/mail/mail';
|
||||
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';
|
||||
import { m } from '@/paraglide/messages';
|
||||
|
||||
export function MailListHotkeys() {
|
||||
const scope = 'mail-list';
|
||||
const [mail, setMail] = useMail();
|
||||
const [{}, items] = useThreads();
|
||||
const [, items] = useThreads();
|
||||
const hoveredEmailId = useRef<string | null>(null);
|
||||
const categories = Categories();
|
||||
const [, setCategory] = useQueryState('category');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mailNavigationCommandAtom } from '@/hooks/use-mail-navigation';
|
||||
import { useThread, useThreads } from '@/hooks/use-threads';
|
||||
import { useThread, } from '@/hooks/use-threads';
|
||||
import { keyboardShortcuts } from '@/config/shortcuts';
|
||||
import useMoveTo from '@/hooks/driver/use-move-to';
|
||||
import useDelete from '@/hooks/driver/use-delete';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type Shortcut, keyboardShortcuts } from '@/config/shortcuts';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
export const useShortcutCache = (userId?: string) => {
|
||||
export const useShortcutCache = () => {
|
||||
// const { data: shortcuts, mutate } = useSWR<Shortcut[]>(
|
||||
// userId ? `/hotkeys/${userId}` : null,
|
||||
// () => axios.get('/api/v1/shortcuts').then((res) => res.data),
|
||||
@@ -97,8 +97,11 @@ const dvorakToQwerty: Record<string, string> = {
|
||||
};
|
||||
|
||||
const qwertyToDvorak: Record<string, string> = Object.entries(dvorakToQwerty).reduce(
|
||||
(acc, [dvorak, qwerty]) => ({ ...acc, [qwerty]: dvorak }),
|
||||
{},
|
||||
(acc, [dvorak, qwerty]) => {
|
||||
acc[qwerty] = dvorak;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
export const formatKeys = (keys: string[] | undefined): string => {
|
||||
|
||||
@@ -4,6 +4,7 @@ export const isValidTimezone = (timezone: string) => {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone').includes(timezone);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { format, isToday, isThisMonth, differenceInCalendarMonths } from 'date-fns';
|
||||
import { isToday, isThisMonth, differenceInCalendarMonths } from 'date-fns';
|
||||
import { getBrowserTimezone } from './timezones';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
import { MAX_URL_LENGTH } from './constants';
|
||||
@@ -349,7 +349,6 @@ export const constructReplyBody = (
|
||||
originalDate: string,
|
||||
originalSender: Sender | undefined,
|
||||
otherRecipients: Sender[],
|
||||
quotedMessage?: string,
|
||||
) => {
|
||||
const senderName = originalSender?.name || originalSender?.email || 'Unknown Sender';
|
||||
const recipientEmails = otherRecipients.map((r) => r.email).join(', ');
|
||||
@@ -373,7 +372,6 @@ export const constructForwardBody = (
|
||||
originalDate: string,
|
||||
originalSender: Sender | undefined,
|
||||
otherRecipients: Sender[],
|
||||
quotedMessage?: string,
|
||||
) => {
|
||||
const senderName = originalSender?.name || originalSender?.email || 'Unknown Sender';
|
||||
const recipientEmails = otherRecipients.map((r) => r.email).join(', ');
|
||||
@@ -492,7 +490,6 @@ export function parseNaturalLanguageSearch(query: string): string {
|
||||
export function parseNaturalLanguageDate(query: string): { from?: Date; to?: Date } | null {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth();
|
||||
|
||||
// Common date patterns
|
||||
const patterns = [
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// import { type NextRequest, NextResponse } from 'next/server';
|
||||
// import { navigationConfig } from '@/config/navigation';
|
||||
// import { geolocation } from '@vercel/functions';
|
||||
// import { EU_COUNTRIES } from './lib/countries';
|
||||
|
||||
// const disabledRoutes = Object.values(navigationConfig)
|
||||
// .flatMap((section) => section.sections)
|
||||
// .flatMap((group) => group.items)
|
||||
// .filter((item) => item.disabled && item.url !== '#')
|
||||
// .map((item) => item.url);
|
||||
|
||||
// export function middleware(request: NextRequest) {
|
||||
// const response = NextResponse.next();
|
||||
// const geo = geolocation(request);
|
||||
// const country = geo.countryRegion || '';
|
||||
|
||||
// response.headers.set('x-user-country', country);
|
||||
|
||||
// const isEuRegion = EU_COUNTRIES.includes(country);
|
||||
// response.headers.set('x-user-eu-region', String(isEuRegion));
|
||||
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// response.headers.set('x-user-eu-region', 'true');
|
||||
// }
|
||||
|
||||
// const pathname = request.nextUrl.pathname;
|
||||
// if (disabledRoutes.some((route) => pathname.startsWith(route))) {
|
||||
// return NextResponse.redirect(new URL('/mail/inbox', request.url));
|
||||
// }
|
||||
|
||||
// return response;
|
||||
// }
|
||||
|
||||
// export const config = {
|
||||
// matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||
// };
|
||||
@@ -83,6 +83,7 @@
|
||||
"next-themes": "0.4.4",
|
||||
"novel": "1.0.2",
|
||||
"nuqs": "2.4.0",
|
||||
"oxlint": "1.6.0",
|
||||
"partysocket": "^1.1.4",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.256.0",
|
||||
@@ -113,6 +114,7 @@
|
||||
"vaul": "1.1.2",
|
||||
"virtua": "0.41.2",
|
||||
"vite-plugin-babel": "1.3.1",
|
||||
"vite-plugin-oxlint": "1.4.0",
|
||||
"workers-og": "0.0.25",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
type PersistedClient,
|
||||
type Persister,
|
||||
} from '@tanstack/react-query-persist-client';
|
||||
import { QueryCache, QueryClient, hashKey, type InfiniteData } from '@tanstack/react-query';
|
||||
import { createTRPCClient, httpBatchLink, loggerLink } from '@trpc/client';
|
||||
import { QueryCache, QueryClient, hashKey } from '@tanstack/react-query';
|
||||
import { createTRPCContext } from '@trpc/tanstack-react-query';
|
||||
import { useMemo, type PropsWithChildren } from 'react';
|
||||
import type { AppRouter } from '@zero/server/trpc';
|
||||
@@ -12,7 +12,6 @@ import { CACHE_BURST_KEY } from '@/lib/constants';
|
||||
import { signOut } from '@/lib/auth-client';
|
||||
import { get, set, del } from 'idb-keyval';
|
||||
import superjson from 'superjson';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function createIDBPersister(idbValidKey: IDBValidKey = 'zero-query-cache') {
|
||||
return {
|
||||
@@ -105,8 +104,6 @@ export const trpcClient = createTRPCClient<AppRouter>({
|
||||
],
|
||||
});
|
||||
|
||||
type TrpcHook = ReturnType<typeof useTRPC>;
|
||||
|
||||
export function QueryProvider({
|
||||
children,
|
||||
connectionId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
import { toolExecutors } from '@/lib/elevenlabs-tools';
|
||||
import { useConversation } from '@elevenlabs/react';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
@@ -42,28 +42,26 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
||||
toast.error(typeof error === 'string' ? error : error.message);
|
||||
setIsInitializing(false);
|
||||
},
|
||||
clientTools: {
|
||||
...Object.entries(toolExecutors).reduce(
|
||||
(acc, [name, executor]) => ({
|
||||
...acc,
|
||||
[name]: async (params: any) => {
|
||||
console.log(`[Voice Tool] ${name} called with params:`, params);
|
||||
setLastToolCall(`Executing: ${name}`);
|
||||
clientTools: Object.entries(toolExecutors).reduce(
|
||||
(acc: Record<string, any>, [name, executor]) => {
|
||||
acc[name] = async (params: any) => {
|
||||
console.log(`[Voice Tool] ${name} called with params:`, params);
|
||||
setLastToolCall(`Executing: ${name}`);
|
||||
|
||||
const paramsWithContext = {
|
||||
...params,
|
||||
_context: currentContext,
|
||||
};
|
||||
const paramsWithContext = {
|
||||
...params,
|
||||
_context: currentContext,
|
||||
};
|
||||
|
||||
const result = await executor(paramsWithContext);
|
||||
console.log(`[Voice Tool] ${name} result:`, result);
|
||||
setLastToolCall(null);
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
},
|
||||
const result = await executor(paramsWithContext);
|
||||
console.log(`[Voice Tool] ${name} result:`, result);
|
||||
setLastToolCall(null);
|
||||
return result;
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
});
|
||||
|
||||
const { status, isSpeaking } = conversation;
|
||||
@@ -111,7 +109,7 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
|
||||
email_context_info: context?.hasOpenEmail
|
||||
? `The user currently has an email open (thread ID: ${context.currentThreadId}). When the user refers to "this email" or "the current email", you can use the getEmail or summarizeEmail tools WITHOUT providing a threadId parameter - the tools will automatically use the currently open email.`
|
||||
: 'No email is currently open. If the user asks about an email, you will need to ask them to open it first or provide a specific thread ID.',
|
||||
...(context || {}),
|
||||
...context,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
||||
import { cloudflare } from '@cloudflare/vite-plugin';
|
||||
import { reactRouter } from '@react-router/dev/vite';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import oxlintPlugin from 'vite-plugin-oxlint';
|
||||
import babel from 'vite-plugin-babel';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import { defineConfig } from 'vite';
|
||||
@@ -13,6 +14,7 @@ const ReactCompilerConfig = {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
oxlintPlugin(),
|
||||
reactRouter(),
|
||||
cloudflare(),
|
||||
babel({
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
import {
|
||||
connection,
|
||||
user as _user,
|
||||
account,
|
||||
userSettings,
|
||||
session,
|
||||
userHotkeys,
|
||||
} from '../db/schema';
|
||||
import { createAuthMiddleware, phoneNumber, jwt, bearer, mcp } from 'better-auth/plugins';
|
||||
import { type Account, betterAuth, type BetterAuthOptions } from 'better-auth';
|
||||
import { getBrowserTimezone, isValidTimezone } from './timezones';
|
||||
@@ -13,16 +5,17 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { getSocialProviders } from './auth-providers';
|
||||
import { redis, resend, twilio } from './services';
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import { user as _user } from '../db/schema';
|
||||
import { defaultUserSettings } from './schemas';
|
||||
import { getMigrations } from 'better-auth/db';
|
||||
|
||||
import { disableBrainFunction } from './brain';
|
||||
import { type EProviders } from '../types';
|
||||
import { APIError } from 'better-auth/api';
|
||||
import { getZeroDB } from './server-utils';
|
||||
import { type EProviders } from '../types';
|
||||
import type { HonoContext } from '../ctx';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { createDriver } from './driver';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { createDb } from '../db';
|
||||
|
||||
const connectionHandlerHook = async (account: Account) => {
|
||||
@@ -240,7 +233,7 @@ export const createAuth = () => {
|
||||
|
||||
const createAuthConfig = () => {
|
||||
const cache = redis();
|
||||
const { db, conn } = createDb(env.HYPERDRIVE.connectionString);
|
||||
const { db } = createDb(env.HYPERDRIVE.connectionString);
|
||||
return {
|
||||
database: drizzleAdapter(db, { provider: 'pg' }),
|
||||
secondaryStorage: {
|
||||
@@ -291,7 +284,7 @@ const createAuthConfig = () => {
|
||||
},
|
||||
},
|
||||
onAPIError: {
|
||||
onError: (error, ctx) => {
|
||||
onError: (error) => {
|
||||
console.error('API Error', error);
|
||||
},
|
||||
errorURL: `${env.VITE_PUBLIC_APP_URL}/login`,
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
sanitizeContext,
|
||||
StandardizedError,
|
||||
} from './utils';
|
||||
import type { IOutgoingMessage, Label, ParsedMessage, DeleteAllSpamResponse } from '../../types';
|
||||
import { mapGoogleLabelColor, mapToGoogleLabelColor } from './google-label-color-map';
|
||||
import { parseAddressList, parseFrom, wasSentWithTLS } from '../email-utils';
|
||||
import type { IOutgoingMessage, Label, ParsedMessage } from '../../types';
|
||||
import { sanitizeTipTapHtml } from '../sanitize-tip-tap-html';
|
||||
import type { MailManager, ManagerConfig } from './types';
|
||||
import { type gmail_v1, gmail } from '@googleapis/gmail';
|
||||
@@ -197,7 +197,7 @@ export class GoogleMailManager implements MailManager {
|
||||
});
|
||||
return {
|
||||
label: res.data.name ?? res.data.id ?? '',
|
||||
count: Number(res.data.threadsUnread) ?? undefined,
|
||||
count: Number(res.data.threadsUnread),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -949,7 +949,6 @@ export class GoogleMailManager implements MailManager {
|
||||
cc,
|
||||
bcc,
|
||||
fromEmail,
|
||||
isForward = false,
|
||||
originalMessage = null,
|
||||
}: IOutgoingMessage) {
|
||||
const msg = createMimeMessage();
|
||||
@@ -1173,6 +1172,7 @@ export class GoogleMailManager implements MailManager {
|
||||
body: attachmentData ?? '',
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to get attachment', e);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -932,20 +932,6 @@ export class OutlookMailManager implements MailManager {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
private async modifyThreadLabels(
|
||||
threadIds: string[],
|
||||
requestBody: unknown, // Gmail-specific type, replace with relevant Outlook logic
|
||||
) {
|
||||
// This method is Gmail-specific (modifying thread labels).
|
||||
// The equivalent in Outlook is modifying messages (read status, categories)
|
||||
// or moving messages between folders.
|
||||
// The logic from modifyMessageReadStatus and modifyMessageLabelsOrFolders is more relevant.
|
||||
console.warn(
|
||||
'modifyThreadLabels is a Gmail-specific concept. Use modifyMessageReadStatus or modifyMessageLabelsOrFolders.',
|
||||
);
|
||||
// Placeholder
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public deleteAllSpam() {
|
||||
console.warn('deleteAllSpam is not implemented for Microsoft');
|
||||
@@ -1008,12 +994,9 @@ export class OutlookMailManager implements MailManager {
|
||||
toRecipients,
|
||||
ccRecipients,
|
||||
bccRecipients,
|
||||
sentDateTime,
|
||||
receivedDateTime,
|
||||
internetMessageId,
|
||||
inferenceClassification, // Might indicate if junk
|
||||
categories, // Outlook categories map to tags
|
||||
parentFolderId, // Can indicate folder (e.g. 'deleteditems')
|
||||
// headers, // Array of Header objects (name, value), doesn't exist in Outlook
|
||||
}: Message): Omit<
|
||||
ParsedMessage,
|
||||
@@ -1119,7 +1102,6 @@ export class OutlookMailManager implements MailManager {
|
||||
headers,
|
||||
cc,
|
||||
bcc,
|
||||
fromEmail, // In Outlook, this is usually determined by the authenticated user unless using "send on behalf of" or "send as"
|
||||
}: IOutgoingMessage): Promise<Message> {
|
||||
// Outlook Graph API expects a Message object structure for sending/creating drafts
|
||||
console.log(to);
|
||||
|
||||
@@ -19,7 +19,7 @@ export const IGetThreadResponseSchema = z.object({
|
||||
labels: z.array(z.object({ id: z.string(), name: z.string() })),
|
||||
});
|
||||
|
||||
export interface ParsedDraft<T = unknown> {
|
||||
export interface ParsedDraft {
|
||||
id: string;
|
||||
to?: string[];
|
||||
subject?: string;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { getActiveConnection, getZeroDB } from '../server-utils';
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import type { gmail_v1 } from '@googleapis/gmail';
|
||||
import type { HonoContext } from '../../ctx';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { createDriver } from '../driver';
|
||||
|
||||
|
||||
import { toByteArray } from 'base64-js';
|
||||
export const FatalErrors = ['invalid_grant'];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user