diff --git a/.gitignore b/.gitignore index 1e15c72..38ed388 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +.yarn/* +.yarn.lock node_modules .output diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..dc61281 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,6 @@ +approvedGitRepositories: + - "**" + +enableScripts: true + +nodeLinker: node-modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ebef57..c3448cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-07-01 + +### Changed + +- Redesigned popup and settings UI with a unified card layout +- Fixed popup height so only content scrolls, not the whole page + +### Fixed + +- "Copy All" no longer undercounts statistics for generated aliases +- Settings/QR modals no longer render outside the popup bounds +- Tab key now moves focus normally instead of being hijacked for @gmail.com autocomplete + ## [1.1.0] - 2025-12-30 ### Added + - Initial release features - Gmail alias generation with plus addressing - Preset management @@ -15,12 +29,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Statistics tracking ### Changed + - Updated dependencies ### Fixed + - Bug fixes and improvements ## [1.0.0] - 2025-12-30 ### Added + - Initial release diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 74da5d5..44d2f1e 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -1,12 +1,92 @@ -export default defineBackground(() => { - console.log("Gmail Alias Toolkit background started"); +import { + getAccountStorageKey, + getLegacyAccountStorageKey, +} from "./popup/utils"; +import { t } from "../lib/i18n"; + +interface EmailAccount { + email: string; + isActive?: boolean; +} + +interface Alias { + email: string; + timestamp: number; +} + +interface AliasStats { + total: number; + tags: Record; +} + +interface Preset { + tag: string; + label: string; +} + +interface AppSettings { + customPresets?: Preset[]; + randomFormat?: "private-mail" | "alphanumeric" | "words" | "timestamp"; + maxHistory?: number; + badgeDisplay?: "none" | "total" | "all-time" | "today" | "week"; +} +export default defineBackground(() => { // Create context menu on install browser.runtime.onInstalled.addListener(async () => { + await migrateLegacyStorageKeys(); await createContextMenus(); await updateBadge(); }); + // One-time migration from the old lossy sanitizer to the new collision-resistant key format + async function migrateLegacyStorageKeys() { + const { migration_legacy_keys_done, email_accounts, base_email } = + (await browser.storage.local.get([ + "migration_legacy_keys_done", + "email_accounts", + "base_email", + ])) as { + migration_legacy_keys_done?: boolean; + email_accounts?: EmailAccount[]; + base_email?: string; + }; + if (migration_legacy_keys_done) return; + + const emails = new Set(); + if (Array.isArray(email_accounts)) { + (email_accounts as EmailAccount[]).forEach( + (acc) => acc?.email && emails.add(acc.email), + ); + } + if (base_email) emails.add(base_email); + + const suffixes = ["gmail_alias_recent", "alias_stats", "favorites"]; + const toSet: Record = {}; + const toRemove: string[] = []; + + for (const email of emails) { + for (const suffix of suffixes) { + const legacyKey = getLegacyAccountStorageKey(email, suffix); + const newKey = getAccountStorageKey(email, suffix); + if (legacyKey === newKey) continue; + + const legacyResult = await browser.storage.local.get(legacyKey); + if (legacyResult[legacyKey] === undefined) continue; + + const newResult = await browser.storage.local.get(newKey); + if (newResult[newKey] === undefined) { + toSet[newKey] = legacyResult[legacyKey]; + } + toRemove.push(legacyKey); + } + } + + if (Object.keys(toSet).length > 0) await browser.storage.local.set(toSet); + if (toRemove.length > 0) await browser.storage.local.remove(toRemove); + await browser.storage.local.set({ migration_legacy_keys_done: true }); + } + // Recreate context menus when settings change browser.storage.onChanged.addListener(async (changes) => { if (changes.app_settings) { @@ -22,7 +102,7 @@ export default defineBackground(() => { (key) => key.startsWith("gmail_alias_recent_") || key.startsWith("alias_stats_") || - key === "email_accounts" + key === "email_accounts", ); if (shouldUpdateBadge) { await updateBadge(); @@ -34,7 +114,7 @@ export default defineBackground(() => { // Parent menu browser.contextMenus.create({ id: "gmail-alias-parent", - title: "Gmail Alias Toolkit", + title: t("extensionName"), contexts: ["editable"], }); @@ -42,7 +122,7 @@ export default defineBackground(() => { browser.contextMenus.create({ id: "fill-random-email", parentId: "gmail-alias-parent", - title: "🎲 Random Email Alias", + title: t("menuRandomEmailAlias"), contexts: ["editable"], }); @@ -50,16 +130,18 @@ export default defineBackground(() => { browser.contextMenus.create({ id: "custom-tag-parent", parentId: "gmail-alias-parent", - title: "📝 Custom Tags", + title: t("menuCustomTags"), contexts: ["editable"], }); // Load custom presets from storage - const result = await browser.storage.local.get("app_settings"); - const customPresets = result.app_settings?.customPresets || []; + const result = (await browser.storage.local.get("app_settings")) as { + app_settings?: AppSettings; + }; + const customPresets: Preset[] = result.app_settings?.customPresets || []; if (customPresets.length > 0) { - customPresets.forEach((preset: any) => { + customPresets.forEach((preset) => { browser.contextMenus.create({ id: `tag-${preset.tag}`, parentId: "custom-tag-parent", @@ -72,7 +154,7 @@ export default defineBackground(() => { browser.contextMenus.create({ id: "no-presets", parentId: "custom-tag-parent", - title: "No presets - Add in Settings", + title: t("menuNoPresets"), contexts: ["editable"], enabled: false, }); @@ -82,48 +164,49 @@ export default defineBackground(() => { browser.contextMenus.create({ id: "gmail-tricks-parent", parentId: "gmail-alias-parent", - title: "✨ Gmail Tricks", + title: t("menuGmailTricks"), contexts: ["editable"], }); browser.contextMenus.create({ id: "trick-dot", parentId: "gmail-tricks-parent", - title: "Dot Variation", + title: t("menuDotVariation"), contexts: ["editable"], }); browser.contextMenus.create({ id: "trick-googlemail", parentId: "gmail-tricks-parent", - title: "Googlemail Domain", + title: t("menuGooglemailDomain"), contexts: ["editable"], }); browser.contextMenus.create({ id: "trick-nodots", parentId: "gmail-tricks-parent", - title: "Remove All Dots", + title: t("menuRemoveAllDots"), contexts: ["editable"], }); } - // Handle context menu clicks browser.contextMenus.onClicked.addListener(async (info, tab) => { if (!tab?.id) return; // Get base email from storage - const result = await browser.storage.local.get([ + const result = (await browser.storage.local.get([ "email_accounts", "base_email", "app_settings", - ]); + ])) as { + email_accounts?: EmailAccount[]; + base_email?: string; + app_settings?: AppSettings; + }; let baseEmail = "your.email@gmail.com"; if (result.email_accounts && Array.isArray(result.email_accounts)) { - const activeAccount = result.email_accounts.find( - (acc: any) => acc.isActive - ); + const activeAccount = result.email_accounts.find((acc) => acc.isActive); if (activeAccount) { baseEmail = activeAccount.email; } @@ -140,21 +223,23 @@ export default defineBackground(() => { let randomTag = ""; switch (format) { - case "private-mail": + case "private-mail": { const chars = "abcdefghijklmnopqrstuvwxyz"; randomTag = Array.from( { length: 8 }, - () => chars[Math.floor(Math.random() * chars.length)] + () => chars[Math.floor(Math.random() * chars.length)], ).join(""); break; - case "alphanumeric": + } + case "alphanumeric": { const alphanum = "abcdefghijklmnopqrstuvwxyz0123456789"; randomTag = Array.from( { length: 10 }, - () => alphanum[Math.floor(Math.random() * alphanum.length)] + () => alphanum[Math.floor(Math.random() * alphanum.length)], ).join(""); break; - case "words": + } + case "words": { const words = [ "alpha", "beta", @@ -170,20 +255,24 @@ export default defineBackground(() => { const num = Math.floor(Math.random() * 100); randomTag = `${word1}${word2}${num}`; break; + } case "timestamp": randomTag = Date.now().toString(); break; + default: + randomTag = Date.now().toString(); + break; } emailToFill = `${username}+${randomTag}@${domain}`; - } else if (info.menuItemId?.startsWith("tag-")) { + } else if (String(info.menuItemId).startsWith("tag-")) { // Custom tag from preset - const tag = info.menuItemId.replace("tag-", ""); + const tag = String(info.menuItemId).replace("tag-", ""); emailToFill = `${username}+${tag}@${domain}`; } else if (info.menuItemId === "trick-dot") { // Dot variation - insert dot at random position const pos = Math.floor(Math.random() * (username.length - 1)) + 1; - const dottedUsername = username.slice(0, pos) + "." + username.slice(pos); + const dottedUsername = `${username.slice(0, pos)}.${username.slice(pos)}`; emailToFill = `${dottedUsername}@${domain}`; } else if (info.menuItemId === "trick-googlemail") { // Googlemail domain @@ -211,7 +300,9 @@ export default defineBackground(() => { async function updateBadge() { try { // Check badge display setting - const settingsResult = await browser.storage.local.get("app_settings"); + const settingsResult = (await browser.storage.local.get( + "app_settings", + )) as { app_settings?: AppSettings }; const badgeDisplay = settingsResult.app_settings?.badgeDisplay ?? "all-time"; @@ -221,10 +312,10 @@ export default defineBackground(() => { } // Get active account - const accountResult = await browser.storage.local.get([ + const accountResult = (await browser.storage.local.get([ "email_accounts", "base_email", - ]); + ])) as { email_accounts?: EmailAccount[]; base_email?: string }; let activeEmail = "your.email@gmail.com"; if ( @@ -232,7 +323,7 @@ export default defineBackground(() => { Array.isArray(accountResult.email_accounts) ) { const activeAccount = accountResult.email_accounts.find( - (acc: any) => acc.isActive + (acc) => acc.isActive, ); if (activeAccount) { activeEmail = activeAccount.email; @@ -244,12 +335,18 @@ export default defineBackground(() => { // Get history for active account const historyKey = getAccountStorageKey( activeEmail, - "gmail_alias_recent" + "gmail_alias_recent", ); const statsKey = getAccountStorageKey(activeEmail, "alias_stats"); - const result = await browser.storage.local.get([historyKey, statsKey]); - const recentAliases = result[historyKey] || []; - const aliasStats = result[statsKey] || { total: 0, tags: {} }; + const result = (await browser.storage.local.get([ + historyKey, + statsKey, + ])) as Record; + const recentAliases = (result[historyKey] as Alias[]) || []; + const aliasStats = (result[statsKey] as AliasStats) || { + total: 0, + tags: {}, + }; let count = 0; const now = new Date(); @@ -261,21 +358,23 @@ export default defineBackground(() => { case "all-time": count = aliasStats.total || 0; break; - case "today": + case "today": { const today = new Date( now.getFullYear(), now.getMonth(), - now.getDate() + now.getDate(), ).getTime(); - count = recentAliases.filter((a: any) => a.timestamp >= today).length; + count = recentAliases.filter((a) => a.timestamp >= today).length; break; - case "week": + } + case "week": { const weekAgo = new Date( - now.getTime() - 7 * 24 * 60 * 60 * 1000 + now.getTime() - 7 * 24 * 60 * 60 * 1000, ).getTime(); - count = recentAliases.filter( - (a: any) => a.timestamp >= weekAgo - ).length; + count = recentAliases.filter((a) => a.timestamp >= weekAgo).length; + break; + } + default: break; } @@ -292,19 +391,13 @@ export default defineBackground(() => { } } - // Helper function to get account-specific storage key - function getAccountStorageKey(email: string, suffix: string): string { - const sanitized = email.replace(/[^a-zA-Z0-9]/g, "_"); - return `${suffix}_${sanitized}`; - } - // Helper function to save email to history and stats async function saveToHistory(email: string, maxRecent: number) { // Get active account - const accountResult = await browser.storage.local.get([ + const accountResult = (await browser.storage.local.get([ "email_accounts", "base_email", - ]); + ])) as { email_accounts?: EmailAccount[]; base_email?: string }; let activeEmail = "your.email@gmail.com"; if ( @@ -312,7 +405,7 @@ export default defineBackground(() => { Array.isArray(accountResult.email_accounts) ) { const activeAccount = accountResult.email_accounts.find( - (acc: any) => acc.isActive + (acc) => acc.isActive, ); if (activeAccount) { activeEmail = activeAccount.email; @@ -326,22 +419,28 @@ export default defineBackground(() => { const statsKey = getAccountStorageKey(activeEmail, "alias_stats"); // Get current history - const result = await browser.storage.local.get([historyKey, statsKey]); - const recentAliases = result[historyKey] || []; + const result = (await browser.storage.local.get([ + historyKey, + statsKey, + ])) as Record; + const recentAliases = (result[historyKey] as Alias[]) || []; // Add to history (remove duplicates, add to top) - const newAlias = { + const newAlias: Alias = { email, timestamp: Date.now(), }; const updated = [ newAlias, - ...recentAliases.filter((a: any) => a.email !== email), + ...recentAliases.filter((a) => a.email !== email), ].slice(0, maxRecent); // Update statistics - let stats = result[statsKey] || { total: 0, tags: {} }; + const stats: AliasStats = (result[statsKey] as AliasStats) || { + total: 0, + tags: {}, + }; stats.total = (stats.total || 0) + 1; // Extract tag from email (if it has + addressing) diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 9f8704f..c6dc11b 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -5,7 +5,7 @@ export default defineContentScript({ browser.runtime.onMessage.addListener((message) => { if (message.action === "fillEmail" && message.email) { // Get the active element (the input field that was right-clicked) - const activeElement = document.activeElement; + const activeElement = document.activeElement as HTMLElement | null; if ( activeElement && diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index a123fa5..4de933a 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -1,15 +1,36 @@ -import { useState, useEffect } from 'react'; -import './App.css'; -import Settings from './components/Settings'; -import Statistics from './components/Statistics'; -import GmailTricks from './components/GmailTricks'; -import WelcomeScreen from './components/WelcomeScreen'; +import { useState, useEffect, useRef, useCallback } from "react"; +import QRCode from "qrcode"; +import "./App.css"; +import Settings from "./components/Settings"; +import Statistics from "./components/Statistics"; +import WelcomeScreen from "./components/WelcomeScreen"; +import GeneratorTabs from "./components/GeneratorTabs"; +import HistorySection from "./components/HistorySection"; +import { + getAccountStorageKey, + generateAlias, + filterAliases, + type RandomFormat, +} from "./utils"; +import { t } from "../../lib/i18n"; interface Alias { email: string; timestamp: number; } +interface EmailAccount { + id: string; + email: string; + label?: string; + isActive: boolean; +} + +interface Favorite { + email: string; + timestamp?: number; +} + interface Preset { id: string; label: string; @@ -21,166 +42,260 @@ interface AppSettings { maxHistory: number; tags?: Record; total?: number; - randomFormat?: 'private-mail' | 'alphanumeric' | 'words' | 'timestamp'; + randomFormat?: "private-mail" | "alphanumeric" | "words" | "timestamp"; + theme?: "light" | "dark" | "auto"; + showNotifications?: boolean; } interface StorageResult { - [key: string]: any; gmail_alias_recent?: Alias[]; base_email?: string; app_settings?: AppSettings; + email_accounts?: EmailAccount[]; + favorites?: Favorite[]; alias_stats?: { total: number; tags: Record; }; } -const STORAGE_KEY = 'gmail_alias_recent'; - -// Helper to get account-specific storage key -const getAccountStorageKey = (email: string, suffix: string) => { - const sanitized = email.replace(/[^a-zA-Z0-9]/g, '_'); - return `${suffix}_${sanitized}`; -}; - +/** Popup root: alias generators, history, favorites, accounts, and settings. */ function App() { - const [baseEmail, setBaseEmail] = useState('your.email@gmail.com'); - const [customTag, setCustomTag] = useState(''); + /** Focuses an input once when it mounts (replaces autoFocus). */ + const focusOnMount = useCallback((el: HTMLInputElement | null) => { + el?.focus(); + }, []); + + const [baseEmail, setBaseEmail] = useState("your.email@gmail.com"); + const [customTag, setCustomTag] = useState(""); const [recentAliases, setRecentAliases] = useState([]); const [copiedEmail, setCopiedEmail] = useState(null); + const [toastMessage, setToastMessage] = useState(null); + const [showNotifications, setShowNotifications] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [maxRecent, setMaxRecent] = useState(20); const [customPresets, setCustomPresets] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [filterTag, setFilterTag] = useState('all'); - const [sortBy, setSortBy] = useState<'recent' | 'alphabetical'>('recent'); - const [viewMode, setViewMode] = useState<'all' | 'favorites'>('all'); + const [searchQuery, setSearchQuery] = useState(""); + const [filterTag, setFilterTag] = useState("all"); + const [sortBy, setSortBy] = useState<"recent" | "alphabetical">("recent"); + const [viewMode, setViewMode] = useState<"all" | "favorites">("all"); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); - const [randomFormat, setRandomFormat] = useState<'private-mail' | 'alphanumeric' | 'words' | 'timestamp'>('private-mail'); - const [lastGeneratedRandom, setLastGeneratedRandom] = useState(''); + const [randomFormat, setRandomFormat] = + useState("private-mail"); const [generatedRandomList, setGeneratedRandomList] = useState([]); const [randomEmailCount, setRandomEmailCount] = useState(10); - const [showRandomSettings, setShowRandomSettings] = useState(false); - const [activeGeneratorTab, setActiveGeneratorTab] = useState<'random' | 'tags' | 'tricks'>('random'); - const [emailAccounts, setEmailAccounts] = useState([]); + const [activeGeneratorTab, setActiveGeneratorTab] = useState< + "random" | "tags" | "tricks" + >("random"); + const [emailAccounts, setEmailAccounts] = useState([]); const [hasEmailAccounts, setHasEmailAccounts] = useState(true); const [showAddAccount, setShowAddAccount] = useState(false); - const [newAccountEmail, setNewAccountEmail] = useState(''); - const [newAccountLabel, setNewAccountLabel] = useState(''); - const [addAccountError, setAddAccountError] = useState(''); + const [newAccountEmail, setNewAccountEmail] = useState(""); + const [newAccountLabel, setNewAccountLabel] = useState(""); + const [addAccountError, setAddAccountError] = useState(""); const [favorites, setFavorites] = useState([]); + // Bulk delete + const [isSelectMode, setIsSelectMode] = useState(false); + const [selectedAliases, setSelectedAliases] = useState>( + new Set(), + ); + // QR code modal + const [qrAlias, setQrAlias] = useState(null); + const qrCanvasRef = useRef(null); + // Theme + const [, setTheme] = useState<"light" | "dark" | "auto">("light"); // Load recent aliases, base email, and settings from storage useEffect(() => { - browser.storage.local.get(['base_email', 'app_settings', 'email_accounts', 'gmail_alias_recent', 'alias_stats', 'favorites']).then(async (result: StorageResult) => { - let activeEmail = 'your.email@gmail.com'; - let needsMigration = false; - - // Load active email from email_accounts or fall back to base_email - if (result.email_accounts && Array.isArray(result.email_accounts)) { - const activeAccount = result.email_accounts.find((acc: any) => acc.isActive); - if (activeAccount) { - activeEmail = activeAccount.email; + browser.storage.local + .get([ + "base_email", + "app_settings", + "email_accounts", + "gmail_alias_recent", + "alias_stats", + "favorites", + ]) + // skipcq: JS-R1005 + .then(async (result: StorageResult) => { + let activeEmail = "your.email@gmail.com"; + let needsMigration = false; + + // Load active email from email_accounts or fall back to base_email + if (result.email_accounts && Array.isArray(result.email_accounts)) { + const activeAccount = result.email_accounts.find( + (acc) => acc.isActive, + ); + if (activeAccount) { + activeEmail = activeAccount.email; + setBaseEmail(activeEmail); + } + } else if (result.base_email) { + activeEmail = result.base_email; setBaseEmail(activeEmail); + // Check if we need to migrate from old format + needsMigration = true; } - } else if (result.base_email) { - activeEmail = result.base_email; - setBaseEmail(activeEmail); - // Check if we need to migrate from old format - needsMigration = true; - } - - // Migrate old data format to new account-specific format if needed - if (needsMigration && (result.gmail_alias_recent || result.alias_stats || result.favorites)) { - const historyKey = getAccountStorageKey(activeEmail, 'gmail_alias_recent'); - const statsKey = getAccountStorageKey(activeEmail, 'alias_stats'); - const favoritesKey = getAccountStorageKey(activeEmail, 'favorites'); - - // Only migrate if account-specific data doesn't exist yet - const accountData = await browser.storage.local.get([historyKey, statsKey, favoritesKey]); - - if (!accountData[historyKey] && !accountData[statsKey] && !accountData[favoritesKey]) { - await browser.storage.local.set({ - [historyKey]: result.gmail_alias_recent || [], - [statsKey]: result.alias_stats || { total: 0, tags: {} }, - [favoritesKey]: result.favorites || [], - }); - console.log('Migrated old data to account-specific storage for:', activeEmail); + + // Migrate old data format to new account-specific format if needed + if ( + needsMigration && + (result.gmail_alias_recent || result.alias_stats || result.favorites) + ) { + const historyKey = getAccountStorageKey( + activeEmail, + "gmail_alias_recent", + ); + const statsKey = getAccountStorageKey(activeEmail, "alias_stats"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + + // Only migrate if account-specific data doesn't exist yet + const accountData = await browser.storage.local.get([ + historyKey, + statsKey, + favoritesKey, + ]); + + if ( + !accountData[historyKey] && + !accountData[statsKey] && + !accountData[favoritesKey] + ) { + await browser.storage.local.set({ + [historyKey]: result.gmail_alias_recent || [], + [statsKey]: result.alias_stats || { total: 0, tags: {} }, + [favoritesKey]: result.favorites || [], + }); + } } - } - - // Load account-specific history - const historyKey = getAccountStorageKey(activeEmail, 'gmail_alias_recent'); - const favoritesKey = getAccountStorageKey(activeEmail, 'favorites'); - const historyResult = await browser.storage.local.get([historyKey, favoritesKey]); - if (historyResult[historyKey] && Array.isArray(historyResult[historyKey])) { - setRecentAliases(historyResult[historyKey] as Alias[]); - } else { - setRecentAliases([]); - } - - // Load favorites - if (historyResult[favoritesKey] && Array.isArray(historyResult[favoritesKey])) { - const favEmails = historyResult[favoritesKey].map((f: any) => f.email); - setFavorites(favEmails); - } else { - setFavorites([]); - } - - if (result.app_settings) { - setMaxRecent(result.app_settings.maxHistory || 20); - setCustomPresets(result.app_settings.customPresets || []); - setRandomFormat(result.app_settings.randomFormat || 'private-mail'); - } - - // Load email accounts list - if (result.email_accounts && Array.isArray(result.email_accounts)) { - setEmailAccounts(result.email_accounts); - setHasEmailAccounts(result.email_accounts.length > 0); - } else if (result.base_email) { - // Legacy: has base_email but no email_accounts - setHasEmailAccounts(true); - } else { - // First time user - setHasEmailAccounts(false); - } - }); + + // Load account-specific history + const historyKey = getAccountStorageKey( + activeEmail, + "gmail_alias_recent", + ); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + const historyResult = await browser.storage.local.get([ + historyKey, + favoritesKey, + ]); + if ( + historyResult[historyKey] && + Array.isArray(historyResult[historyKey]) + ) { + setRecentAliases(historyResult[historyKey] as Alias[]); + } else { + setRecentAliases([]); + } + + // Load favorites + if ( + historyResult[favoritesKey] && + Array.isArray(historyResult[favoritesKey]) + ) { + const favEmails = historyResult[favoritesKey].map( + (f: Favorite) => f.email, + ); + setFavorites(favEmails); + } else { + setFavorites([]); + } + + if (result.app_settings) { + setMaxRecent(result.app_settings.maxHistory || 20); + setCustomPresets(result.app_settings.customPresets || []); + setRandomFormat(result.app_settings.randomFormat || "private-mail"); + setShowNotifications(result.app_settings.showNotifications ?? true); + const savedTheme = result.app_settings.theme || "light"; + setTheme(savedTheme); + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + document.documentElement.classList.toggle( + "dark", + savedTheme === "dark" || (savedTheme === "auto" && prefersDark), + ); + } + + // Load email accounts list + if (result.email_accounts && Array.isArray(result.email_accounts)) { + setEmailAccounts(result.email_accounts); + setHasEmailAccounts(result.email_accounts.length > 0); + } else if (result.base_email) { + // Legacy: has base_email but no email_accounts + setHasEmailAccounts(true); + } else { + // First time user + setHasEmailAccounts(false); + } + }); }, []); // Listen for settings changes useEffect(() => { - const handleStorageChange = async (changes: any) => { + /** Syncs settings, accounts, and favorites state when extension storage changes. */ + const handleStorageChange = async ( + changes: Record, + ) => { if (changes.app_settings) { - const newSettings = changes.app_settings.newValue; + const newSettings = changes.app_settings.newValue as + | AppSettings + | undefined; if (newSettings) { setMaxRecent(newSettings.maxHistory || 20); setCustomPresets(newSettings.customPresets || []); - setRandomFormat(newSettings.randomFormat || 'private-mail'); + setRandomFormat(newSettings.randomFormat || "private-mail"); + setShowNotifications(newSettings.showNotifications ?? true); + const newTheme = newSettings.theme || "light"; + setTheme(newTheme); + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + document.documentElement.classList.toggle( + "dark", + newTheme === "dark" || (newTheme === "auto" && prefersDark), + ); } } if (changes.email_accounts) { - const newAccounts = changes.email_accounts.newValue; + const newAccounts = changes.email_accounts.newValue as + | EmailAccount[] + | undefined; if (newAccounts) { setEmailAccounts(newAccounts); setHasEmailAccounts(newAccounts.length > 0); // Update base email if active account changed - const activeAccount = newAccounts.find((acc: any) => acc.isActive); + const activeAccount = newAccounts.find((acc) => acc.isActive); if (activeAccount && activeAccount.email !== baseEmail) { setBaseEmail(activeAccount.email); // Load history for new account - const historyKey = getAccountStorageKey(activeAccount.email, 'gmail_alias_recent'); + const historyKey = getAccountStorageKey( + activeAccount.email, + "gmail_alias_recent", + ); const historyResult = await browser.storage.local.get(historyKey); - if (historyResult[historyKey] && Array.isArray(historyResult[historyKey])) { + if ( + historyResult[historyKey] && + Array.isArray(historyResult[historyKey]) + ) { setRecentAliases(historyResult[historyKey] as Alias[]); } else { setRecentAliases([]); } // Load favorites for new account - const favoritesKey = getAccountStorageKey(activeAccount.email, 'favorites'); + const favoritesKey = getAccountStorageKey( + activeAccount.email, + "favorites", + ); const favResult = await browser.storage.local.get(favoritesKey); - if (favResult[favoritesKey] && Array.isArray(favResult[favoritesKey])) { - const favEmails = favResult[favoritesKey].map((f: any) => f.email); + if ( + favResult[favoritesKey] && + Array.isArray(favResult[favoritesKey]) + ) { + const favEmails = favResult[favoritesKey].map( + (f: Favorite) => f.email, + ); setFavorites(favEmails); } else { setFavorites([]); @@ -188,13 +303,15 @@ function App() { } } } - + // Listen for favorites changes - const favoritesKey = getAccountStorageKey(baseEmail, 'favorites'); + const favoritesKey = getAccountStorageKey(baseEmail, "favorites"); if (changes[favoritesKey]) { - const newFavorites = changes[favoritesKey].newValue; + const newFavorites = changes[favoritesKey].newValue as + | Favorite[] + | undefined; if (newFavorites && Array.isArray(newFavorites)) { - const favEmails = newFavorites.map((f: any) => f.email); + const favEmails = newFavorites.map((f: Favorite) => f.email); setFavorites(favEmails); } else { setFavorites([]); @@ -211,81 +328,250 @@ function App() { setCurrentPage(1); }, [searchQuery, filterTag, viewMode, sortBy]); + // Modals are absolutely positioned against the document (popups have no stable viewport), + // so scroll to top when one opens or it can render off-screen below the fold. + useEffect(() => { + if (isSettingsOpen || qrAlias) { + window.scrollTo(0, 0); + } + }, [isSettingsOpen, qrAlias]); + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl/Cmd + K to open settings - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + if ((e.ctrlKey || e.metaKey) && e.key === "k") { e.preventDefault(); setIsSettingsOpen(true); } // Escape to close settings - if (e.key === 'Escape' && isSettingsOpen) { + if (e.key === "Escape" && isSettingsOpen) { setIsSettingsOpen(false); } }; - globalThis.addEventListener('keydown', handleKeyDown); - return () => globalThis.removeEventListener('keydown', handleKeyDown); + globalThis.addEventListener("keydown", handleKeyDown); + return () => globalThis.removeEventListener("keydown", handleKeyDown); }, [isSettingsOpen]); - const saveRecentAlias = (email: string) => { - const newAlias: Alias = { + /** Increments the total and per-tag counters for the given generated emails. */ + const updateStats = async (emails: string[]) => { + // Use account-specific stats key + const statsKey = getAccountStorageKey(baseEmail, "alias_stats"); + const result = (await browser.storage.local.get(statsKey)) as Record< + string, + { total: number; tags: Record } | undefined + >; + const stats = result[statsKey] || { total: 0, tags: {} }; + + stats.total = (stats.total || 0) + emails.length; + stats.tags = stats.tags || {}; + + emails.forEach((email) => { + // Extract tag from email (only if it has + addressing) + const tagMatch = email.match(/\+([^@]+)@/); + if (tagMatch) { + const tag = tagMatch[1]; + stats.tags[tag] = (stats.tags[tag] || 0) + 1; + } + }); + + await browser.storage.local.set({ [statsKey]: stats }); + }; + + // Batched save: computes the merged list and stats totals once, avoiding the + // stale-closure / lost-update race that happens when saveRecentAlias is called + // N times in a tight loop (e.g. "Copy All"). + const saveRecentAliases = (emails: string[]) => { + if (emails.length === 0) return; + + const now = Date.now(); + const newAliases: Alias[] = emails.map((email, i) => ({ email, - timestamp: Date.now(), - }; + timestamp: now - i, + })); + const newEmailSet = new Set(emails); - const updated = [newAlias, ...recentAliases.filter((a) => a.email !== email)].slice( - 0, - maxRecent - ); + const updated = [ + ...newAliases, + ...recentAliases.filter((a) => !newEmailSet.has(a.email)), + ].slice(0, maxRecent); setRecentAliases(updated); - + // Save with account-specific key - const historyKey = getAccountStorageKey(baseEmail, 'gmail_alias_recent'); + const historyKey = getAccountStorageKey(baseEmail, "gmail_alias_recent"); browser.storage.local.set({ [historyKey]: updated }); // Update statistics - updateStats(email); + updateStats(emails); }; - const updateStats = async (email: string) => { - // Use account-specific stats key - const statsKey = getAccountStorageKey(baseEmail, 'alias_stats'); - const result: StorageResult = await browser.storage.local.get(statsKey); - const stats = result[statsKey] || { total: 0, tags: {} }; + /** Saves a single alias to recent history. */ + const saveRecentAlias = (email: string) => saveRecentAliases([email]); - stats.total = (stats.total || 0) + 1; + // QR code: draw when alias changes + useEffect(() => { + if (qrAlias && qrCanvasRef.current) { + (async () => { + try { + await QRCode.toCanvas(qrCanvasRef.current, qrAlias, { + width: 200, + margin: 2, + }); + } catch { + if (showNotifications) { + setToastMessage(t("toastQrFailed")); + setTimeout(() => setToastMessage(null), 2000); + } + } + })(); + } + }, [qrAlias, showNotifications]); - // Extract tag from email (only if it has + addressing) - const tagMatch = email.match(/\+([^@]+)@/); - if (tagMatch) { - const tag = tagMatch[1]; - stats.tags = stats.tags || {}; - stats.tags[tag] = (stats.tags[tag] || 0) + 1; + /** Triggers a browser download for the given file content. */ + const downloadFile = ( + filename: string, + mimeType: string, + content: string, + ) => { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + }; + + /** Exports recent aliases as a CSV or JSON download. */ + const exportAliases = (format: "csv" | "json") => { + if (recentAliases.length === 0) return; + if (format === "csv") { + const rows = recentAliases.map( + (a) => `"${a.email}","${new Date(a.timestamp).toISOString()}"`, + ); + downloadFile( + `aliases-${Date.now()}.csv`, + "text/csv", + `Email,Created At\n${rows.join("\n")}`, + ); + } else { + const data = recentAliases.map((a) => ({ + email: a.email, + createdAt: new Date(a.timestamp).toISOString(), + })); + downloadFile( + `aliases-${Date.now()}.json`, + "application/json", + JSON.stringify(data, null, 2), + ); + } + if (showNotifications) { + setToastMessage(t("toastExportedAliases", String(recentAliases.length))); + setTimeout(() => setToastMessage(null), 2000); } + }; - await browser.storage.local.set({ [statsKey]: stats }); + /** Deletes the selected aliases from history, favorites, and stats. */ + const deleteSelected = async () => { + const count = selectedAliases.size; + const updated = recentAliases.filter((a) => !selectedAliases.has(a.email)); + setRecentAliases(updated); + const historyKey = getAccountStorageKey(baseEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(baseEmail, "favorites"); + const statsKey = getAccountStorageKey(baseEmail, "alias_stats"); + + const [favResult, statsResult] = await Promise.all([ + browser.storage.local.get(favoritesKey), + browser.storage.local.get(statsKey), + ]); + + // Remove deleted emails from favorites + const currentFavs = (favResult[favoritesKey] as Favorite[]) || []; + const updatedFavs = currentFavs.filter( + (f: Favorite) => !selectedAliases.has(f.email), + ); + + // Decrement stats: total and per-tag counts + const stats = (statsResult[statsKey] as { + total: number; + tags: Record; + }) || { total: 0, tags: {} }; + const tags = { ...stats.tags }; + selectedAliases.forEach((email) => { + const match = email.match(/\+([^@]+)@/); + if (match && tags[match[1]]) { + tags[match[1]] = Math.max(0, tags[match[1]] - 1); + } + }); + // Drop tags whose count reached zero + const remainingTags = Object.fromEntries( + Object.entries(tags).filter(([, tagCount]) => tagCount > 0), + ); + const updatedStats = { + total: Math.max(0, stats.total - count), + tags: remainingTags, + }; + + await browser.storage.local.set({ + [historyKey]: updated, + [favoritesKey]: updatedFavs, + [statsKey]: updatedStats, + }); + + setFavorites(updatedFavs.map((f: Favorite) => f.email)); + setSelectedAliases(new Set()); + setIsSelectMode(false); + if (showNotifications) { + setToastMessage(t("toastDeletedAliases", String(count))); + setTimeout(() => setToastMessage(null), 2000); + } }; - const clearHistory = () => { + /** Toggles an alias in the bulk-delete selection set. */ + const toggleSelectAlias = (email: string) => { + setSelectedAliases((prev) => { + const next = new Set(prev); + if (next.has(email)) { + next.delete(email); + } else { + next.add(email); + } + return next; + }); + }; + + /** Clears all history, favorites, and stats for the active account. */ + const clearHistory = async () => { setRecentAliases([]); - const historyKey = getAccountStorageKey(baseEmail, 'gmail_alias_recent'); - browser.storage.local.set({ [historyKey]: [] }); + setFavorites([]); + const historyKey = getAccountStorageKey(baseEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(baseEmail, "favorites"); + const statsKey = getAccountStorageKey(baseEmail, "alias_stats"); + await browser.storage.local.set({ + [historyKey]: [], + [favoritesKey]: [], + [statsKey]: { total: 0, tags: {} }, + }); + if (showNotifications) { + setToastMessage(t("toastHistoryCleared")); + setTimeout(() => setToastMessage(null), 2000); + } }; + /** Adds or removes an alias from the account's favorites. */ const toggleFavorite = async (email: string) => { - const favoritesKey = getAccountStorageKey(baseEmail, 'favorites'); + const favoritesKey = getAccountStorageKey(baseEmail, "favorites"); const result = await browser.storage.local.get(favoritesKey); - const currentFavs = result[favoritesKey] || []; - - const exists = currentFavs.find((f: any) => f.email === email); - + const currentFavs = (result[favoritesKey] as Favorite[]) || []; + + const exists = currentFavs.find((f: Favorite) => f.email === email); + let updated; if (exists) { // Remove from favorites - updated = currentFavs.filter((f: any) => f.email !== email); + updated = currentFavs.filter((f: Favorite) => f.email !== email); } else { // Add to favorites const newFav = { @@ -295,712 +581,264 @@ function App() { }; updated = [...currentFavs, newFav]; } - - await browser.storage.local.set({ [favoritesKey]: updated }); - - // Update local state - const favEmails = updated.map((f: any) => f.email); - setFavorites(favEmails); - }; - const generateRandomString = (format: 'private-mail' | 'alphanumeric' | 'words' | 'timestamp', index: number = 0): string => { - if (format === 'private-mail') { - // Generate format like: private-mail-q2ga - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - const length = 4; - let randomStr = ''; - for (let i = 0; i < length; i++) { - randomStr += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return `private-mail-${randomStr}`; - } - - if (format === 'timestamp') { - // Add index to ensure uniqueness when generating multiple - return (Date.now() + index).toString(36); - } - - if (format === 'words') { - const adjectives = ['happy', 'sunny', 'calm', 'bright', 'swift', 'brave', 'cool', 'smart', 'quick', 'zen', 'wild', 'free', 'bold', 'wise', 'pure', 'kind', 'fair', 'true', 'rare', 'fine']; - const nouns = ['fox', 'bird', 'bear', 'wolf', 'deer', 'lion', 'hawk', 'eagle', 'tiger', 'panda', 'seal', 'otter', 'raven', 'crane', 'swan', 'lynx', 'coral', 'pearl', 'jade', 'ruby']; - const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; - const noun = nouns[Math.floor(Math.random() * nouns.length)]; - const num = Math.floor(Math.random() * 999); - return `${adj}-${noun}-${num}`; - } - - // alphanumeric - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - const length = 8; - let result = ''; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; - }; + await browser.storage.local.set({ [favoritesKey]: updated }); - const generateRandomAlias = () => { - // Clear previous results first - setGeneratedRandomList([]); - setLastGeneratedRandom(''); - - const aliases: string[] = []; - const timestamp = Date.now(); - - for (let i = 0; i < randomEmailCount; i++) { - const randomTag = generateRandomString(randomFormat, i + timestamp); - const alias = generateAlias(randomTag); - if (alias) { - aliases.push(alias); - } + const favEmails = updated.map((f: Favorite) => f.email); + setFavorites(favEmails); + if (showNotifications) { + setToastMessage( + exists ? t("toastFavoriteRemoved") : t("toastFavoriteAdded"), + ); + setTimeout(() => setToastMessage(null), 2000); } - - // Use setTimeout to ensure state update triggers re-render - setTimeout(() => { - if (aliases.length > 0) { - setLastGeneratedRandom(aliases[0]); - setGeneratedRandomList(aliases); - // Copy first one to clipboard - copyToClipboard(aliases[0]); - } - }, 0); - }; - - const saveBaseEmail = (email: string) => { - // Only update base_email storage, don't modify account data - browser.storage.local.set({ base_email: email }); - }; - - const generateAlias = (tag: string) => { - const [username, domain] = baseEmail.split('@'); - if (!username || !domain) return null; - return `${username}+${tag}@${domain}`; }; + /** Copies an alias to the clipboard and records it in recent history. */ const copyToClipboard = async (email: string) => { try { await navigator.clipboard.writeText(email); setCopiedEmail(email); + if (showNotifications) { + setToastMessage(t("toastCopiedEmail", email)); + } saveRecentAlias(email); - setTimeout(() => setCopiedEmail(null), 2000); - } catch (err) { - console.error('Failed to copy:', err); + setTimeout(() => { + setCopiedEmail(null); + setToastMessage(null); + }, 2000); + } catch { + if (showNotifications) { + setToastMessage(t("toastCopyFailed")); + setTimeout(() => setToastMessage(null), 2000); + } } }; + /** Generates and copies an alias for the clicked preset tag. */ const handlePresetClick = (tag: string) => { - const alias = generateAlias(tag); + const alias = generateAlias(baseEmail, tag); if (alias) { copyToClipboard(alias); } }; + /** Generates and copies an alias from the custom tag input. */ const handleCustomGenerate = () => { if (!customTag.trim()) return; - const alias = generateAlias(customTag.trim()); + const alias = generateAlias(baseEmail, customTag.trim()); if (alias) { copyToClipboard(alias); - setCustomTag(''); + setCustomTag(""); } }; + /** Generates the custom alias when Enter is pressed. */ const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + if (e.key === "Enter") { handleCustomGenerate(); } }; + /** Validates and adds a new email account without switching to it. */ const handleAddAccount = async () => { - setAddAccountError(''); - + setAddAccountError(""); + if (!newAccountEmail.trim()) { - setAddAccountError('Email is required'); + setAddAccountError(t("errorEnterEmail")); return; } - - if (!newAccountEmail.includes('@')) { - setAddAccountError('Please enter a valid email address'); + + if (!newAccountEmail.includes("@")) { + setAddAccountError(t("errorInvalidEmail")); return; } - + // Check if email already exists - const emailExists = emailAccounts.some(acc => acc.email.toLowerCase() === newAccountEmail.trim().toLowerCase()); + const emailExists = emailAccounts.some( + (acc) => acc.email.toLowerCase() === newAccountEmail.trim().toLowerCase(), + ); if (emailExists) { - setAddAccountError('This email address is already added!'); + setAddAccountError(t("errorAccountExists")); return; } - + const newAccount = { id: Date.now().toString(), email: newAccountEmail.trim(), - label: newAccountLabel.trim() || 'Account ' + (emailAccounts.length + 1), + label: newAccountLabel.trim() || `Account ${emailAccounts.length + 1}`, isActive: false, // Don't auto-switch to new account }; - + const updatedAccounts = [...emailAccounts, newAccount]; await browser.storage.local.set({ email_accounts: updatedAccounts }); - + // Initialize empty storage for new account - const historyKey = getAccountStorageKey(newAccount.email, 'gmail_alias_recent'); - const statsKey = getAccountStorageKey(newAccount.email, 'alias_stats'); - const favoritesKey = getAccountStorageKey(newAccount.email, 'favorites'); - + const historyKey = getAccountStorageKey( + newAccount.email, + "gmail_alias_recent", + ); + const statsKey = getAccountStorageKey(newAccount.email, "alias_stats"); + const favoritesKey = getAccountStorageKey(newAccount.email, "favorites"); + await browser.storage.local.set({ [historyKey]: [], [statsKey]: { total: 0, tags: {} }, [favoritesKey]: [], }); - - setNewAccountEmail(''); - setNewAccountLabel(''); - setAddAccountError(''); + + setNewAccountEmail(""); + setNewAccountLabel(""); + setAddAccountError(""); setShowAddAccount(false); - - // Show success message briefly - const accountLabel = newAccount.label; - setCopiedEmail(`✓ ${accountLabel} added!`); - setTimeout(() => setCopiedEmail(null), 2000); + + if (showNotifications) { + setToastMessage(t("toastAccountAdded", newAccount.label)); + setTimeout(() => setToastMessage(null), 2000); + } }; + // Compute outside IIFE so bulk-delete bar can reference it + const filteredAliases = filterAliases(recentAliases, { + viewMode, + favorites, + searchQuery, + filterTag, + sortBy, + }); + + // skipcq: JS-0415 return ( -
+
{/* Show Welcome Screen for first-time users */} {!hasEmailAccounts ? ( - { - setBaseEmail(email); - setHasEmailAccounts(true); - }} - onOpenSettings={() => setIsSettingsOpen(true)} - /> +
+ { + setBaseEmail(email); + setHasEmailAccounts(true); + }} + onOpenSettings={() => setIsSettingsOpen(true)} + /> +
) : ( + // skipcq: JS-0415 <> {/* Header */} -
+
-
-

Gmail Alias Toolkit

-

Generate aliases with plus addressing

+
+ +
+

+ {t("extensionName")} +

+

+ {t("headerSubtitle")} +

+
- {/* Main Content */} -
- {/* Base Email Selector - Dropdown */} -
- -
-
- -
- - - -
-
- -
- - {/* Quick Add Account Form */} - {showAddAccount && ( -
-
- { - setNewAccountEmail(e.target.value); - setAddAccountError(''); - }} - onKeyDown={(e) => { - if (e.key === 'Tab' && newAccountEmail && !newAccountEmail.includes('@')) { - e.preventDefault(); - setNewAccountEmail(newAccountEmail + '@gmail.com'); - } - }} - placeholder="your.email" - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - autoFocus - /> - {newAccountEmail && !newAccountEmail.includes('@') && ( -
- @gmail.com -
- )} -
- {addAccountError && ( -
-

{addAccountError}

-
- )} -

- 💡 Press Tab to add @gmail.com -

- setNewAccountLabel(e.target.value)} - placeholder="Label (optional, e.g., Work, Personal)" - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- - -
-
- )} - - {baseEmail && !baseEmail.includes('@gmail.com') && baseEmail.includes('@') && ( -

- ⚠ This doesn't look like a Gmail address. Plus addressing works best with Gmail. -

- )} -
- - {/* Unified Email Alias Generator - RoboForm Style */} -
- {/* Header */} -
-
- - - -

