refactored to resolve comments

This commit is contained in:
Ahmet Kilinc
2025-03-11 17:29:53 +00:00
parent 6812e0d715
commit 554f884dcb
3 changed files with 156 additions and 225 deletions

View File

@@ -1,9 +1,8 @@
"use client";
import { ComponentProps, useCallback, useEffect, useRef, useState } from "react";
import { AlertTriangle, Bell, Briefcase, Tag, User, Users } from "lucide-react";
import { InitialThread, ThreadProps, MailListProps, MailSelectMode } from "@/types";
import { EmptyState, type FolderType } from "@/components/mail/empty-state";
import { preloadThread, useThreads } from "@/hooks/use-threads";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn, defaultPageSize, formatDate } from "@/lib/utils";
import { useSearchValue } from "@/hooks/use-search-value";
import { markAsRead, markAsUnread } from "@/actions/mail";
@@ -13,25 +12,9 @@ import { useMail } from "@/components/mail/use-mail";
import { useHotKey } from "@/hooks/use-hot-key";
import { useDrafts } from "@/hooks/use-drafts";
import { useSession } from "@/lib/auth-client";
import { Badge } from "@/components/ui/badge";
import { useRouter } from "next/navigation";
import { InitialThread } from "@/types";
import { toast } from "sonner";
interface DraftsListProps {
isCompact?: boolean;
}
type MailSelectMode = "mass" | "range" | "single" | "selectAllBelow";
type ThreadProps = {
message: InitialThread;
selectMode: MailSelectMode;
onClick?: (message: InitialThread) => () => Promise<any> | undefined;
isCompact?: boolean;
demo?: boolean;
};
const highlightText = (text: string, highlight: string) => {
if (!highlight?.trim()) return text;
@@ -52,7 +35,7 @@ const highlightText = (text: string, highlight: string) => {
});
};
const Thread = ({ message, selectMode, demo, onClick }: ThreadProps) => {
const Draft = ({ message, onClick }: ThreadProps) => {
const [mail] = useMail();
const [searchValue] = useSearchValue();
@@ -67,7 +50,6 @@ const Thread = ({ message, selectMode, demo, onClick }: ThreadProps) => {
"hover:bg-offsetLight hover:bg-primary/5 group relative flex cursor-pointer flex-col items-start overflow-clip rounded-lg border border-transparent px-4 py-3 text-left text-sm transition-all hover:opacity-100",
!message.unread && "opacity-50",
(isMailSelected || isMailBulkSelected) && "border-border bg-primary/5 opacity-100",
// isCompact && "py-2",
)}
>
<div
@@ -113,7 +95,7 @@ const Thread = ({ message, selectMode, demo, onClick }: ThreadProps) => {
);
};
export function DraftsList({ isCompact }: DraftsListProps) {
export function DraftsList({ isCompact }: MailListProps) {
const [mail, setMail] = useMail();
const { data: session } = useSession();
const [searchValue] = useSearchValue();
@@ -418,10 +400,10 @@ export function DraftsList({ isCompact }: DraftsListProps) {
}}
className="absolute left-0 top-0 w-full p-[8px]"
>
{virtualItems.map(({ index, key }) => {
{virtualItems.map(({ index }) => {
const item = items[index];
return item ? (
<Thread
<Draft
key={item.id}
onClick={handleMailClick}
message={item}
@@ -444,71 +426,3 @@ export function DraftsList({ isCompact }: DraftsListProps) {
</ScrollArea>
);
}
function MailLabels({ labels }: { labels: string[] }) {
if (!labels.length) return null;
const visibleLabels = labels.filter(
(label) => !["unread", "inbox"].includes(label.toLowerCase()),
);
if (!visibleLabels.length) return null;
return (
<div className={cn("flex select-none items-center gap-1")}>
{visibleLabels.map((label) => {
const style = getDefaultBadgeStyle(label);
// Skip rendering if style is "secondary" (default case)
if (style === "secondary") return null;
return (
<Badge key={label} className="rounded-full p-1" variant={style}>
{getLabelIcon(label)}
</Badge>
);
})}
</div>
);
}
function getLabelIcon(label: string) {
const normalizedLabel = label.toLowerCase().replace(/^category_/i, "");
switch (normalizedLabel) {
case "important":
return <AlertTriangle className="h-3 w-3" />;
case "promotions":
return <Tag className="h-3 w-3 rotate-90" />;
case "personal":
return <User className="h-3 w-3" />;
case "updates":
return <Bell className="h-3 w-3" />;
case "work":
return <Briefcase className="h-3 w-3" />;
case "forums":
return <Users className="h-3 w-3" />;
default:
return null;
}
}
function getDefaultBadgeStyle(label: string): ComponentProps<typeof Badge>["variant"] {
const normalizedLabel = label.toLowerCase().replace(/^category_/i, "");
switch (normalizedLabel) {
case "important":
return "important";
case "promotions":
return "promotions";
case "personal":
return "personal";
case "updates":
return "updates";
case "work":
return "default";
case "forums":
return "forums";
default:
return "secondary";
}
}

View File

@@ -1,43 +1,43 @@
"use client";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { InitialThread, ThreadProps, MailSelectMode, MailListProps } from "@/types";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertTriangle, Tag, User, Bell, Briefcase, Users } from "lucide-react";
import { EmptyState, type FolderType } from "@/components/mail/empty-state";
import { preloadThread, useThreads } from "@/hooks/use-threads";
import { ThreadContextMenu } from "../context/thread-context";
import { cn, defaultPageSize, formatDate } from "@/lib/utils";
import { useSearchValue } from "@/hooks/use-search-value";
import { markAsRead, markAsUnread } from "@/actions/mail";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useMail } from "@/components/mail/use-mail";
import { useSummary } from "@/hooks/use-summary";
import { useHotKey } from "@/hooks/use-hot-key";
import { useSession } from "@/lib/auth-client";
import { Badge } from "@/components/ui/badge";
import { cn, defaultPageSize, formatDate } from "@/lib/utils";
import { InitialThread } from "@/types";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import items from "./demo.json";
import Image from "next/image";
import { toast } from "sonner";
import { ThreadContextMenu } from "../context/thread-context";
import { useParams } from "next/navigation";
import { useSummary } from "@/hooks/use-summary";
import { AlertTriangle, Tag, User, Bell, Briefcase, Users } from "lucide-react";
import items from './demo.json'
interface MailListProps {
isCompact?: boolean;
}
// interface MailListProps {
// isCompact?: boolean;
// }
const HOVER_DELAY = 1000; // ms before prefetching
type MailSelectMode = "mass" | "range" | "single" | "selectAllBelow";
// type MailSelectMode = "mass" | "range" | "single" | "selectAllBelow";
type ThreadProps = {
message: InitialThread;
selectMode: MailSelectMode;
onClick?: (message: InitialThread) => () => Promise<any> | undefined;
isCompact?: boolean;
demo?: boolean;
};
// type ThreadProps = {
// message: InitialThread;
// selectMode: MailSelectMode;
// onClick?: (message: InitialThread) => () => Promise<any> | undefined;
// isCompact?: boolean;
// demo?: boolean;
// };
const highlightText = (text: string, highlight: string) => {
if (!highlight?.trim()) return text;
@@ -145,15 +145,14 @@ const Thread = memo(({ message, selectMode, demo, onClick }: ThreadProps) => {
<span className={cn(mail.selected && "max-w-[120px] truncate")}>
{highlightText(message.sender.name, searchValue.highlight)}
</span>{" "}
{message.unread ? (
<span className=" size-2 rounded-full bg-[#006FFE]" />
) : null}
{message.unread ? <span className="size-2 rounded-full bg-[#006FFE]" /> : null}
</p>
<MailLabels labels={message.tags} />
{message.totalReplies !== 1 ? (
<span className="text-xs opacity-70 border border-dotted rounded-full px-[5px] py-[1px]">{message.totalReplies}</span>
) : null}
<span className="rounded-full border border-dotted px-[5px] py-[1px] text-xs opacity-70">
{message.totalReplies}
</span>
) : null}
</div>
{message.receivedOn ? (
<p
@@ -168,7 +167,7 @@ const Thread = memo(({ message, selectMode, demo, onClick }: ThreadProps) => {
</div>
<p
className={cn(
"mt-1 text-xs opacity-70 transition-opacity line-clamp-1",
"mt-1 line-clamp-1 text-xs opacity-70 transition-opacity",
mail.selected ? "line-clamp-1" : "line-clamp-2",
isMailSelected && "opacity-100",
)}
@@ -179,42 +178,33 @@ const Thread = memo(({ message, selectMode, demo, onClick }: ThreadProps) => {
);
});
export function MailListDemo({ items: filteredItems = items }) {
return <ScrollArea
className="h-full pb-2"
type="scroll"
>
<div
className={cn(
"relative min-h-[calc(100vh-4rem)] w-full",
)}
>
<div
className="absolute left-0 top-0 w-full p-[8px]"
>
{filteredItems.map((item) => {
return item ? (
<Thread
demo
key={item.id}
message={item}
selectMode={'single'}
/>
) : null;
})}
return (
<ScrollArea className="h-full pb-2" type="scroll">
<div className={cn("relative min-h-[calc(100vh-4rem)] w-full")}>
<div className="absolute left-0 top-0 w-full p-[8px]">
{filteredItems.map((item) => {
return item ? <Thread demo key={item.id} message={item} selectMode={"single"} /> : null;
})}
</div>
</div>
</div>
</ScrollArea>
</ScrollArea>
);
}
export function MailList({ isCompact }: MailListProps) {
const { folder } = useParams<{ folder: string }>()
const { folder } = useParams<{ folder: string }>();
const [mail, setMail] = useMail();
const { data: session } = useSession();
const [searchValue] = useSearchValue();
const { data: { threads: items, nextPageToken }, mutate, isValidating, isLoading, loadMore } = useThreads(folder, undefined, searchValue.value, defaultPageSize);
const {
data: { threads: items, nextPageToken },
mutate,
isValidating,
isLoading,
loadMore,
} = useThreads(folder, undefined, searchValue.value, defaultPageSize);
const parentRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
@@ -238,7 +228,7 @@ export function MailList({ isCompact }: MailListProps) {
if (scrolledToBottom) {
console.log("Loading more items...");
await loadMore()
await loadMore();
}
},
[isLoading, isValidating, nextPageToken, itemHeight],
@@ -425,63 +415,68 @@ export function MailList({ isCompact }: MailListProps) {
? "selectAllBelow"
: "single";
const handleMailClick = useCallback((message: InitialThread) => () => {
if (selectMode === "mass") {
const updatedBulkSelected = mail.bulkSelected.includes(message.id)
? mail.bulkSelected.filter((id) => id !== message.id)
: [...mail.bulkSelected, message.id];
const handleMailClick = useCallback(
(message: InitialThread) => () => {
if (selectMode === "mass") {
const updatedBulkSelected = mail.bulkSelected.includes(message.id)
? mail.bulkSelected.filter((id) => id !== message.id)
: [...mail.bulkSelected, message.id];
setMail({ ...mail, bulkSelected: updatedBulkSelected });
return;
}
if (selectMode === "range") {
const lastSelectedItem =
mail.bulkSelected[mail.bulkSelected.length - 1] ?? mail.selected ?? message.id;
const mailsIndex = items.map((m) => m.id);
const startIdx = mailsIndex.indexOf(lastSelectedItem);
const endIdx = mailsIndex.indexOf(message.id);
if (startIdx !== -1 && endIdx !== -1) {
const selectedRange = mailsIndex.slice(
Math.min(startIdx, endIdx),
Math.max(startIdx, endIdx) + 1,
);
setMail({ ...mail, bulkSelected: selectedRange });
setMail({ ...mail, bulkSelected: updatedBulkSelected });
return;
}
return;
}
if (selectMode === "selectAllBelow") {
const mailsIndex = items.map((m) => m.id);
const startIdx = mailsIndex.indexOf(message.id);
if (selectMode === "range") {
const lastSelectedItem =
mail.bulkSelected[mail.bulkSelected.length - 1] ?? mail.selected ?? message.id;
if (startIdx !== -1) {
const selectedRange = mailsIndex.slice(startIdx);
const mailsIndex = items.map((m) => m.id);
const startIdx = mailsIndex.indexOf(lastSelectedItem);
const endIdx = mailsIndex.indexOf(message.id);
setMail({ ...mail, bulkSelected: selectedRange });
if (startIdx !== -1 && endIdx !== -1) {
const selectedRange = mailsIndex.slice(
Math.min(startIdx, endIdx),
Math.max(startIdx, endIdx) + 1,
);
setMail({ ...mail, bulkSelected: selectedRange });
}
return;
}
return;
}
if (mail.selected === message.threadId || mail.selected === message.id) {
setMail({
selected: null,
bulkSelected: [],
});
} else {
setMail({
...mail,
selected: message.threadId ?? message.id,
bulkSelected: [],
});
}
if (message.unread) {
return markAsRead({ ids: [message.id] }).then(() => mutate() as any).catch(console.error);
}
}, [mail, setMail, selectMode]);
if (selectMode === "selectAllBelow") {
const mailsIndex = items.map((m) => m.id);
const startIdx = mailsIndex.indexOf(message.id);
if (startIdx !== -1) {
const selectedRange = mailsIndex.slice(startIdx);
setMail({ ...mail, bulkSelected: selectedRange });
}
return;
}
if (mail.selected === message.threadId || mail.selected === message.id) {
setMail({
selected: null,
bulkSelected: [],
});
} else {
setMail({
...mail,
selected: message.threadId ?? message.id,
bulkSelected: [],
});
}
if (message.unread) {
return markAsRead({ ids: [message.id] })
.then(() => mutate() as any)
.catch(console.error);
}
},
[mail, setMail, selectMode],
);
const isEmpty = items.length === 0;
const isFiltering = searchValue.value.trim().length > 0;
@@ -508,22 +503,27 @@ export function MailList({ isCompact }: MailListProps) {
)}
style={{
height: `${virtualizer.getTotalSize()}px`,
willChange: "transform", contain: 'paint'
willChange: "transform",
contain: "paint",
}}
>
<div
style={{ transform: `translateY(${virtualItems[0]?.start ?? 0}px)`, willChange: "transform", contain: 'paint' }}
style={{
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
willChange: "transform",
contain: "paint",
}}
className="absolute left-0 top-0 w-full p-[8px]"
>
{virtualItems.map(({ index, key }) => {
const item = items[index];
return item ? (
<Thread
<Thread
key={item.id}
onClick={handleMailClick}
message={item}
message={item}
selectMode={selectMode}
isCompact={isCompact}
isCompact={isCompact}
/>
) : null;
})}
@@ -542,33 +542,36 @@ export function MailList({ isCompact }: MailListProps) {
);
}
const MailLabels = memo(({ labels }: { labels: string[] }) => {
if (!labels.length) return null;
const MailLabels = memo(
({ labels }: { labels: string[] }) => {
if (!labels.length) return null;
const visibleLabels = labels.filter(
(label) => !["unread", "inbox"].includes(label.toLowerCase()),
);
const visibleLabels = labels.filter(
(label) => !["unread", "inbox"].includes(label.toLowerCase()),
);
if (!visibleLabels.length) return null;
if (!visibleLabels.length) return null;
return (
<div className={cn("flex select-none items-center gap-1")}>
{visibleLabels.map((label) => {
const style = getDefaultBadgeStyle(label);
// Skip rendering if style is "secondary" (default case)
if (style === "secondary") return null;
return (
<Badge key={label} className="rounded-full p-1" variant={style}>
{getLabelIcon(label)}
</Badge>
);
})}
</div>
);
}, (prev, next) => {
return prev.labels === next.labels;
});
return (
<div className={cn("flex select-none items-center gap-1")}>
{visibleLabels.map((label) => {
const style = getDefaultBadgeStyle(label);
// Skip rendering if style is "secondary" (default case)
if (style === "secondary") return null;
return (
<Badge key={label} className="rounded-full p-1" variant={style}>
{getLabelIcon(label)}
</Badge>
);
})}
</div>
);
},
(prev, next) => {
return prev.labels === next.labels;
},
);
function getLabelIcon(label: string) {
const normalizedLabel = label.toLowerCase().replace(/^category_/i, "");

View File

@@ -74,3 +74,17 @@ export interface InitialThread {
references?: string;
inReplyTo?: string;
}
export interface MailListProps {
isCompact?: boolean;
}
export type MailSelectMode = "mass" | "range" | "single" | "selectAllBelow";
export interface ThreadProps {
message: InitialThread;
selectMode: MailSelectMode;
onClick?: (message: InitialThread) => () => Promise<any> | undefined;
isCompact?: boolean;
demo?: boolean;
}