Skip to content
Open
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
1 change: 1 addition & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@import "tw-animate-css";

@custom-variant dark (&:is(.dark *));
@custom-variant can-hover (@media (hover: hover));

@theme inline {
--color-background: var(--background);
Expand Down
2 changes: 2 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { Suspense } from "react";
import "./globals.css";
import { ConvexClientProvider } from "@/lib/convex";
import { Toaster } from "@/components/ui/toaster";

const geistSans = Geist({
variable: "--font-geist-sans",
Expand Down Expand Up @@ -32,6 +33,7 @@ export default function RootLayout({
<Suspense>
<ConvexClientProvider>{children}</ConvexClientProvider>
</Suspense>
<Toaster />
</body>
</html>
);
Expand Down
5 changes: 4 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 72 additions & 12 deletions components/dashboard/recent-unread-section.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"use client";

import Link from "next/link";
import { Mail, Check, Sparkles } from "lucide-react";
import { Mail, Check, Sparkles, ScanText } from "lucide-react";
import { toast } from "sonner";
import { Preloaded, usePreloadedQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import { Button } from "@/components/ui/button";
import { EmailAvatar } from "@/components/email-avatar";
import { EmptyState } from "@/components/empty-state";
import { getDisplayName } from "@/lib/utils";
import { detectVerificationCode } from "@/lib/verificationCodeDetector";

export function RecentUnreadSection({
preloadedUnread,
Expand All @@ -24,6 +26,46 @@ export function RecentUnreadSection({
return { month, day };
};

// Detect and copy verification code directly
const handleDetectAndCopy = async (subject: string) => {
const code = detectVerificationCode(subject);

if (!code) {
toast.error("No verification code found", {
description: "No code detected in the email subject",
duration: 2000,
});
return;
}

try {
// Copy to clipboard
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(code);
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
}

toast.success(`Code ${code} copied to clipboard`, {
duration: 2000,
});
} catch (error) {
console.error("Failed to copy code:", error);
toast.error("Failed to copy code", {
description: "Unable to copy to clipboard",
duration: 3000,
});
}
};

if (unreadEmails.length === 0) {
return (
<div className="rounded-xl border border-zinc-200 dark:border-zinc-800">
Expand Down Expand Up @@ -61,18 +103,36 @@ export function RecentUnreadSection({
{email.subject}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="absolute right-4 top-1/2 -translate-y-1/2 gap-1.5 bg-white opacity-0 shadow-sm transition-opacity hover:bg-emerald-50 hover:text-emerald-700 group-hover:opacity-100 dark:bg-zinc-900 dark:hover:bg-emerald-950/50 dark:hover:text-emerald-400"
onClick={(e) => {
e.preventDefault();
markAsRead({ id: email._id });
}}
<div
className="absolute right-2 top-1/2 flex -translate-y-1/2 gap-0.5 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity"
onClick={(e) => e.preventDefault()}
>
<Check className="h-3.5 w-3.5" />
Mark read
</Button>
<Button
variant="ghost"
size="icon-sm"
className="h-7 w-7 bg-white shadow-sm hover:bg-blue-50 hover:text-blue-700 dark:bg-zinc-900 dark:hover:bg-blue-950/50 dark:hover:text-blue-400 md:h-8 md:w-8"
onClick={(e) => {
e.preventDefault();
handleDetectAndCopy(email.subject);
}}
aria-label="Detect and copy verification code"
title="Detect and copy verification code"
>
<ScanText className="h-3.5 w-3.5 md:h-4 md:w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="h-7 w-7 bg-white shadow-sm hover:bg-emerald-50 hover:text-emerald-700 dark:bg-zinc-900 dark:hover:bg-emerald-950/50 dark:hover:text-emerald-400 md:h-auto md:w-auto md:gap-1.5 md:px-3"
onClick={(e) => {
e.preventDefault();
markAsRead({ id: email._id });
}}
>
<Check className="h-3.5 w-3.5" />
<span className="hidden text-xs md:inline">Mark read</span>
</Button>
</div>
</div>
</Link>
);
Expand Down
100 changes: 81 additions & 19 deletions components/email-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import {
MailOpen,
Trash2,
Circle,
ScanText,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { DeliveryStatusBadge } from "@/components/delivery-status-badge";
import { EmailAvatar } from "@/components/email-avatar";
import { formatRelativeTime, getDisplayName, cn } from "@/lib/utils";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import { Doc, Id } from "@/convex/_generated/dataModel";
import { detectVerificationCode } from "@/lib/verificationCodeDetector";

type ConvexEmail = Doc<"emails">;

Expand Down Expand Up @@ -43,6 +46,46 @@ export function EmailList({ emails, showSender = true, emptyMessage }: EmailList
}
};

// Detect and copy verification code directly
const handleDetectAndCopy = async (subject: string) => {
const code = detectVerificationCode(subject);

if (!code) {
toast.error("No verification code found", {
description: "No code detected in the email subject",
duration: 2000,
});
return;
}

try {
// Copy to clipboard
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(code);
} else {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
Comment on lines +72 to +74
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clipboard fallback doesn't handle execCommand failure.

document.execCommand("copy") returns a boolean indicating success/failure, but this isn't checked. While rare, the command can fail silently.

🛡️ Proposed fix
         document.body.appendChild(textArea);
         textArea.select();
-        document.execCommand("copy");
+        const success = document.execCommand("copy");
         document.body.removeChild(textArea);
+        if (!success) {
+          throw new Error("execCommand copy failed");
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
textArea.select();
const success = document.execCommand("copy");
document.body.removeChild(textArea);
if (!success) {
throw new Error("execCommand copy failed");
}
🤖 Prompt for AI Agents
In `@components/email-list.tsx` around lines 72 - 74, The clipboard fallback code
calls document.execCommand("copy") but doesn't check its boolean result; update
the copy routine around textArea.select()/document.execCommand("copy") to
capture the return value and, if it returns false (or throws), fall back to
navigator.clipboard.writeText(textArea.value) when available, otherwise handle
the failure (e.g., show an error/console.warn) before removing the element via
document.body.removeChild(textArea); ensure the logic around
document.execCommand("copy"), navigator.clipboard.writeText and
document.body.removeChild(textArea) cleanly handles both success and failure
paths.

}

toast.success(`Code ${code} copied to clipboard`, {
duration: 2000,
});
} catch (error) {
console.error("Failed to copy code:", error);
toast.error("Failed to copy code", {
description: "Unable to copy to clipboard",
duration: 3000,
});
}
};

if (emails.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center" data-empty="true">
Expand Down Expand Up @@ -131,38 +174,57 @@ export function EmailList({ emails, showSender = true, emptyMessage }: EmailList
</div>

<div
className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
className="flex shrink-0 items-center gap-0.5 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity"
onClick={(e) => e.preventDefault()}
>
{email.folder === "inbox" && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.preventDefault();
handleToggleRead(email);
}}
title={email.isRead ? "Mark as unread" : "Mark as read"}
>
{email.isRead ? (
<Mail className="h-4 w-4" />
) : (
<MailOpen className="h-4 w-4" />
)}
</Button>
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 md:h-8 md:w-8"
onClick={(e) => {
e.preventDefault();
handleToggleRead(email);
}}
aria-label={email.isRead ? "Mark as unread" : "Mark as read"}
title={email.isRead ? "Mark as unread" : "Mark as read"}
>
{email.isRead ? (
<Mail className="h-3.5 w-3.5 md:h-4 md:w-4" />
) : (
<MailOpen className="h-3.5 w-3.5 md:h-4 md:w-4" />
)}
</Button>
{/* Verification code detection and copy button */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 md:h-8 md:w-8"
onClick={(e) => {
e.preventDefault();
handleDetectAndCopy(email.subject);
}}
aria-label="Detect and copy verification code"
title="Detect and copy verification code"
data-detect-code-btn={email._id}
>
<ScanText className="h-3.5 w-3.5 md:h-4 md:w-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950/50"
className="h-7 w-7 text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950/50 md:h-8 md:w-8"
onClick={(e) => {
e.preventDefault();
handleDelete(email._id);
}}
aria-label="Delete email"
title="Delete"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5 md:h-4 md:w-4" />
</Button>
</div>
</div>
Expand Down
28 changes: 28 additions & 0 deletions components/ui/toaster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { Toaster as Sonner } from "sonner";

/**
* Toaster Component
*
* Wraps sonner toast library with project-specific styling.
* Provides accessible toast notifications with proper ARIA announcements.
*/
export function Toaster() {
return (
<Sonner
position="bottom-right"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
/>
);
}
36 changes: 36 additions & 0 deletions lib/verificationCodeDetector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Unit tests for verification code detection
*/

import { describe, it, expect } from "vitest";
import { detectVerificationCode } from "./verificationCodeDetector";

describe("detectVerificationCode", () => {
it("should detect code at the beginning of subject", () => {
const result = detectVerificationCode("123456 is your Google verification code");
console.log("Result:", result);
expect(result).toBe("123456");
});

it("should detect code at the end of subject", () => {
const result = detectVerificationCode("Your verification code is 789012");
console.log("Result:", result);
expect(result).toBe("789012");
});

it("should detect alphanumeric code", () => {
const result = detectVerificationCode("Your OTP is A3B9X7");
console.log("Result:", result);
expect(result).toBe("A3B9X7");
});

it("should return null when no keyword present", () => {
const result = detectVerificationCode("Order #123456 shipped");
expect(result).toBeNull();
});

it("should return null when no code present", () => {
const result = detectVerificationCode("Your verification code will arrive soon");
expect(result).toBeNull();
});
});
Loading