Email Alias Generator

-
-
- - {/* Main Tabs */} -
- - - -
- - {/* Tab Content */} -
- {/* Random Tab */} - {activeGeneratorTab === 'random' && ( -
- {/* Format Selector */} -
- - -
- - {/* Number of Emails */} -
- - setRandomEmailCount(Math.max(1, parseInt(e.target.value) || 10))} - className="w-20 px-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-purple-500" - /> -
- - {/* Generate Button */} - - - {/* Generated Emails List */} - {generatedRandomList.length > 0 && ( -
-
-
- Generated Aliases - {generatedRandomList.length} total -
-
-
- {generatedRandomList.map((email, index) => ( -
-
- {email} -
- -
- ))} -
-
- )} - -
- {randomFormat === 'private-mail' ? 'Format: private-mail-xxxx' : randomFormat === 'alphanumeric' ? '8 random characters' : randomFormat === 'words' ? '2 random words' : 'Unix timestamp'} -
-
- )} - - {/* Custom Tags Tab */} - {activeGeneratorTab === 'tags' && ( -
-
- setCustomTag(e.target.value)} - onKeyDown={handleKeyPress} - className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="Enter tag (e.g., shopping, work)" - /> - -
- - {/* Custom Presets - Quick Access */} - {customPresets.length > 0 && ( -
-
Your Presets
-
- {customPresets.map((preset) => ( - - ))} + {/* Main Content */} +
+
+ {/* Base Email Selector - Dropdown */} +
+ +
+
+
+ + + + + +
-
- )} - -
- Example: {baseEmail.split('@')[0]}+your-tag@{baseEmail.split('@')[1]} -
-
- )} - - {/* Gmail Tricks Tab */} - {activeGeneratorTab === 'tricks' && ( -
- -
- )} -
-
- - {/* Recent Aliases */} - {(recentAliases.length > 0 || favorites.length > 0) && ( -
-
-

- {viewMode === 'all' ? 'Recent Aliases' : 'Favorites'} -

- - {viewMode === 'all' ? `${recentAliases.length} total` : `${favorites.length} starred`} - -
- - {/* View Mode Tabs */} -
- - -
+ setSearchQuery(e.target.value)} - placeholder="🔍 Search aliases..." - className="w-full pl-3 pr-8 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> - {searchQuery && ( - - )} -
- -
- - - -
-
+ // Update active account and base_email + const updated = emailAccounts.map((acc) => ({ + ...acc, + isActive: acc.email === selectedEmail, + })); -
- {(() => { - // Filter and sort aliases - const filteredAliases = recentAliases - .filter((alias) => { - // Filter by view mode - if (viewMode === 'favorites' && !favorites.includes(alias.email)) { - return false; - } - - // Filter by search query - if (searchQuery && !alias.email.toLowerCase().includes(searchQuery.toLowerCase())) { - return false; - } - - // Filter by tag - if (filterTag !== 'all') { - const tagMatch = alias.email.match(/\+([^@]+)@/); - const emailTag = tagMatch ? tagMatch[1] : null; - if (emailTag !== filterTag) { - return false; - } - } - - return true; - }) - .sort((a, b) => { - if (sortBy === 'recent') { - return b.timestamp - a.timestamp; - } else { - return a.email.localeCompare(b.email); - } - }); - - // Calculate pagination - const totalItems = filteredAliases.length; - const totalPages = Math.ceil(totalItems / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const endIndex = startIndex + itemsPerPage; - const paginatedAliases = filteredAliases.slice(startIndex, endIndex); - - // Empty state for favorites - if (filteredAliases.length === 0 && viewMode === 'favorites') { - return ( -
- - - -

No favorites yet

-

Star emails from your history to quick access them here

-
- ); - } - - // Render paginated list - return ( - <> - {paginatedAliases.map((alias) => ( -
- - {alias.email} - - -
+
+ +
+ + {/* Quick Add Account Form */} + {showAddAccount && ( +
+
+
- ) : ( +
+ { + setNewAccountEmail(e.target.value); + setAddAccountError(""); + }} + onBlur={() => { + if ( + newAccountEmail && + !newAccountEmail.includes("@") + ) { + setNewAccountEmail(`${newAccountEmail}@gmail.com`); + } + }} + placeholder={t("emailPlaceholder")} + className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + ref={focusOnMount} + /> + {newAccountEmail && !newAccountEmail.includes("@") && ( +
+ @gmail.com +
+ )} +
+ {addAccountError && ( +
+

+ {addAccountError} +

+
+ )} + {newAccountEmail && !newAccountEmail.includes("@") && ( +

+ {t("pressTabToAddGmail", "Tab").split("Tab")[0]} + + Tab + + {t("pressTabToAddGmail", "Tab").split("Tab")[1]} +

+ )} +
+
- )} - -
- ))} - - {/* Pagination Controls */} - {totalPages > 1 && ( -
-
- {/* Page info and items per page selector */} -
-
- Showing {startIndex + 1}-{Math.min(endIndex, totalItems)} of {totalItems} -
- -
- - {/* Page navigation */} -
- - -
- {Array.from({ length: totalPages }, (_, i) => i + 1) - .filter(page => { - // Show first, last, current, and pages around current - if (page === 1 || page === totalPages) return true; - if (Math.abs(page - currentPage) <= 1) return true; - return false; - }) - .map((page, index, array) => { - // Add ellipsis - const prevPage = array[index - 1]; - const showEllipsis = prevPage && page - prevPage > 1; - - return ( -
- {showEllipsis && ...} - -
- ); - })} -
- - -
-
- )} - - ); - })()} + setNewAccountLabel(e.target.value)} + placeholder={t("accountLabelPlaceholder")} + className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> +
+
+ + +
+
+ )} + + {baseEmail && + !baseEmail.includes("@gmail.com") && + baseEmail.includes("@") && ( +

+ {t("gmailWarning")} +

+ )} +
+ + {/* Unified Email Alias Generator */} + + + + + {/* Statistics - Collapsible */} +
-
- )} - {/* Statistics - Collapsible */} - + {/* Toast Notification */} + {toastMessage && ( +
+ {toastMessage} +
+ )} +
+ + )} - {/* Success Message */} - {copiedEmail && ( -
- ✓ Copied to clipboard! + {/* QR Code Modal */} + {qrAlias && ( +
setQrAlias(null)} + > +
e.stopPropagation()} + > +

+ {t("scanToCopyAlias")} +

+ +

+ {qrAlias} +

+
+ + +
- )} -
- +
)} {/* Settings Modal */} diff --git a/entrypoints/popup/components/Button.tsx b/entrypoints/popup/components/Button.tsx index 5e92219..e34179a 100644 --- a/entrypoints/popup/components/Button.tsx +++ b/entrypoints/popup/components/Button.tsx @@ -1,41 +1,47 @@ -import { ReactNode } from 'react'; +import { ReactNode } from "react"; interface ButtonProps { children: ReactNode; onClick?: () => void; - variant?: 'primary' | 'secondary' | 'danger' | 'success'; - size?: 'sm' | 'md' | 'lg'; + variant?: "primary" | "secondary" | "danger" | "success"; + size?: "sm" | "md" | "lg"; disabled?: boolean; fullWidth?: boolean; icon?: ReactNode; } +/** Styled button with variant, size, and optional icon support. */ export default function Button({ children, onClick, - variant = 'primary', - size = 'md', + variant = "primary", + size = "md", disabled = false, fullWidth = false, icon, }: ButtonProps) { - const baseClasses = 'font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'; - + const baseClasses = + "font-medium rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2"; + const variantClasses = { - primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', - secondary: 'bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500', - danger: 'bg-red-50 text-red-700 hover:bg-red-100 focus:ring-red-500', - success: 'bg-green-50 text-green-700 hover:bg-green-100 focus:ring-green-500', + primary: + "bg-gradient-to-r from-blue-600 to-purple-600 text-white hover:from-blue-700 hover:to-purple-700 focus:ring-blue-500", + secondary: + "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600", + danger: + "bg-red-50 text-red-700 hover:bg-red-100 focus:ring-red-500 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50", + success: + "bg-green-50 text-green-700 hover:bg-green-100 focus:ring-green-500 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50", }; const sizeClasses = { - sm: 'px-3 py-1.5 text-xs', - md: 'px-4 py-2 text-sm', - lg: 'px-6 py-3 text-base', + sm: "px-3 py-1.5 text-xs", + md: "px-4 py-2 text-sm", + lg: "px-6 py-3 text-base", }; - const widthClass = fullWidth ? 'w-full' : ''; - const disabledClass = disabled ? 'opacity-50 cursor-not-allowed' : ''; + const widthClass = fullWidth ? "w-full" : ""; + const disabledClass = disabled ? "opacity-50 cursor-not-allowed" : ""; return (
- - + + -

No favorites yet

-

Click ⭐ on any alias in history to add it here

+

{t("noFavoritesYet")}

+

+ Click ⭐ on any alias in history to add it here +

); @@ -77,14 +92,16 @@ export default function Favorites({ baseEmail, onCopy }: FavoritesProps) {

⭐ Favorites

- {favorites.length} saved + + {t("favoritesSaved", String(favorites.length))} +
{favorites.map((favorite) => { const tagMatch = favorite.email.match(/\+([^@]+)@/); - const tag = tagMatch ? tagMatch[1] : 'no-tag'; - + const tag = tagMatch ? tagMatch[1] : "no-tag"; + return (
- + diff --git a/entrypoints/popup/components/GeneratorTabs.tsx b/entrypoints/popup/components/GeneratorTabs.tsx new file mode 100644 index 0000000..7cc9fb5 --- /dev/null +++ b/entrypoints/popup/components/GeneratorTabs.tsx @@ -0,0 +1,432 @@ +import GmailTricks from "./GmailTricks"; +import { + generateAlias, + generateRandomString, + type RandomFormat, +} from "../utils"; +import { t } from "../../../lib/i18n"; + +interface Preset { + id: string; + label: string; + tag: string; +} + +interface GeneratorTabsProps { + baseEmail: string; + activeTab: "random" | "tags" | "tricks"; + setActiveTab: (tab: "random" | "tags" | "tricks") => void; + randomFormat: RandomFormat; + setRandomFormat: (format: RandomFormat) => void; + customTag: string; + setCustomTag: (tag: string) => void; + generatedRandomList: string[]; + setGeneratedRandomList: (list: string[]) => void; + randomEmailCount: number; + setRandomEmailCount: (count: number) => void; + customPresets: Preset[]; + showNotifications: boolean; + copyToClipboard: (email: string) => Promise; + handleCustomGenerate: () => void; + handleKeyPress: (e: React.KeyboardEvent) => void; + handlePresetClick: (tag: string) => void; + saveRecentAliases: (emails: string[]) => void; + setToastMessage: (msg: string | null) => void; +} + +/** Renders three alias generator tabs: random (formatted strings), custom tags, and Gmail tricks. */ +export default function GeneratorTabs({ + baseEmail, + activeTab, + setActiveTab, + randomFormat, + setRandomFormat, + customTag, + setCustomTag, + generatedRandomList, + setGeneratedRandomList, + randomEmailCount, + setRandomEmailCount, + customPresets, + showNotifications, + copyToClipboard, + handleCustomGenerate, + handleKeyPress, + handlePresetClick, + saveRecentAliases, + setToastMessage, +}: GeneratorTabsProps) { + /** Updates random format setting and persists to storage. */ + const handleFormatChange = async (newFormat: RandomFormat) => { + setRandomFormat(newFormat); + const result = await browser.storage.local.get("app_settings"); + const currentSettings = result.app_settings || {}; + await browser.storage.local.set({ + app_settings: { + ...currentSettings, + randomFormat: newFormat, + }, + }); + }; + + return ( +
+ {/* Main Tabs */} +
+ + + +
+ + {/* Tab Content */} +
+ {/* Random Tab */} + {activeTab === "random" && ( +
+ {/* Format Selector */} +
+ + +
+ + {/* Number of Emails */} +
+ + + setRandomEmailCount( + Math.max(1, parseInt(e.target.value) || 10), + ) + } + className="w-20 px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> +
+ + {/* Generate Button */} + + + {/* Generated Emails List */} + {generatedRandomList.length > 0 && ( + // skipcq: JS-0415 +
+
+
+ + {t("generatedAliases")} + +
+ + {t("totalCount", String(generatedRandomList.length))} + + +
+
+
+
+ {generatedRandomList.map((email) => ( +
+
+ {email} +
+ +
+ ))} +
+
+ )} + +
+ {randomFormat === "private-mail" + ? t("formatPrivateMail") + : randomFormat === "alphanumeric" + ? t("formatAlphanumeric") + : randomFormat === "words" + ? t("formatWords") + : t("formatTimestamp")} +
+
+ )} + + {/* Custom Tags Tab */} + {activeTab === "tags" && ( + // skipcq: JS-0415 +
+
+
+
+ + + +
+ setCustomTag(e.target.value)} + onKeyDown={handleKeyPress} + className="w-full pl-10 pr-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + placeholder={t("tagPlaceholder")} + /> +
+ +
+ + {/* Custom Presets - Quick Access */} + {customPresets.length > 0 && ( +
+
+ {t("yourPresets")} +
+
+ {customPresets.map((preset) => ( + + ))} +
+
+ )} + +
+ + {t("example")} {baseEmail.split("@")[0]}+ + + your-tag + + @{baseEmail.split("@")[1]} + + +
+
+ )} + + {/* Gmail Tricks Tab */} + {activeTab === "tricks" && ( +
+ +
+ )} +
+
+ ); +} diff --git a/entrypoints/popup/components/GmailTricks.tsx b/entrypoints/popup/components/GmailTricks.tsx index e6887d1..9d8bf13 100644 --- a/entrypoints/popup/components/GmailTricks.tsx +++ b/entrypoints/popup/components/GmailTricks.tsx @@ -1,176 +1,195 @@ -import { useState } from 'react'; +import { useState } from "react"; +import { getDotVariationCandidates } from "../utils"; interface GmailTricksProps { baseEmail: string; onCopy: (email: string) => void; } +/** Panel that generates Gmail trick variations (dots, plus tags, googlemail, combos) for the base email. */ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { - const [selectedTrick, setSelectedTrick] = useState<'dot' | 'googlemail' | 'nodots' | 'combo' | 'plus' | 'dotplus'>('dot'); + const [selectedTrick, setSelectedTrick] = useState< + "dot" | "googlemail" | "nodots" | "combo" | "plus" | "dotplus" + >("dot"); const [tricksCount, setTricksCount] = useState(10); const [generatedTricks, setGeneratedTricks] = useState([]); const [randomizeDots, setRandomizeDots] = useState(false); - const generateDotVariations = (username: string, count: number = 10): string[] => { - if (username.length < 2) return []; - - const variations: string[] = []; - - if (randomizeDots) { - // Random dot positions - truly random each time - for (let i = 0; i < count; i++) { - const chars = username.split(''); - const maxDots = Math.min(3, chars.length - 1); - const numDots = Math.floor(Math.random() * maxDots) + 1; - const positions = new Set(); - - // Generate truly random positions - while (positions.size < numDots) { - const pos = Math.floor(Math.random() * (chars.length - 1)) + 1; - positions.add(pos); - } - - // Insert dots at random positions - const sortedPositions = Array.from(positions).sort((a, b) => a - b); - let result = ''; - let lastPos = 0; - sortedPositions.forEach(pos => { - result += chars.slice(lastPos, pos).join('') + '.'; - lastPos = pos; - }); - result += chars.slice(lastPos).join(''); - - variations.push(result); - } - } else { - // Sequential dot positions (original behavior) - const len = username.length; - for (let i = 1; i < len; i++) { - variations.push(username.slice(0, i) + '.' + username.slice(i)); - } - - // Generate multiple dots - if (username.length >= 4) { - for (let i = 1; i < len - 1; i++) { - for (let j = i + 1; j < len; j++) { - variations.push( - username.slice(0, i) + '.' + - username.slice(i, j) + '.' + - username.slice(j) - ); - } - } - } - } - - return [...new Set(variations)].slice(0, count); - }; + /** Combines dot variations with common plus tags, capped at `count` results. */ + const generateCombinations = (count = 10): string[] => { + if (!baseEmail.includes("@")) return []; - const generateGooglemailVariation = (): string | null => { - if (!baseEmail.includes('@')) return null; - - const [username, domain] = baseEmail.split('@'); - if (domain === 'gmail.com') { - return `${username}@googlemail.com`; - } else if (domain === 'googlemail.com') { - return `${username}@gmail.com`; - } - return null; - }; + const [username, domain] = baseEmail.split("@"); + const normalizedDomain = domain.toLowerCase(); + const isGmail = + normalizedDomain === "gmail.com" || normalizedDomain === "googlemail.com"; + if (!isGmail) return []; - const generateCombinations = (count: number = 10): string[] => { - if (!baseEmail.includes('@')) return []; - - const [username, domain] = baseEmail.split('@'); - if (!domain.includes('gmail')) return []; - const combinations: string[] = []; - const dotVariations = generateDotVariations(username, count); - + const dotVariations = getDotVariationCandidates( + username, + count, + randomizeDots, + ); + // Dot + common tags - const commonTags = ['newsletter', 'shop', 'spam', 'work', 'personal', 'test', 'promo', 'social', 'finance', 'travel']; - dotVariations.forEach(dotUser => { - commonTags.forEach(tag => { + const commonTags = [ + "newsletter", + "shop", + "spam", + "work", + "personal", + "test", + "promo", + "social", + "finance", + "travel", + ]; + dotVariations.forEach((dotUser) => { + commonTags.forEach((tag) => { combinations.push(`${dotUser}+${tag}@${domain}`); }); }); - + return combinations.slice(0, count); }; - const generatePlusVariations = (count: number = 10): string[] => { - if (!baseEmail.includes('@')) return []; - - const [username, domain] = baseEmail.split('@'); + /** Generates plus-tag aliases from a list of common tags, capped at `count` results. */ + const generatePlusVariations = (count = 10): string[] => { + if (!baseEmail.includes("@")) return []; + + const [username, domain] = baseEmail.split("@"); const tags = [ - 'newsletter', 'shop', 'spam', 'work', 'personal', 'test', 'promo', - 'social', 'finance', 'travel', 'amazon', 'ebay', 'facebook', 'twitter', - 'linkedin', 'github', 'google', 'microsoft', 'apple', 'samsung', - 'newsletter1', 'newsletter2', 'deals', 'offers', 'alerts', 'updates', - 'notifications', 'receipts', 'invoices', 'subscriptions' + "newsletter", + "shop", + "spam", + "work", + "personal", + "test", + "promo", + "social", + "finance", + "travel", + "amazon", + "ebay", + "facebook", + "twitter", + "linkedin", + "github", + "google", + "microsoft", + "apple", + "samsung", + "newsletter1", + "newsletter2", + "deals", + "offers", + "alerts", + "updates", + "notifications", + "receipts", + "invoices", + "subscriptions", ]; - - return tags.slice(0, count).map(tag => `${username}+${tag}@${domain}`); + + return tags.slice(0, count).map((tag) => `${username}+${tag}@${domain}`); }; - const generateDotPlusVariations = (count: number = 10): string[] => { - if (!baseEmail.includes('@')) return []; - - const [username, domain] = baseEmail.split('@'); - const dotVars = generateDotVariations(username, Math.ceil(count / 3)); - const tags = ['shop', 'work', 'test', 'spam', 'newsletter', 'promo', 'social', 'finance']; + /** Combines dot variations with plus tags, capped at `count` results. */ + const generateDotPlusVariations = (count = 10): string[] => { + if (!baseEmail.includes("@")) return []; + + const [username, domain] = baseEmail.split("@"); + const dotVars = getDotVariationCandidates( + username, + Math.ceil(count / 3), + randomizeDots, + ); + const tags = [ + "shop", + "work", + "test", + "spam", + "newsletter", + "promo", + "social", + "finance", + ]; const results: string[] = []; - - dotVars.forEach(dotUser => { - tags.forEach(tag => { + + dotVars.forEach((dotUser) => { + tags.forEach((tag) => { results.push(`${dotUser}+${tag}@${domain}`); }); }); - + return results.slice(0, count); }; + /** Generates variations for the currently selected trick and copies the first result. */ const generateTricksVariations = () => { - if (!baseEmail.includes('@')) return; - + if (!baseEmail.includes("@")) return; + // Clear previous results first to force re-render setGeneratedTricks([]); - - const [username, domain] = baseEmail.split('@'); + + const [username, domain] = baseEmail.split("@"); let results: string[] = []; - + switch (selectedTrick) { - case 'dot': - results = generateDotVariations(username, tricksCount).map(u => `${u}@${domain}`); + case "dot": + results = getDotVariationCandidates( + username, + tricksCount, + randomizeDots, + ).map((u) => `${u}@${domain}`); break; - case 'googlemail': - const altDomain = domain === 'gmail.com' ? 'googlemail.com' : 'gmail.com'; - results = generateDotVariations(username, tricksCount).map(u => `${u}@${altDomain}`); + case "googlemail": { + const altDomain = + domain.toLowerCase() === "gmail.com" ? "googlemail.com" : "gmail.com"; + results = getDotVariationCandidates( + username, + tricksCount, + randomizeDots, + ).map((u) => `${u}@${altDomain}`); break; - case 'nodots': - const noDots = username.replace(/\./g, ''); + } + case "nodots": { + const noDots = username.replace(/\./g, ""); const noDotResults = [ `${noDots}@${domain}`, - `${noDots}@${domain === 'gmail.com' ? 'googlemail.com' : 'gmail.com'}`, + `${noDots}@${domain === "gmail.com" ? "googlemail.com" : "gmail.com"}`, ]; // Generate with plus tags too - const tags = ['work', 'shop', 'test', 'spam', 'newsletter', 'promo', 'social', 'finance']; - tags.forEach(tag => { + const tags = [ + "work", + "shop", + "test", + "spam", + "newsletter", + "promo", + "social", + "finance", + ]; + tags.forEach((tag) => { noDotResults.push(`${noDots}+${tag}@${domain}`); }); results = noDotResults.slice(0, tricksCount); break; - case 'plus': + } + case "plus": results = generatePlusVariations(tricksCount); break; - case 'dotplus': + case "dotplus": results = generateDotPlusVariations(tricksCount); break; - case 'combo': + case "combo": results = generateCombinations(tricksCount); break; + default: + break; } - + // Use setTimeout to ensure state update triggers re-render setTimeout(() => { setGeneratedTricks(results); @@ -180,74 +199,68 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { }, 0); }; - const removeDots = (): string | null => { - if (!baseEmail.includes('@')) return null; - - const [username, domain] = baseEmail.split('@'); - const noDots = username.replace(/\./g, ''); - return `${noDots}@${domain}`; - }; - + // skipcq: JS-0415 return ( + // skipcq: JS-0415
{/* Trick Type Selector */}
@@ -334,13 +381,22 @@ export default function GmailTricks({ baseEmail, onCopy }: GmailTricksProps) { )} {/* Info */} -
+
- - + + -

- Gmail trick: Dots are ignored & everything after + goes to same inbox +

+ Gmail trick: Dots are ignored & everything after + + goes to same inbox

diff --git a/entrypoints/popup/components/HistorySection.tsx b/entrypoints/popup/components/HistorySection.tsx new file mode 100644 index 0000000..a7c0c23 --- /dev/null +++ b/entrypoints/popup/components/HistorySection.tsx @@ -0,0 +1,649 @@ +/** Recent aliases list with search, filter, pagination, and bulk selection. */ +import { t } from "../../../lib/i18n"; + +interface Alias { + email: string; + timestamp: number; +} + +interface HistorySectionProps { + recentAliases: Alias[]; + favorites: string[]; + searchQuery: string; + setSearchQuery: (q: string) => void; + filterTag: string; + setFilterTag: (tag: string) => void; + sortBy: "recent" | "alphabetical"; + setSortBy: (sort: "recent" | "alphabetical") => void; + viewMode: "all" | "favorites"; + setViewMode: (mode: "all" | "favorites") => void; + currentPage: number; + setCurrentPage: (page: number) => void; + itemsPerPage: number; + setItemsPerPage: (count: number) => void; + isSelectMode: boolean; + setIsSelectMode: (on: boolean) => void; + selectedAliases: Set; + setSelectedAliases: (set: Set) => void; + copiedEmail: string | null; + filteredAliases: Alias[]; + exportAliases: (format: "csv" | "json") => void; + deleteSelected: () => void; + toggleSelectAlias: (email: string) => void; + toggleFavorite: (email: string) => void; + copyToClipboard: (email: string) => Promise; + setQrAlias: (email: string | null) => void; +} + +/** Recent aliases list with search, filter, pagination, and bulk selection. */ +export default function HistorySection({ + recentAliases, + favorites, + searchQuery, + setSearchQuery, + filterTag, + setFilterTag, + sortBy, + setSortBy, + viewMode, + setViewMode, + currentPage, + setCurrentPage, + itemsPerPage, + setItemsPerPage, + isSelectMode, + setIsSelectMode, + selectedAliases, + setSelectedAliases, + copiedEmail, + filteredAliases, + exportAliases, + deleteSelected, + toggleSelectAlias, + toggleFavorite, + copyToClipboard, + setQrAlias, +}: HistorySectionProps) { + if (recentAliases.length === 0 && favorites.length === 0) return null; + + // skipcq: JS-0415 + return ( + // skipcq: JS-0415 +
+ {/* Header with title and action buttons */} +
+

+ {viewMode === "all" ? t("recentAliases") : t("favorites")} +

+
+ {viewMode === "all" && recentAliases.length > 0 && ( + <> + + + + + )} + + {viewMode === "all" + ? t("totalCount", String(recentAliases.length)) + : t("starredCount", String(favorites.length))} + +
+
+ + {/* Bulk delete bar */} + {isSelectMode && ( +
+ + + {t("selectedCount", String(selectedAliases.size))} + + +
+ )} + + {/* View mode tabs */} +
+ + +
+ + {/* Search and filters */} +
+
+ setSearchQuery(e.target.value)} + placeholder={t("searchAliases")} + className="w-full pl-3 pr-8 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + /> + {searchQuery && ( + + )} +
+ +
+ + + +
+
+ + {/* Aliases list with pagination */} + +
+ ); +} + +/** Paginated list of aliases with copy, favorite, and QR actions. */ +function HistoryList({ + filteredAliases, + currentPage, + setCurrentPage, + itemsPerPage, + setItemsPerPage, + isSelectMode, + selectedAliases, + toggleSelectAlias, + copiedEmail, + favorites, + toggleFavorite, + copyToClipboard, + setQrAlias, + viewMode, +}: { + filteredAliases: Alias[]; + currentPage: number; + setCurrentPage: (page: number) => void; + itemsPerPage: number; + setItemsPerPage: (count: number) => void; + isSelectMode: boolean; + selectedAliases: Set; + toggleSelectAlias: (email: string) => void; + copiedEmail: string | null; + favorites: string[]; + toggleFavorite: (email: string) => void; + copyToClipboard: (email: string) => Promise; + setQrAlias: (email: string | null) => void; + viewMode: "all" | "favorites"; +}) { + const totalItems = filteredAliases.length; + const totalPages = Math.ceil(totalItems / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedAliases = filteredAliases.slice(startIndex, endIndex); + + if (filteredAliases.length === 0 && viewMode === "favorites") { + return ( +
+ + + +

{t("noFavoritesYet")}

+

{t("starEmailsHint")}

+
+ ); + } + + if (filteredAliases.length === 0) { + return ( +
+ + + +

{t("noResultsFound")}

+

{t("differentSearchHint")}

+
+ ); + } + + // skipcq: JS-0415 + return ( +
+ {paginatedAliases.map((alias) => ( + toggleSelectAlias(alias.email)} + isCopied={copiedEmail === alias.email} + isFavorited={favorites.includes(alias.email)} + onToggleFavorite={() => toggleFavorite(alias.email)} + onCopy={() => copyToClipboard(alias.email)} + onShowQR={() => setQrAlias(alias.email)} + /> + ))} + + {totalPages > 1 && ( + + )} +
+ ); +} + +/** Single alias row with action buttons. */ +function AliasRow({ + alias, + isSelectMode, + isSelected, + onToggleSelect, + isCopied, + isFavorited, + onToggleFavorite, + onCopy, + onShowQR, +}: { + alias: Alias; + isSelectMode: boolean; + isSelected: boolean; + onToggleSelect: () => void; + isCopied: boolean; + isFavorited: boolean; + onToggleFavorite: () => void; + onCopy: () => void; + onShowQR: () => void; +}) { + return ( +
+ {isSelectMode && ( + + )} + + {alias.email} + + + + +
+ ); +} + +/** Pagination controls. */ +function Pagination({ + currentPage, + setCurrentPage, + totalPages, + itemsPerPage, + setItemsPerPage, + startIndex, + endIndex, + totalItems, +}: { + currentPage: number; + setCurrentPage: (page: number) => void; + totalPages: number; + itemsPerPage: number; + setItemsPerPage: (count: number) => void; + startIndex: number; + endIndex: number; + totalItems: number; +}) { + // skipcq: JS-0415 + return ( + // skipcq: JS-0415 +
+
+ {/* Page info */} +
+
+ {t("showingRange", [ + String(startIndex + 1), + String(Math.min(endIndex, totalItems)), + String(totalItems), + ])} +
+ +
+ + {/* Navigation buttons */} +
+ + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter( + (page) => + page === 1 || + page === totalPages || + Math.abs(page - currentPage) <= 1, + ) + .map((page, index, array) => { + const prevPage = array[index - 1]; + const showEllipsis = prevPage && page - prevPage > 1; + + return ( +
+ {showEllipsis && ( + + ... + + )} + +
+ ); + })} +
+ + +
+
+
+ ); +} diff --git a/entrypoints/popup/components/Input.tsx b/entrypoints/popup/components/Input.tsx index f16b37a..7e5b5b7 100644 --- a/entrypoints/popup/components/Input.tsx +++ b/entrypoints/popup/components/Input.tsx @@ -1,5 +1,5 @@ interface InputProps { - type?: 'text' | 'email' | 'number'; + type?: "text" | "email" | "number"; value: string | number; onChange: (value: string) => void; placeholder?: string; @@ -8,8 +8,9 @@ interface InputProps { onKeyPress?: (e: React.KeyboardEvent) => void; } +/** Styled text input with optional label. */ export default function Input({ - type = 'text', + type = "text", value, onChange, placeholder, @@ -20,7 +21,7 @@ export default function Input({ return (
{label && ( -
); diff --git a/entrypoints/popup/components/KeyboardShortcuts.tsx b/entrypoints/popup/components/KeyboardShortcuts.tsx index a2074e7..4d154c7 100644 --- a/entrypoints/popup/components/KeyboardShortcuts.tsx +++ b/entrypoints/popup/components/KeyboardShortcuts.tsx @@ -1,12 +1,17 @@ -import { useState } from 'react'; +import { useState } from "react"; +/** Button that opens a modal listing available keyboard shortcuts. */ export default function KeyboardShortcuts() { const [isOpen, setIsOpen] = useState(false); const shortcuts = [ - { key: 'Enter', description: 'Generate alias with custom tag', context: 'In custom tag input' }, - { key: 'Ctrl/Cmd + K', description: 'Open settings', context: 'Anywhere' }, - { key: 'Esc', description: 'Close settings', context: 'In settings' }, + { + key: "Enter", + description: "Generate alias with custom tag", + context: "In custom tag input", + }, + { key: "Ctrl/Cmd + K", description: "Open settings", context: "Anywhere" }, + { key: "Esc", description: "Close settings", context: "In settings" }, ]; return ( @@ -15,31 +20,70 @@ export default function KeyboardShortcuts() { onClick={() => setIsOpen(true)} className="text-xs text-gray-500 hover:text-gray-700 flex items-center gap-1" > - - + + Keyboard Shortcuts {isOpen && ( -
setIsOpen(false)}> -
e.stopPropagation()}> + // skipcq: JS-0415 +
setIsOpen(false)} + > +
e.stopPropagation()} + >
-

Keyboard Shortcuts

-
- {shortcuts.map((shortcut, index) => ( -
+ {shortcuts.map((shortcut) => ( +
-
{shortcut.description}
-
{shortcut.context}
+
+ {shortcut.description} +
+
+ {shortcut.context} +
{shortcut.key} diff --git a/entrypoints/popup/components/Settings.tsx b/entrypoints/popup/components/Settings.tsx index 40cfdce..18c232b 100644 --- a/entrypoints/popup/components/Settings.tsx +++ b/entrypoints/popup/components/Settings.tsx @@ -1,7 +1,9 @@ -import { useState, useEffect } from 'react'; -import Toggle from './Toggle'; -import Button from './Button'; -import Input from './Input'; +import { useState, useEffect, useCallback } from "react"; +import Toggle from "./Toggle"; +import Button from "./Button"; +import Input from "./Input"; +import { getAccountStorageKey } from "../utils"; +import { t } from "../../../lib/i18n"; interface SettingsProps { isOpen: boolean; @@ -25,79 +27,166 @@ interface EmailAccount { interface AppSettings { customPresets: CustomPreset[]; maxHistory: number; - theme: 'light' | 'dark' | 'auto'; + theme: "light" | "dark" | "auto"; showNotifications: boolean; - badgeDisplay: 'none' | 'total' | 'today' | 'week' | 'all-time'; - randomFormat: 'private-mail' | 'alphanumeric' | 'words' | 'timestamp'; + badgeDisplay: "none" | "total" | "today" | "week" | "all-time"; + randomFormat: "private-mail" | "alphanumeric" | "words" | "timestamp"; +} + +interface ConfirmationRequest { + title: string; + message: string; + confirmLabel: string; + variant?: "primary" | "danger"; + resolve: (confirmed: boolean) => void; } const DEFAULT_SETTINGS: AppSettings = { customPresets: [], maxHistory: 20, - theme: 'light', + theme: "light", showNotifications: true, - badgeDisplay: 'all-time', - randomFormat: 'private-mail', -}; - -// Helper to get account-specific storage key -const getAccountStorageKey = (email: string, suffix: string) => { - const sanitized = email.replace(/[^a-zA-Z0-9]/g, '_'); - return `${suffix}_${sanitized}`; + badgeDisplay: "all-time", + randomFormat: "private-mail", }; -export default function Settings({ isOpen, onClose, onClearHistory }: SettingsProps) { +const CHANGELOG: { + version: string; + date: string; + changes: { type: "Added" | "Changed" | "Fixed"; items: string[] }[]; +}[] = [ + { + version: "1.2.0", + date: "2026-07-01", + changes: [ + { + type: "Changed", + items: [ + "Redesigned popup and settings UI with a unified card layout", + "Fixed popup height so only content scrolls, not the whole page", + ], + }, + { + type: "Fixed", + items: [ + '"Copy All" no longer undercounts statistics for generated aliases', + "Settings/QR modals no longer render outside the popup bounds", + "Tab key now moves focus normally instead of being hijacked for @gmail.com autocomplete", + ], + }, + ], + }, + { + version: "1.1.0", + date: "2025-12-30", + changes: [ + { + type: "Added", + items: [ + "Gmail alias generation with plus addressing", + "Preset management", + "Keyboard shortcuts", + "Statistics tracking", + ], + }, + { type: "Changed", items: ["Updated dependencies"] }, + { type: "Fixed", items: ["Bug fixes and improvements"] }, + ], + }, + { + version: "1.0.0", + date: "2025-12-30", + changes: [{ type: "Added", items: ["Initial release"] }], + }, +]; + +/** Settings modal with general, accounts, presets, advanced, and changelog tabs. */ +export default function Settings({ + isOpen, + onClose, + onClearHistory, +}: SettingsProps) { const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [newPresetLabel, setNewPresetLabel] = useState(''); - const [newPresetTag, setNewPresetTag] = useState(''); - const [activeTab, setActiveTab] = useState<'general' | 'accounts' | 'presets' | 'advanced'>('general'); + const [newPresetLabel, setNewPresetLabel] = useState(""); + const [newPresetTag, setNewPresetTag] = useState(""); + const [activeTab, setActiveTab] = useState< + "general" | "accounts" | "presets" | "advanced" | "changelog" + >("general"); const [emailAccounts, setEmailAccounts] = useState([]); const [editingAccountId, setEditingAccountId] = useState(null); - const [editingLabel, setEditingLabel] = useState(''); - const [editingEmail, setEditingEmail] = useState(''); - const [version, setVersion] = useState('1.1.0'); + const [editingLabel, setEditingLabel] = useState(""); + const [editingEmail, setEditingEmail] = useState(""); + const [version, setVersion] = useState("1.1.0"); const [showAddAccount, setShowAddAccount] = useState(false); - const [newAccountEmail, setNewAccountEmail] = useState(''); - const [newAccountLabel, setNewAccountLabel] = useState(''); - const [addAccountError, setAddAccountError] = useState(''); + const [newAccountEmail, setNewAccountEmail] = useState(""); + const [newAccountLabel, setNewAccountLabel] = useState(""); + const [addAccountError, setAddAccountError] = useState(""); + const [toast, setToast] = useState(null); + const [confirmation, setConfirmation] = useState( + null, + ); + + const showToast = useCallback((msg: string) => { + setToast(msg); + setTimeout(() => setToast(null), 2000); + }, []); + + const askForConfirmation = useCallback( + (request: Omit) => + new Promise((resolve) => { + setConfirmation({ ...request, resolve }); + }), + [], + ); + + const closeConfirmation = useCallback((confirmed: boolean) => { + setConfirmation((current) => { + current?.resolve(confirmed); + return null; + }); + }, []); useEffect(() => { try { const manifest = browser.runtime.getManifest(); - if (manifest && manifest.version) { + if (manifest?.version) { setVersion(manifest.version); } } catch (error) { - console.log('Could not get manifest version:', error); + console.log("Could not get manifest version:", error); } }, []); - useEffect(() => { - if (isOpen) { - loadSettings(); - loadAccounts(); - } - }, [isOpen]); - + /** Loads saved app settings from extension storage, merged over defaults. */ const loadSettings = async () => { - const result = await browser.storage.local.get('app_settings'); + const result = await browser.storage.local.get("app_settings"); if (result.app_settings) { setSettings({ ...DEFAULT_SETTINGS, ...result.app_settings }); } }; + /** Loads the email accounts list from extension storage. */ const loadAccounts = async () => { - const result = await browser.storage.local.get('email_accounts'); + const result = await browser.storage.local.get("email_accounts"); if (result.email_accounts && Array.isArray(result.email_accounts)) { setEmailAccounts(result.email_accounts); } }; + useEffect(() => { + if (isOpen) { + loadSettings(); + loadAccounts(); + } + }, [isOpen]); + + /** Persists settings to extension storage and updates local state. */ const saveSettings = async (newSettings: AppSettings) => { setSettings(newSettings); await browser.storage.local.set({ app_settings: newSettings }); }; + /** Adds a new custom preset from the label/tag form fields. */ const handleAddPreset = () => { if (!newPresetLabel.trim() || !newPresetTag.trim()) return; @@ -113,33 +202,39 @@ export default function Settings({ isOpen, onClose, onClearHistory }: SettingsPr }; saveSettings(updatedSettings); - setNewPresetLabel(''); - setNewPresetTag(''); + setNewPresetLabel(""); + setNewPresetTag(""); + showToast(t("toastPresetAdded")); }; + /** Removes a custom preset by id. */ const handleRemovePreset = (id: string) => { const updatedSettings = { ...settings, customPresets: settings.customPresets.filter((p) => p.id !== id), }; saveSettings(updatedSettings); + showToast(t("toastPresetRemoved")); }; + /** Downloads the current settings as a JSON file. */ const handleExportSettings = () => { const dataStr = JSON.stringify(settings, null, 2); - const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const dataBlob = new Blob([dataStr], { type: "application/json" }); const url = URL.createObjectURL(dataBlob); - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = url; link.download = `gmail-alias-settings-${Date.now()}.json`; link.click(); URL.revokeObjectURL(url); + showToast(t("toastSettingsExported")); }; + /** Imports settings from a user-selected JSON file. */ const handleImportSettings = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.json'; + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; @@ -148,51 +243,96 @@ export default function Settings({ isOpen, onClose, onClearHistory }: SettingsPr const text = await file.text(); const imported = JSON.parse(text); saveSettings({ ...DEFAULT_SETTINGS, ...imported }); - } catch (err) { - alert('Failed to import settings. Please check the file format.'); + showToast(t("toastSettingsImported")); + } catch { + showToast(t("toastImportFailed")); } }; input.click(); }; - const handleResetSettings = () => { - if (confirm('Are you sure you want to reset all settings to default?')) { - saveSettings(DEFAULT_SETTINGS); + /** Focuses an input once when it mounts (replaces autoFocus). */ + const focusOnMount = useCallback((el: HTMLInputElement | null) => { + el?.focus(); + }, []); + + /** Resets all settings to defaults after user confirmation. */ + const handleResetSettings = async () => { + const confirmed = await askForConfirmation({ + title: t("resetSettingsTitle"), + message: t("resetSettingsMessage"), + confirmLabel: t("reset"), + variant: "danger", + }); + + if (!confirmed) return; + + saveSettings(DEFAULT_SETTINGS); + showToast(t("toastSettingsReset")); + }; + + /** Clears recent aliases after user confirmation. */ + const handleClearHistory = async () => { + const confirmed = await askForConfirmation({ + title: t("clearHistoryTitle"), + message: t("clearHistoryMessage"), + confirmLabel: t("clear"), + variant: "danger", + }); + + if (confirmed) { + onClearHistory(); } }; + /** Marks the given account as active and updates the base email. */ const handleSwitchAccount = async (accountId: string) => { - const updated = emailAccounts.map(acc => ({ + const updated = emailAccounts.map((acc) => ({ ...acc, isActive: acc.id === accountId, })); await browser.storage.local.set({ email_accounts: updated }); - const activeAccount = updated.find(acc => acc.id === accountId); + const activeAccount = updated.find((acc) => acc.id === accountId); if (activeAccount) { await browser.storage.local.set({ base_email: activeAccount.email }); } setEmailAccounts(updated); + showToast(t("toastAccountSwitched")); }; + /** Deletes an account and all of its stored data after confirmation. */ const handleDeleteAccount = async (account: EmailAccount) => { if (emailAccounts.length === 1) { - alert('Cannot delete the last account. You must have at least one account.'); + showToast(t("cannotDeleteLastAccountTitle")); return; } - const confirmMsg = `Delete "${account.label}" (${account.email})?\n\nThis will permanently delete:\n• All history for this account\n• All statistics\n• All favorites\n\nThis action cannot be undone.`; - - if (!confirm(confirmMsg)) return; + const confirmMsg = t("deleteAccountMessage", [ + account.label, + account.email, + ]); + + const confirmed = await askForConfirmation({ + title: t("deleteAccountTitle"), + message: confirmMsg, + confirmLabel: t("delete"), + variant: "danger", + }); + + if (!confirmed) return; // Delete account-specific data - const historyKey = getAccountStorageKey(account.email, 'gmail_alias_recent'); - const statsKey = getAccountStorageKey(account.email, 'alias_stats'); - const favoritesKey = getAccountStorageKey(account.email, 'favorites'); + const historyKey = getAccountStorageKey( + account.email, + "gmail_alias_recent", + ); + const statsKey = getAccountStorageKey(account.email, "alias_stats"); + const favoritesKey = getAccountStorageKey(account.email, "favorites"); await browser.storage.local.remove([historyKey, statsKey, favoritesKey]); // Remove from accounts list - let updated = emailAccounts.filter(acc => acc.id !== account.id); + let updated = emailAccounts.filter((acc) => acc.id !== account.id); // If we deleted the active account, make the first one active if (account.isActive && updated.length > 0) { @@ -205,32 +345,36 @@ export default function Settings({ isOpen, onClose, onClearHistory }: SettingsPr await browser.storage.local.set({ email_accounts: updated }); setEmailAccounts(updated); + showToast(t("toastAccountDeleted")); }; + /** Enters edit mode for the given account. */ const handleStartEdit = (account: EmailAccount) => { setEditingAccountId(account.id); setEditingLabel(account.label); setEditingEmail(account.email); }; + /** Exits edit mode without saving. */ const handleCancelEdit = () => { setEditingAccountId(null); - setEditingLabel(''); - setEditingEmail(''); + setEditingLabel(""); + setEditingEmail(""); }; + /** Saves account edits, migrating stored data if the email changed. */ const handleSaveEdit = async (accountId: string) => { if (!editingLabel.trim()) { - alert('Label cannot be empty'); + showToast(t("errorLabelRequired")); return; } - if (!editingEmail.trim() || !editingEmail.includes('@')) { - alert('Please enter a valid email address'); + if (!editingEmail.trim() || !editingEmail.includes("@")) { + showToast(t("errorInvalidEmail")); return; } - const account = emailAccounts.find(acc => acc.id === accountId); + const account = emailAccounts.find((acc) => acc.id === accountId); if (!account) return; const oldEmail = account.email; @@ -239,27 +383,47 @@ export default function Settings({ isOpen, onClose, onClearHistory }: SettingsPr // Check if email changed if (oldEmail !== newEmail) { // Check if new email already exists in another account - const emailExists = emailAccounts.some(acc => acc.id !== accountId && acc.email === newEmail); + const emailExists = emailAccounts.some( + (acc) => + acc.id !== accountId && + acc.email.toLowerCase() === newEmail.toLowerCase(), + ); if (emailExists) { - alert('This email address is already used by another account!'); + showToast(t("errorDuplicateEmail")); return; } - const confirmMsg = `Change email from\n${oldEmail}\nto\n${newEmail}?\n\nThis will:\n• Migrate all history, statistics, and favorites to the new email\n• Update the account email\n• Delete data associated with the old email\n\nContinue?`; - - if (!confirm(confirmMsg)) return; + const confirmMsg = t("changeAccountEmailMessage", [oldEmail, newEmail]); - // Migrate data from old email to new email - const oldHistoryKey = getAccountStorageKey(oldEmail, 'gmail_alias_recent'); - const oldStatsKey = getAccountStorageKey(oldEmail, 'alias_stats'); - const oldFavoritesKey = getAccountStorageKey(oldEmail, 'favorites'); + const confirmed = await askForConfirmation({ + title: t("changeAccountEmailTitle"), + message: confirmMsg, + confirmLabel: t("changeEmail"), + }); - const newHistoryKey = getAccountStorageKey(newEmail, 'gmail_alias_recent'); - const newStatsKey = getAccountStorageKey(newEmail, 'alias_stats'); - const newFavoritesKey = getAccountStorageKey(newEmail, 'favorites'); + if (!confirmed) return; + + // Migrate data from old email to new email + const oldHistoryKey = getAccountStorageKey( + oldEmail, + "gmail_alias_recent", + ); + const oldStatsKey = getAccountStorageKey(oldEmail, "alias_stats"); + const oldFavoritesKey = getAccountStorageKey(oldEmail, "favorites"); + + const newHistoryKey = getAccountStorageKey( + newEmail, + "gmail_alias_recent", + ); + const newStatsKey = getAccountStorageKey(newEmail, "alias_stats"); + const newFavoritesKey = getAccountStorageKey(newEmail, "favorites"); // Get old data - const oldData = await browser.storage.local.get([oldHistoryKey, oldStatsKey, oldFavoritesKey]); + const oldData = await browser.storage.local.get([ + oldHistoryKey, + oldStatsKey, + oldFavoritesKey, + ]); // Save to new keys await browser.storage.local.set({ @@ -269,7 +433,11 @@ export default function Settings({ isOpen, onClose, onClearHistory }: SettingsPr }); // Delete old keys - await browser.storage.local.remove([oldHistoryKey, oldStatsKey, oldFavoritesKey]); + await browser.storage.local.remove([ + oldHistoryKey, + oldStatsKey, + oldFavoritesKey, + ]); // Update base_email if this is the active account if (account.isActive) { @@ -278,374 +446,524 @@ export default function Settings({ isOpen, onClose, onClearHistory }: SettingsPr } // Update account in list - const updated = emailAccounts.map(acc => - acc.id === accountId ? { ...acc, label: editingLabel.trim(), email: editingEmail.trim() } : acc + const updated = emailAccounts.map((acc) => + acc.id === accountId + ? { ...acc, label: editingLabel.trim(), email: editingEmail.trim() } + : acc, ); - + await browser.storage.local.set({ email_accounts: updated }); setEmailAccounts(updated); setEditingAccountId(null); - setEditingLabel(''); - setEditingEmail(''); + setEditingLabel(""); + setEditingEmail(""); + showToast(t("toastAccountUpdated")); }; + /** Validates and adds a new email account. */ const handleAddAccount = async () => { let email = newAccountEmail.trim(); - + if (!email) { - setAddAccountError('Please enter an email address'); + setAddAccountError(t("errorEnterEmail")); return; } // Auto-add @gmail.com if only username provided - if (!email.includes('@')) { - email += '@gmail.com'; + if (!email.includes("@")) { + email += "@gmail.com"; } // Validate email format - if (!email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { - setAddAccountError('Please enter a valid email address'); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setAddAccountError(t("errorInvalidEmail")); return; } // Check if account already exists - const exists = emailAccounts.some(acc => acc.email === email); + const exists = emailAccounts.some((acc) => acc.email === email); if (exists) { - setAddAccountError('This account already exists'); + setAddAccountError(t("errorAccountExists")); return; } - // Create new account + // Create new account — only auto-activate if it's the first account + const isFirst = emailAccounts.length === 0; const newAccount: EmailAccount = { id: Date.now().toString(), email, - label: newAccountLabel.trim() || email.split('@')[0], - isActive: emailAccounts.length === 0, // Make first account active + label: newAccountLabel.trim() || email.split("@")[0], + isActive: isFirst, }; - const updated = emailAccounts.length === 0 - ? [newAccount] - : [...emailAccounts.map(acc => ({ ...acc, isActive: false })), newAccount]; + const updated = isFirst ? [newAccount] : [...emailAccounts, newAccount]; - // Make new account active - updated[updated.length - 1].isActive = true; - - await browser.storage.local.set({ + await browser.storage.local.set({ email_accounts: updated, - base_email: newAccount.email + ...(isFirst ? { base_email: newAccount.email } : {}), }); setEmailAccounts(updated); setShowAddAccount(false); - setNewAccountEmail(''); - setNewAccountLabel(''); - setAddAccountError(''); + setNewAccountEmail(""); + setNewAccountLabel(""); + setAddAccountError(""); + showToast(t("toastAccountAdded", newAccount.label)); }; if (!isOpen) return null; + // skipcq: JS-0415 return ( -
-
+ // skipcq: JS-0415 +
+
{/* Header */} -
-
- - - - -

Settings

+
+
+ +

{t("settings")}

{/* Tabs */} -
+
+
{/* Content */} -
+
{/* General Tab */} - {activeTab === 'general' && ( -
- {/* Appearance Section */} -
-

- - - - Appearance & Display -

-
-
- - -
- -
- -

Display count on extension icon

- -
+ {activeTab === "general" && ( + // skipcq: JS-0415 +
+
+ {/* Appearance Section */} +
+

+ + + + + + {t("appearanceDisplay")} +

+
+
+ + +
-
- -

Copy confirmation messages

+ + +
+ +
+ + + saveSettings({ + ...settings, + showNotifications: enabled, + }) + } + label="" + />
- saveSettings({ ...settings, showNotifications: enabled })} - label="" - />
-
- {/* Alias Generation Section */} -
-

- - - - Alias Generation -

-
-
- - -

Choose the format for random alias generation

-
+ {/* Alias Generation Section */} +
+

+ + + + + + {t("aliasGeneration")} +

+
+
+ + +
-
- - -

Maximum number of aliases to auto-save to history

+
+ + +
-
- {/* Custom Presets Section */} -
-

- - - - Custom Presets -

-
- - - -
+ {/* Custom Presets Section */} +
+

+ + + + + + {t("customPresets")} +

+
+
+ + +
+ +
- {settings.customPresets.length > 0 && ( -
-
Your Presets ({settings.customPresets.length})
-
+ {settings.customPresets.length > 0 && ( +
{settings.customPresets.map((preset) => (
-
-
{preset.label}
-
+{preset.tag}
+
+ + {preset.label} + + + +{preset.tag} +
))}
-
- )} -
+ )} +
- {/* Data Management Section */} -
-

- - - - Data Management -

-
-
+ {/* Data Management Section */} +
+

+ + + + + + {t("dataManagement")} +

+
+
- + {t("resetSettings")} +
- - {/* Danger Zone */} -
-

- - - - Danger Zone -

-

This action cannot be undone

- -
)} {/* Accounts Tab */} - {activeTab === 'accounts' && ( + {activeTab === "accounts" && (
-

Email Accounts

-

- Manage your Gmail accounts. Each account has its own history, statistics, and favorites. +

+ {t("emailAccounts")} +

+

+ {t("manageAccountsDescription")}

{emailAccounts.length === 0 ? ( -
- No accounts found. Please add an account from the main screen. +
+ {t("noAccountsFound")}
) : (
@@ -654,36 +972,40 @@ export default function Settings({ isOpen, onClose, onClearHistory }: SettingsPr key={account.id} className={`rounded-lg border-2 transition-all ${ account.isActive - ? 'border-blue-500 bg-blue-50' - : 'border-gray-200 bg-white hover:border-gray-300' + ? "border-blue-500 bg-blue-50 dark:bg-blue-950/40" + : "border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600" }`} > {editingAccountId === account.id ? ( // Edit mode
- + setEditingLabel(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - placeholder="Account label" - autoFocus + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + placeholder={t("accountLabel")} + ref={focusOnMount} />
- + setEditingEmail(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono" - placeholder="your.email@gmail.com" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100" + placeholder={t("emailAddressPlaceholder")} /> {editingEmail !== account.email && ( -

- ⚠️ Changing email will migrate all data to the new email address +

+ {t("emailChangeWarning")}

)}
@@ -692,18 +1014,19 @@ export default function Settings({ isOpen, onClose, onClearHistory }: SettingsPr onClick={() => handleSaveEdit(account.id)} className="flex-1 px-3 py-1.5 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700 transition-colors" > - Save Changes + {t("saveChanges")}
) : ( // View mode + // skipcq: JS-0415
{/* Radio button to select active account */}