About
-
+
Terms & Conditions
-
+
Privacy Policy
diff --git a/apps/mail/components/mail/category-badge.tsx b/apps/mail/components/mail/category-badge.tsx
new file mode 100644
index 0000000000..3c4352e925
--- /dev/null
+++ b/apps/mail/components/mail/category-badge.tsx
@@ -0,0 +1,61 @@
+import { cn } from '@/lib/utils';
+import { LayoutGrid } from 'lucide-react';
+
+const CATEGORY_PALETTES = [
+ 'border-violet-200/80 bg-violet-50 text-violet-900 dark:border-violet-500/40 dark:bg-violet-950/60 dark:text-violet-100',
+ 'border-sky-200/80 bg-sky-50 text-sky-900 dark:border-sky-500/40 dark:bg-sky-950/60 dark:text-sky-100',
+ 'border-emerald-200/80 bg-emerald-50 text-emerald-900 dark:border-emerald-500/40 dark:bg-emerald-950/60 dark:text-emerald-100',
+ 'border-amber-200/80 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-950/60 dark:text-amber-100',
+ 'border-rose-200/80 bg-rose-50 text-rose-900 dark:border-rose-500/40 dark:bg-rose-950/60 dark:text-rose-100',
+ 'border-cyan-200/80 bg-cyan-50 text-cyan-900 dark:border-cyan-500/40 dark:bg-cyan-950/60 dark:text-cyan-100',
+ 'border-indigo-200/80 bg-indigo-50 text-indigo-900 dark:border-indigo-500/40 dark:bg-indigo-950/60 dark:text-indigo-100',
+ 'border-orange-200/80 bg-orange-50 text-orange-900 dark:border-orange-500/40 dark:bg-orange-950/60 dark:text-orange-100',
+] as const;
+
+const ICON_PALETTES = [
+ 'text-violet-600 dark:text-violet-300',
+ 'text-sky-600 dark:text-sky-300',
+ 'text-emerald-600 dark:text-emerald-300',
+ 'text-amber-600 dark:text-amber-300',
+ 'text-rose-600 dark:text-rose-300',
+ 'text-cyan-600 dark:text-cyan-300',
+ 'text-indigo-600 dark:text-indigo-300',
+ 'text-orange-600 dark:text-orange-300',
+] as const;
+
+function getCategoryPaletteIndex(name: string): number {
+ let hash = 0;
+ for (const char of name) {
+ hash = char.charCodeAt(0) + ((hash << 5) - hash);
+ }
+ return Math.abs(hash) % CATEGORY_PALETTES.length;
+}
+
+export function formatCategoryDisplayName(name: string): string {
+ return name
+ .replace(/[_-]/g, ' ')
+ .replace(/\b\w/g, (char) => char.toUpperCase());
+}
+
+interface CategoryBadgeProps {
+ category: string;
+ className?: string;
+}
+
+export function CategoryBadge({ category, className }: CategoryBadgeProps) {
+ const paletteIndex = getCategoryPaletteIndex(category.toLowerCase());
+
+ return (
+
+
+ {formatCategoryDisplayName(category)}
+
+ );
+}
diff --git a/apps/mail/components/mail/mail-display.tsx b/apps/mail/components/mail/mail-display.tsx
index 3410c57707..4c24534e3b 100644
--- a/apps/mail/components/mail/mail-display.tsx
+++ b/apps/mail/components/mail/mail-display.tsx
@@ -1,28 +1,21 @@
import {
- Bell,
Docx,
Figma,
Forward,
ImageFile,
- Lightning,
PDF,
Reply,
ReplyAll,
ThreeDots,
- Tag,
- User,
ChevronDown,
Printer,
} from '../icons/icons';
import {
- Briefcase,
- Star,
- StickyNote,
- Users,
Lock,
HardDriveDownload,
Loader2,
CopyIcon,
+ CircleAlert,
} from 'lucide-react';
import {
DropdownMenu,
@@ -40,21 +33,20 @@ import type { Sender, ParsedMessage, Attachment } from '@/types';
import { useActiveConnection } from '@/hooks/use-connections';
import { useAttachments } from '@/hooks/use-attachments';
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 { PriorityScoreCircle } from './priority-score-circle';
+import { RemovableTextLabels } from './removable-text-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 { useQueryState } from 'nuqs';
-import { Badge } from '../ui/badge';
import { format } from 'date-fns';
import { toast } from 'sonner';
@@ -159,87 +151,6 @@ type Props = {
threadAttachments?: Attachment[];
};
-const MailDisplayLabels = ({ labels }: { labels: string[] }) => {
- const visibleLabels = labels.filter(
- (label) => !['unread', 'inbox'].includes(label.toLowerCase()),
- );
-
- if (!visibleLabels.length) return null;
-
- return (
-
- {visibleLabels.map((label, index) => {
- const normalizedLabel = label.toLowerCase().replace(/^category_/i, '');
-
- let icon = null;
- let bgColor = '';
- let labelText = '';
-
- switch (normalizedLabel) {
- case 'important':
- icon =
;
- bgColor = 'bg-[#F59E0D]';
- labelText = m['common.mailCategories.important']();
- break;
- case 'promotions':
- icon =
;
- bgColor = 'bg-[#F43F5E]';
- labelText = m['common.mailCategories.promotions']();
- break;
- case 'personal':
- icon =
;
- bgColor = 'bg-[#39AE4A]';
- labelText = m['common.mailCategories.personal']();
- break;
- case 'updates':
- icon =
;
- bgColor = 'bg-[#8B5CF6]';
- labelText = m['common.mailCategories.updates']();
- break;
- case 'work':
- icon =
;
- bgColor = '';
- labelText = m['common.mailCategories.work']();
- break;
- case 'forums':
- icon =
;
- bgColor = 'bg-blue-600';
- labelText = m['common.mailCategories.forums']();
- break;
- case 'notes':
- icon =
;
- bgColor = 'bg-amber-500';
- labelText = m['common.mailCategories.notes']();
- break;
- case 'starred':
- icon =
;
- bgColor = 'bg-yellow-500';
- labelText = m['common.mailCategories.starred']();
- break;
- default:
- return null;
- }
-
- return (
-
-
-
- {icon}
-
-
-
- {labelText}
-
-
- );
- })}
-
- );
-};
-
// Helper function to clean email display
const cleanEmailDisplay = (email?: string) => {
if (!email) return '';
@@ -673,9 +584,6 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
const collapseTimeoutRef = useRef
(null);
const [activeReplyId, setActiveReplyId] = useQueryState('activeReplyId');
- const { labels: threadLabels } = useThreadLabels(
- emailData.tags ? emailData.tags.map((l) => l.id) : [],
- );
const { data: activeConnection } = useActiveConnection();
const [researchSender, setResearchSender] = useState(null);
const [searchQuery, setSearchQuery] = useState(null);
@@ -1161,6 +1069,11 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
}
};
+ // TODO_doorman : Implement priorityscorefeedback
+ const handlePriorityScoreFeedback = (_rating: 'low' | 'high') => {
+ alert('feedback is not implemented yet');
+ };
+
const renderPerson = useCallback(
(person: Sender) => (
@@ -1261,51 +1174,51 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
-
- {emailData?.tags?.length ? (
-
t.name) || []} />
- ) : null}
- {emailData?.tags?.length ? (
-
- ) : null}
-
- {threadLabels.length ? (
-
- ) : null}
-
- {(() => {
- if (people.length <= 2) {
- return people.map(renderPerson);
- }
-
- // Only show first two people plus count if we have at least two people
- const firstPerson = people[0];
- const secondPerson = people[1];
-
- if (firstPerson && secondPerson) {
- return (
- <>
- {renderPerson(firstPerson)}
- {renderPerson(secondPerson)}
-
-
-
- +{people.length - 2}{' '}
- {people.length - 2 === 1 ? 'other' : 'others'}
-
-
-
- {people.slice(2).map((person) => (
- {renderPerson(person)}
- ))}
-
-
- >
- );
- }
-
- return null;
- })()}
+
+
+
+
+
+ {threadData?.latest?.suggestedAction ? (
+
+ Suggested Action : {threadData.latest.suggestedAction}
+
+ ) : null}
+
+ {(() => {
+ if (people.length <= 2) {
+ return people.map(renderPerson);
+ }
+
+ // Only show first two people plus count if we have at least two people
+ const firstPerson = people[0];
+ const secondPerson = people[1];
+
+ if (firstPerson && secondPerson) {
+ return (
+ <>
+ {renderPerson(firstPerson)}
+ {renderPerson(secondPerson)}
+
+
+
+ +{people.length - 2}{' '}
+ {people.length - 2 === 1 ? 'other' : 'others'}
+
+
+
+ {people.slice(2).map((person) => (
+ {renderPerson(person)}
+ ))}
+
+
+ >
+ );
+ }
+
+ return null;
+ })()}
+
@@ -1318,11 +1231,14 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
-
+
@@ -1500,7 +1416,7 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
-
+
{
e.preventDefault();
@@ -1511,6 +1427,26 @@ const MailDisplay = ({ emailData, index, totalEmails, demo, threadAttachments }:
{m['common.mailDisplay.print']()}
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ handlePriorityScoreFeedback('low');
+ }}
+ >
+
+ Priority score is low
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ handlePriorityScoreFeedback('high');
+ }}
+ >
+
+ Priority score is high
+
{(messageAttachments?.length ?? 0) > 0 && (
option.value === sortBy)?.label ?? 'Received Date';
+
+ return (
+
+
+
+
+
+ {MAIL_SORT_OPTIONS.map((option) => (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ void setSortBy(option.value);
+ }}
+ role="menuitemradio"
+ aria-checked={sortBy === option.value}
+ >
+ {option.label}
+ {sortBy === option.value && }
+
+ ))}
+
+
+ );
+}
+
const Thread = memo(
function Thread({
message,
@@ -185,10 +253,6 @@ const Thread = memo(
[idToUse, folder, optimisticMoveThreadsTo, handleNext],
);
- const { labels: threadLabels } = useThreadLabels(
- optimisticLabels ? optimisticLabels.map((l) => l.id) : [],
- );
-
const [mailState, setMail] = useMail();
const { isMailSelected, isMailBulkSelected } = useMemo(() => {
const isSelected =
@@ -265,7 +329,7 @@ const Thread = memo(
{displayStarred
? m['common.threadDisplay.unstar']()
@@ -344,7 +408,7 @@ const Thread = memo(
-
+
{isMailBulkSelected ? (
)}
+
{/* {displayUnread && !isMailSelected && !isFolderSent ? (
<>
@@ -460,7 +525,6 @@ const Thread = memo(
) : null} */}
-
{latestMessage.receivedOn ? (
{getThreadData.labels ? : null}
*/}
- {threadLabels && (
-
- {!isFolderSent ? : null}
- {/* {getThreadData.labels ? : null} */}
-
- )}
{emailContent && (
{highlightText(emailContent, searchValue.highlight)}
)}
+ {!isFolderSent &&
+ (latestMessage.category || latestMessage.tags?.length) ? (
+
+ {latestMessage.category ? (
+
+ ) : null}
+ {latestMessage.tags?.length ? (
+
+ ) : null}
+
+ ) : null}
+ {latestMessage.suggestedAction ? (
+
+ Suggested Action : {latestMessage.suggestedAction}
+
+ ) : null}
{/* {mainSearchTerm && (
@@ -534,8 +608,6 @@ const Thread = memo(
displayUnread,
isMailSelected,
isMailBulkSelected,
- threadLabels,
- optimisticLabels,
emailContent,
]);
@@ -729,6 +801,8 @@ export const MailList = memo(
const [{ refetch, isLoading, isFetching, isFetchingNextPage, hasNextPage }, items, , loadMore] =
useThreads();
const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const [sortBy] = useQueryState('sortBy', { defaultValue: 'receivedDate' });
const isFetchingMail = useIsFetching({ queryKey: trpc.mail.get.queryKey() }) > 0;
const itemsRef = useRef(items);
const parentRef = useRef(null);
@@ -906,7 +980,23 @@ export const MailList = memo(
});
};
- const filteredItems = useMemo(() => items.filter((item) => item.id), [items]);
+ const filteredItems = useMemo(() => {
+ const base = items.filter((item) => item.id);
+ if (sortBy !== 'priorityScore') return base;
+
+ // TODO_doorman:implement_mail_list_sorting
+ return [...base].sort((a, b) => {
+ const getPriorityScore = (threadId: string) => {
+ const data = queryClient.getQueryData(
+ trpc.mail.get.queryKey({ id: threadId }),
+ ) as IGetThreadResponse | undefined;
+ const score = data?.latest?.priorityScore;
+ return typeof score === 'number' && Number.isFinite(score) ? score : -1;
+ };
+
+ return getPriorityScore(b.id) - getPriorityScore(a.id);
+ });
+ }, [items, sortBy, queryClient, trpc]);
const Comp = useMemo(() => (folder === FOLDERS.DRAFT ? Draft : Thread), [folder]);
@@ -981,7 +1071,7 @@ export const MailList = memo(
ref={vListRef}
count={filteredItems.length}
overscan={5}
- itemSize={100}
+ itemSize={120}
className="scrollbar-none flex-1 overflow-x-hidden"
onScroll={() => {
if (!vListRef.current) return;
@@ -1021,86 +1111,13 @@ export const MailList = memo(
export const MailLabels = memo(
function MailListLabels({ labels }: { labels: { id: string; name: string }[] }) {
- if (!labels?.length) return null;
-
- const visibleLabels = labels.filter(
- (label) => !['unread', 'inbox'].includes(label.name.toLowerCase()),
- );
-
- if (!visibleLabels.length) return null;
-
- return (
-
- {visibleLabels.map((label) => {
- const style = getDefaultBadgeStyle(label.name);
- if (label.name.toLowerCase() === 'notes') {
- return (
-
-
-
- {getLabelIcon(label.name)}
-
-
-
- {m['common.notes.title']()}
-
-
- );
- }
-
- // Skip rendering if style is "secondary" (default case)
- if (style === 'secondary') return null;
- const content = getLabelIcon(label.name);
-
- return content ? (
-
- {content}
-
- ) : null;
- })}
-
- );
+ return ;
},
(prev, next) => {
return JSON.stringify(prev.labels) === JSON.stringify(next.labels);
},
);
-function getLabelIcon(label: string) {
- const normalizedLabel = label.toLowerCase().replace(/^category_/i, '');
-
- switch (normalizedLabel) {
- case 'starred':
- return ;
- default:
- return null;
- }
-}
-
-function getDefaultBadgeStyle(label: string): ComponentProps['variant'] {
- const normalizedLabel = label.toLowerCase().replace(/^category_/i, '');
-
- switch (normalizedLabel) {
- case 'starred':
- case 'important':
- return 'important';
- case 'promotions':
- return 'promotions';
- case 'personal':
- return 'personal';
- case 'updates':
- return 'updates';
- case 'work':
- return 'default';
- case 'forums':
- return 'forums';
- case 'notes':
- return 'secondary';
- default:
- return 'secondary';
- }
-}
-
// Helper function to clean name display
const cleanNameDisplay = (name?: string) => {
if (!name) return '';
diff --git a/apps/mail/components/mail/mail.tsx b/apps/mail/components/mail/mail.tsx
index b91cd264eb..36378ed24f 100644
--- a/apps/mail/components/mail/mail.tsx
+++ b/apps/mail/components/mail/mail.tsx
@@ -1,23 +1,17 @@
-import {
- DropdownMenu,
- DropdownMenuItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from '../ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Bell, Lightning, Mail, ScanEye, Tag, User, X, Search } from '../icons/icons';
import { useCategorySettings, useDefaultCategoryId } from '@/hooks/use-categories';
import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable';
+import type { ImperativePanelHandle } from 'react-resizable-panels';
import { useCommandPalette } from '../context/command-palette-context';
-import { useHotkeys, useHotkeysContext } from 'react-hotkeys-hook';
+import { useHotkeysContext } from 'react-hotkeys-hook';
import { ThreadDisplay } from '@/components/mail/thread-display';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { useActiveConnection } from '@/hooks/use-connections';
-import { Check, ChevronDown, RefreshCcw } from 'lucide-react';
+import { RefreshCcw } from 'lucide-react';
import { useMediaQuery } from '../../hooks/use-media-query';
-import useSearchLabels from '@/hooks/use-labels-search';
import * as CustomIcons from '@/components/icons/icons';
-import { MailList } from '@/components/mail/mail-list';
+import { MailList, MailSortDropdown } from '@/components/mail/mail-list';
import { useNavigate, useParams } from 'react-router';
import { useMail } from '@/components/mail/use-mail';
import { SidebarToggle } from '../ui/sidebar-toggle';
@@ -25,7 +19,6 @@ import { PricingDialog } from '../ui/pricing-dialog';
import { clearBulkSelectionAtom } from './use-mail';
import AISidebar from '@/components/ui/ai-sidebar';
import { useThreads } from '@/hooks/use-threads';
-import AIToggleButton from '../ai-toggle-button';
import { useIsMobile } from '@/hooks/use-mobile';
import { Button } from '@/components/ui/button';
import { useSession } from '@/lib/auth-client';
@@ -343,6 +336,17 @@ export function MailLayout() {
const isDesktop = useMediaQuery('(min-width: 768px)');
const [threadId] = useQueryState('threadId');
+ const threadPanelRef = useRef(null);
+
+ useEffect(() => {
+ if (!isDesktop || !threadPanelRef.current) return;
+
+ if (threadId) {
+ threadPanelRef.current.expand();
+ } else {
+ threadPanelRef.current.collapse();
+ }
+ }, [threadId, isDesktop]);
useEffect(() => {
if (threadId) {
@@ -415,24 +419,26 @@ export function MailLayout() {
return (
-
+
-
+
@@ -485,7 +491,7 @@ export function MailLayout() {
{activeConnection?.providerId === 'google' && folder === 'inbox' && (
- 0} />
+
)}
>
) : (
@@ -533,7 +539,7 @@ export function MailLayout() {
/>
-
@@ -543,17 +549,22 @@ export function MailLayout() {
{isDesktop && (
-
-
-
+ {threadId ? (
+
+
+
+ ) : null}
)}
@@ -569,7 +580,6 @@ export function MailLayout() {
)}
{activeConnection?.id ?
: null}
- {activeConnection?.id ?
: null}
@@ -688,125 +698,3 @@ export const Categories = () => {
return categories as CategoryItem[];
};
-interface CategoryDropdownProps {
- isMultiSelectMode?: boolean;
-}
-
-function CategoryDropdown({ isMultiSelectMode }: CategoryDropdownProps) {
- const categorySettings = useCategorySettings();
- const { setLabels, labels } = useSearchLabels();
- const params = useParams<{ folder: string }>();
- const folder = params?.folder ?? 'inbox';
- const [isOpen, setIsOpen] = useState(false);
-
- useHotkeys(
- ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
- (key) => {
- const category = categorySettings[Number(key.key) - 1];
- if (!category) return;
- const isCurrentlyActive = labels.includes(category.searchValue);
-
- if (isCurrentlyActive) {
- setLabels(labels.filter((label) => label !== category.searchValue));
- } else {
- setLabels([...labels, category.searchValue]);
- }
- },
- {
- scopes: ['mail-list'],
- preventDefault: true,
- enableOnFormTags: false,
- },
- );
-
- const handleLabelChange = (searchValue: string) => {
- const trimmed = searchValue.trim();
- if (!trimmed) {
- setLabels([]);
- return;
- }
-
- const parsedLabels = trimmed
- .split(',')
- .map((label) => label.trim())
- .filter((label) => label.length > 0);
-
- if (parsedLabels.length === 0) {
- setLabels([]);
- return;
- }
-
- const currentLabelsSet = new Set(labels);
- const parsedLabelsSet = new Set(parsedLabels);
-
- const allLabelsSelected = parsedLabels.every((label) => currentLabelsSet.has(label));
-
- if (allLabelsSelected) {
- const updatedLabels = labels.filter((label) => !parsedLabelsSet.has(label));
- setLabels(updatedLabels);
- } else {
- const newLabelsSet = new Set([...labels, ...parsedLabels]);
- setLabels(Array.from(newLabelsSet));
- }
- };
-
- if (folder !== 'inbox' || isMultiSelectMode) return null;
-
- return (
-
-
-
-
-
- {categorySettings.map((category) => (
- {
- e.preventDefault();
- e.stopPropagation();
- handleLabelChange(category.searchValue);
- }}
- role="menuitemcheckbox"
- aria-checked={labels.includes(category.id)}
- >
-
- {category.name.toLowerCase()}
-
- {/* Special case: empty searchValue means "All Mail" - shows everything */}
- {(category.searchValue === ''
- ? labels.length === 0
- : category.searchValue.split(',').some((val) => labels.includes(val))) && (
-
- )}
-
- ))}
-
-
- );
-}
diff --git a/apps/mail/components/mail/note-panel.tsx b/apps/mail/components/mail/note-panel.tsx
index e7319031d9..3efda1f9a5 100644
--- a/apps/mail/components/mail/note-panel.tsx
+++ b/apps/mail/components/mail/note-panel.tsx
@@ -105,9 +105,9 @@ function SortableNote({
ref={setNodeRef}
style={style}
className={cn(
- 'group relative mb-3 overflow-hidden rounded-md border border-[#E7E7E7] p-3 dark:border-[#252525]',
+ 'group relative mb-3 overflow-hidden rounded-md border border-border p-3 dark:border-border',
note.isPinned && 'ring-1 ring-amber-200 dark:ring-amber-800',
- note.color === 'default' ? 'bg-white dark:bg-[#202020]' : '',
+ note.color === 'default' ? 'bg-card' : '',
)}
>
{m['common.notes.actions.changeColor']()}
-
+
{NOTE_COLORS.map((color) => {
return (
@@ -499,7 +499,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
variant="ghost"
size="sm"
className={cn(
- 'inline-flex h-7 w-7 items-center justify-center gap-1 overflow-hidden rounded-lg bg-white dark:bg-[#313131]',
+ 'inline-flex h-7 w-7 items-center justify-center gap-1 overflow-hidden rounded-lg bg-card',
notes.length > 0 && 'text-amber-500',
isOpen && 'bg-white/80 dark:bg-[#313131]/80',
)}
@@ -519,17 +519,17 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
{m['common.notes.title']()}
-
+
{m['common.notes.noteCount']({ count: notes.length })}
{isOpen && (
-
+
{m['common.notes.title']()}{' '}
@@ -542,7 +542,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {
@@ -603,7 +603,7 @@ export function NotesPanel({ threadId }: NotesPanelProps) {