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:
Adam
2025-07-10 10:59:40 -07:00
committed by GitHub
parent 3532012ada
commit 277f476575
121 changed files with 758 additions and 2743 deletions

View File

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

View File

@@ -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
View 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"
}
}

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[]) => {

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from '@/lib/constants';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,6 @@ import {
Italic,
Strikethrough,
Underline,
Code,
Link as LinkIcon,
List,
ListOrdered,
Heading1,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]" />

View File

@@ -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[]) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useRef } from 'react';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]) =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,7 @@ import {
ExclamationCircle,
Folder,
Inbox,
MessageSquare,
NotesList,
PaperPlane,
SettingsGear,
Sparkles,
Stars,
Tabs,
Users,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

@@ -4,6 +4,7 @@ export const isValidTimezone = (timezone: string) => {
try {
return Intl.supportedValuesOf('timeZone').includes(timezone);
} catch (error) {
console.error(error);
return false;
}
};

View File

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

View File

@@ -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).*)',
// };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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