Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/mail/components/home/HomeContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import Link from 'next/link';
import axios from 'axios';
import React from 'react';
import { z } from 'zod';
import { useTheme } from 'next-themes';

const tabs = [
{ label: 'Chat With Your Inbox', value: 'smart-categorization' },
Expand Down Expand Up @@ -75,6 +76,7 @@ export default function HomeContent() {
const [showSuccess, setShowSuccess] = useState(false);
const [signupCount, setSignupCount] = useState<number>(0);
const [open, setOpen] = useState(false);
const { setTheme } = useTheme();

const form = useForm<z.infer<typeof betaSignupSchema>>({
resolver: zodResolver(betaSignupSchema),
Expand All @@ -83,6 +85,10 @@ export default function HomeContent() {
},
});

useEffect(() => {
setTheme('dark');
}, [setTheme]);

useEffect(() => {
const fetchSignupCount = async () => {
try {
Expand Down
55 changes: 54 additions & 1 deletion apps/mail/components/icons/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,6 @@ export const Star = ({ className }: { className?: string }) => (
</svg>
);


export const ThreeDots = ({ className }: { className?: string }) => (
<svg
className={className}
Expand Down Expand Up @@ -1013,3 +1012,57 @@ export const LinkedIn = ({ className }: { className?: string }) => (
/>
</svg>
);

export const People = ({ className }: { className?: string }) => (
<svg
className={className}
width="22"
height="20"
viewBox="0 0 22 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M3.5 4.375C3.5 2.09683 5.34683 0.25 7.625 0.25C9.90317 0.25 11.75 2.09683 11.75 4.375C11.75 6.65317 9.90317 8.5 7.625 8.5C5.34683 8.5 3.5 6.65317 3.5 4.375Z" />
<path d="M13.25 6.625C13.25 4.76104 14.761 3.25 16.625 3.25C18.489 3.25 20 4.76104 20 6.625C20 8.48896 18.489 10 16.625 10C14.761 10 13.25 8.48896 13.25 6.625Z" />
<path d="M0.5 17.125C0.5 13.19 3.68997 10 7.625 10C11.56 10 14.75 13.19 14.75 17.125V17.1276C14.75 17.1674 14.7496 17.2074 14.749 17.2469C14.7446 17.5054 14.6074 17.7435 14.3859 17.8768C12.4107 19.0661 10.0966 19.75 7.625 19.75C5.15343 19.75 2.8393 19.0661 0.864061 17.8768C0.642563 17.7435 0.505373 17.5054 0.501026 17.2469C0.500345 17.2064 0.5 17.1657 0.5 17.125Z" />
<path d="M16.2498 17.1281C16.2498 17.1762 16.2494 17.2244 16.2486 17.2722C16.2429 17.6108 16.1612 17.9378 16.0157 18.232C16.2172 18.2439 16.4203 18.25 16.6248 18.25C18.2206 18.25 19.732 17.8803 21.0764 17.2213C21.3234 17.1002 21.4843 16.8536 21.4957 16.5787C21.4984 16.5111 21.4998 16.4432 21.4998 16.375C21.4998 13.6826 19.3172 11.5 16.6248 11.5C15.8784 11.5 15.1711 11.6678 14.5387 11.9676C15.6135 13.4061 16.2498 15.1912 16.2498 17.125V17.1281Z" />
</svg>
);

export const Star2 = ({ className }: { className?: string }) => (
<svg
className={className}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.78814 1.21068C9.23648 0.132735 10.7635 0.132735 11.2119 1.21068L13.2938 6.2164L18.6979 6.64964C19.8617 6.74293 20.3336 8.19522 19.4469 8.95473L15.3296 12.4817L16.5875 17.7551C16.8584 18.8908 15.623 19.7883 14.6266 19.1798L10 16.3538L5.37334 19.1798C4.37703 19.7883 3.14163 18.8908 3.41252 17.7551L4.67043 12.4817L0.553089 8.95473C-0.333552 8.19523 0.138322 6.74293 1.30206 6.64964L6.70615 6.2164L8.78814 1.21068Z"
/>
</svg>
);

export const Archive2 = ({ className }: { className?: string }) => (
<svg
className={className}
width="22"
height="20"
viewBox="0 0 21.5 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.375 0C1.33947 0 0.5 0.839466 0.5 1.875V2.625C0.5 3.66053 1.33947 4.5 2.375 4.5H19.625C20.6605 4.5 21.5 3.66053 21.5 2.625V1.875C21.5 0.839467 20.6605 0 19.625 0H2.375Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.08679 6L2.62657 15.1762C2.71984 16.7619 4.03296 18 5.62139 18H16.3783C17.9667 18 19.2799 16.7619 19.3731 15.1762L19.9129 6H2.08679ZM8.24976 9.75C8.24976 9.33579 8.58554 9 8.99976 9H12.9998C13.414 9 13.7498 9.33579 13.7498 9.75C13.7498 10.1642 13.414 10.5 12.9998 10.5H8.99976C8.58554 10.5 8.24976 10.1642 8.24976 9.75Z"

/>
</svg>
);
169 changes: 155 additions & 14 deletions apps/mail/components/mail/mail-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ import {
parseNaturalLanguageSearch,
} from '@/lib/utils';
import type { ConditionalThreadProps, MailListProps, MailSelectMode, ParsedMessage } from '@/types';
import { Briefcase, CheckCircle2, ChevronDown, Star, StickyNote, Users } from 'lucide-react';
import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { type ComponentProps, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { preloadThread, useThread, useThreads } from '@/hooks/use-threads';
import { Bell, GroupPeople, Lightning, Tag, User } from '../icons/icons';
import { Bell, GroupPeople, Lightning, People, Tag, User, Star2, Archive, Trash, Archive2, ChevronDown } from '../icons/icons';
import { ThreadContextMenu } from '@/components/context/thread-context';
import { Avatar, AvatarImage, AvatarFallback } from '../ui/avatar';
import { useMailNavigation } from '@/hooks/use-mail-navigation';
Expand All @@ -36,6 +35,16 @@ import { useQueryState } from 'nuqs';
import { Categories } from './mail';
import items from './demo.json';
import Image from 'next/image';
import { useTheme } from 'next-themes';
import { useIsMobile } from '@/hooks/use-mobile';
import { useAtom } from 'jotai';
import { backgroundQueueAtom } from '@/store/backgroundQueue';
import { useStats } from '@/hooks/use-stats';
import { toast } from 'sonner';
import { SuccessEmailToast } from '../theme/toast';
import { moveThreadsTo, ThreadDestination } from '@/lib/thread-actions';
import { toggleStar } from '@/actions/mail';
import { CheckCircle2 } from 'lucide-react';

const HOVER_DELAY = 1000; // ms before prefetching

Expand Down Expand Up @@ -139,22 +148,84 @@ const Thread = memo(
sessionData,
isKeyboardFocused,
demoMessage,
}: ConditionalThreadProps) => {
index,
}: ConditionalThreadProps & { index?: number }) => {
const [mail] = useMail();
const [searchValue, setSearchValue] = useSearchValue();
const t = useTranslations();
const { folder } = useParams<{ folder: string }>();
const { mutate } = useThreads();
const { mutate: mutateThreads } = useThreads();
const [threadId] = useQueryState('threadId');
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isHovering = useRef<boolean>(false);
const hasPrefetched = useRef<boolean>(false);
const isMobile = useIsMobile();
const [, setBackgroundQueue] = useAtom(backgroundQueueAtom);
const { mutate: mutateStats } = useStats();
const {
data: getThreadData,
labels,
isLoading,
isGroupThread,
} = useThread(demo ? null : message.id);
const [isHovered, setIsHovered] = useState(false);
const [isStarred, setIsStarred] = useState(false);

// Set initial star state based on email data
useEffect(() => {
if (getThreadData?.latest?.tags) {
setIsStarred(getThreadData.latest.tags.some((tag) => tag.name === 'STARRED'));
}
}, [getThreadData?.latest?.tags]);

const handleToggleStar = useCallback(async () => {
if (!getThreadData || !message.id) return;

const newStarredState = !isStarred;
setIsStarred(newStarredState);
if (newStarredState) {
toast.custom((id) => <SuccessEmailToast message={t('common.actions.addedToFavorites')} />);
} else {
toast.custom((id) => (
<SuccessEmailToast message={t('common.actions.removedFromFavorites')} />
));
}
await toggleStar({ ids: [message.id] });
mutateThreads();
}, [getThreadData, message.id, isStarred, mutateThreads, t]);

const moveThreadTo = useCallback(
async (destination: ThreadDestination) => {
if (!message.id) return;
const promise = moveThreadsTo({
threadIds: [message.id],
currentFolder: folder,
destination,
});
setBackgroundQueue({ type: 'add', threadId: `thread:${message.id}` });

toast.custom((id) => (
<SuccessEmailToast
message={
destination === 'inbox'
? t('common.actions.movedToInbox')
: destination === 'spam'
? t('common.actions.movedToSpam')
: destination === 'bin'
? t('common.actions.movedToBin')
: t('common.actions.archived')
}
/>
));
toast.promise(promise, {
error: t('common.actions.failedToMove'),
finally: async () => {
await Promise.all([mutateStats(), mutateThreads()]);
},
});
},
[message.id, folder, t, setBackgroundQueue, mutateStats, mutateThreads],
);

const latestMessage = demo ? demoMessage : getThreadData?.latest;
const emailContent = demo ? demoMessage?.body : getThreadData?.latest?.body;
Expand Down Expand Up @@ -204,6 +275,7 @@ const Thread = memo(
const handleMouseEnter = () => {
if (demo || !latestMessage) return;
isHovering.current = true;
setIsHovered(true);

// Prefetch only in single select mode
if (selectMode === 'single' && sessionData?.userId && !hasPrefetched.current) {
Expand All @@ -216,7 +288,6 @@ const Thread = memo(
hoverTimeoutRef.current = setTimeout(() => {
if (isHovering.current) {
const messageId = latestMessage.threadId ?? message.id;
// Only prefetch if still hovering and hasn't been prefetched
console.log(
`🕒 Hover threshold reached for email ${messageId}, initiating prefetch...`,
);
Expand All @@ -229,6 +300,7 @@ const Thread = memo(

const handleMouseLeave = () => {
isHovering.current = false;
setIsHovered(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
Expand All @@ -251,6 +323,7 @@ const Thread = memo(

if (!demo && (isLoading || !latestMessage || !getThreadData)) return null;


const demoContent =
demo && latestMessage ? (
<div className="p-1 px-3" onClick={onClick ? onClick(latestMessage) : undefined}>
Expand Down Expand Up @@ -363,7 +436,7 @@ const Thread = memo(
) : null;

if (demo) return demoContent;

const content =
latestMessage && getThreadData ? (
<div className="select-none py-1" onClick={onClick ? onClick(latestMessage) : undefined}>
Expand All @@ -373,13 +446,73 @@ const Thread = memo(
onMouseLeave={handleMouseLeave}
key={latestMessage.threadId ?? latestMessage.id}
className={cn(
'hover:bg-offsetLight hover:bg-primary/5 group relative mx-[8px] flex cursor-pointer flex-col items-start overflow-clip rounded-[10px] border-transparent py-3 text-left text-sm transition-all hover:opacity-100',

'hover:bg-offsetLight hover:bg-primary/5 group relative mx-[8px] flex cursor-pointer flex-col items-start rounded-[10px] border-transparent py-3 text-left text-sm transition-all hover:opacity-100',
(isMailSelected || isMailBulkSelected || isKeyboardFocused) &&
'border-border bg-primary/5 opacity-100',
isKeyboardFocused && 'ring-primary/50',
'relative',
)}
>
<div
className={cn(
'absolute inset-y-0 left-0 w-1 -translate-x-2 transition-transform ease-out',
isMailBulkSelected && 'translate-x-0',
)}
/>

{/* Quick Action Row */}
{isHovered && !isMobile && (
<div className={cn(
"dark:bg-[#1A1A1A] bg-white rounded-xl border absolute right-2 z-[25] flex -translate-y-1/2 items-center p-1 gap-1 shadow-sm",
index === 0 ? "top-4" : "top-[-1]"
)}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 [&_svg]:size-3.5"
onClick={handleToggleStar}
>
<Star2 className={cn(
"h-4 w-4",
isStarred
? 'fill-yellow-400 stroke-yellow-400'
: 'fill-transparent stroke-[#9D9D9D] dark:stroke-[#9D9D9D]',
)} />
</Button>
</TooltipTrigger>
<TooltipContent className='mb-1 dark:bg-[#1A1A1A] bg-white'>{isStarred ? t('common.threadDisplay.unstar') : t('common.threadDisplay.star')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 [&_svg]:size-3.5"
onClick={() => moveThreadTo('archive')}
>
<Archive2 className="fill-[#9D9D9D]" />
</Button>
</TooltipTrigger>
<TooltipContent className='mb-1 dark:bg-[#1A1A1A] bg-white'>{t('common.threadDisplay.archive')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 [&_svg]:size-3.5 dark:hover:bg-[#411D23] hover:bg-[#FDE4E9]"
onClick={() => moveThreadTo('bin')}
>
<Trash className="fill-[#F43F5E]" />
</Button>
</TooltipTrigger>
<TooltipContent className='mb-1 dark:bg-[#1A1A1A] bg-white'>{t('common.actions.Bin')}</TooltipContent>
</Tooltip>
</div>
)}

<div className="flex w-full items-center justify-between gap-4 px-4">
<div>
<Avatar className="h-8 w-8 rounded-full border dark:border-none">
Expand Down Expand Up @@ -491,7 +624,7 @@ const Thread = memo(
isFolderSpam={isFolderSpam}
isFolderSent={isFolderSent}
isFolderBin={isFolderBin}
refreshCallback={() => mutate()}
refreshCallback={() => mutateThreads()}
>
{content}
</ThreadWrapper>
Expand Down Expand Up @@ -692,6 +825,8 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
});
};

const { resolvedTheme } = useTheme();

return (
<>
<div
Expand All @@ -709,18 +844,23 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
disableScope('mail-list');
}}
>
<ScrollArea className="hide-scrollbar h-full overflow-auto">
<ScrollArea hideScrollbar className="hide-scrollbar h-full overflow-auto">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-neutral-900 border-t-transparent dark:border-white dark:border-t-transparent" />
</div>
) : !items || items.length === 0 ? (
<div className="flex h-[calc(100vh-4rem)] w-full items-center justify-center">
<div className="flex flex-col items-center justify-center gap-2 text-center">
<Image src="/empty-state.svg" alt="Empty Inbox" width={200} height={200} />
<Image
src={resolvedTheme === 'dark' ? "/empty-state.svg" : "/empty-state-light.svg"}
alt="Empty Inbox"
width={200}
height={200}
/>
<div className="mt-5">
<p className="text-lg">It's empty here</p>
<p className="text-md text-white/50">
<p className="text-md text-[#6D6D6D] dark:text-white/50">
Search for another email or{' '}
<button className="underline" onClick={clearFilters}>
clear filters
Expand Down Expand Up @@ -750,6 +890,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
isInQuickActionMode={isQuickActionMode && focusedIndex === index}
selectedQuickActionIndex={quickActionIndex}
resetNavigation={resetNavigation}
index={index}
/>
);
})}
Expand Down Expand Up @@ -892,7 +1033,7 @@ function getLabelIcon(label: string) {
case 'work':
return <Briefcase className="h-3.5 w-3.5" />;
case 'forums':
return <Users className="h-3.5 w-3.5" />;
return <People className="h-3.5 w-3.5 fill-blue-500" />;
case 'notes':
return <StickyNote className="h-3.5 w-3.5" />;
case 'starred':
Expand Down
Loading