From a3eb2ba93c96495c32f04541cf2ce1b735be1676 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Oickle Date: Thu, 12 Feb 2026 11:57:20 -0800 Subject: [PATCH 1/9] Added ability to hide emails --- .../repositories/chrome_local_storage_repo.ts | 89 +++++++++ .../repositories/mocks/mock_storage_repo.ts | 30 +++ src/domain/repositories/storage_repo.ts | 23 +++ src/presentation/apps/sidebar/App.css | 21 ++ src/presentation/apps/sidebar/App.tsx | 19 +- .../apps/sidebar/components/actionButton.css | 9 + .../apps/sidebar/components/actionButton.tsx | 55 ++++-- .../apps/sidebar/components/header.css | 28 +++ .../apps/sidebar/components/header.tsx | 22 ++- .../apps/sidebar/components/reloadButton.css | 7 +- .../apps/sidebar/components/senderLine.css | 6 + .../apps/sidebar/components/senderLine.tsx | 8 +- .../apps/sidebar/components/settingsModal.css | 182 ++++++++++++++++++ .../apps/sidebar/components/settingsModal.tsx | 110 +++++++++++ .../apps/sidebar/providers/modalContext.tsx | 2 +- src/presentation/providers/app_provider.tsx | 74 +++++-- test/ui/sidebar/helpers.ts | 4 +- test/ui/sidebar/hideSenders.spec.ts | 178 +++++++++++++++++ 18 files changed, 818 insertions(+), 49 deletions(-) create mode 100644 src/presentation/apps/sidebar/components/settingsModal.css create mode 100644 src/presentation/apps/sidebar/components/settingsModal.tsx create mode 100644 test/ui/sidebar/hideSenders.spec.ts diff --git a/src/data/repositories/chrome_local_storage_repo.ts b/src/data/repositories/chrome_local_storage_repo.ts index c4a4450..443f993 100644 --- a/src/data/repositories/chrome_local_storage_repo.ts +++ b/src/data/repositories/chrome_local_storage_repo.ts @@ -45,4 +45,93 @@ export class ChromeLocalStorageRepo implements StorageRepo { }); }); } + + async storeHiddenSenders( + emails: string[], + accountEmail: string, + ): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.get([accountEmail], (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + const accountData = result[accountEmail] || { senders: [] }; + const currentHidden = accountData.hiddenSenderEmails || []; + const updatedHidden = Array.from( + new Set([...currentHidden, ...emails]), + ); + + chrome.storage.local.set( + { + [accountEmail]: { + ...accountData, + hiddenSenderEmails: updatedHidden, + }, + }, + () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + console.log("Updated hidden senders in local storage."); + resolve(); + }, + ); + }); + }); + } + + async readHiddenSenders(accountEmail: string): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.get([accountEmail], (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + const hiddenSenders = + result[accountEmail]?.hiddenSenderEmails || []; + resolve(hiddenSenders); + }); + }); + } + + async removeHiddenSenders( + emails: string[], + accountEmail: string, + ): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.get([accountEmail], (result) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + + const accountData = result[accountEmail] || { senders: [] }; + const currentHidden = accountData.hiddenSenderEmails || []; + const updatedHidden = currentHidden.filter( + (email: string) => !emails.includes(email), + ); + + chrome.storage.local.set( + { + [accountEmail]: { + ...accountData, + hiddenSenderEmails: updatedHidden, + }, + }, + () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + return; + } + console.log("Removed hidden senders from local storage."); + resolve(); + }, + ); + }); + }); + } } diff --git a/src/data/repositories/mocks/mock_storage_repo.ts b/src/data/repositories/mocks/mock_storage_repo.ts index 4370e3b..63fe44f 100644 --- a/src/data/repositories/mocks/mock_storage_repo.ts +++ b/src/data/repositories/mocks/mock_storage_repo.ts @@ -3,6 +3,7 @@ import { Sender } from "../../../domain/entities/sender"; export class MockStorageRepo implements StorageRepo { private mockSenders: Sender[] = []; + private mockHiddenSenders: string[] = []; constructor(initialSenders: Sender[] = this.mockSenders) { this.mockSenders = initialSenders; @@ -34,4 +35,33 @@ export class MockStorageRepo implements StorageRepo { }); return Promise.resolve(); } + + storeHiddenSenders(emails: string[], accountEmail: string): Promise { + console.log(`[MOCK] Storing hidden senders for account: ${accountEmail}`); + emails.forEach((email) => { + console.log(`[MOCK] Hiding sender: ${email}`); + }); + this.mockHiddenSenders = Array.from( + new Set([...this.mockHiddenSenders, ...emails]), + ); + return Promise.resolve(); + } + + readHiddenSenders(accountEmail: string): Promise { + console.log(`[MOCK] Reading hidden senders for account: ${accountEmail}`); + return Promise.resolve(this.mockHiddenSenders); + } + + removeHiddenSenders(emails: string[], accountEmail: string): Promise { + console.log( + `[MOCK] Removing hidden senders for account: ${accountEmail}`, + ); + emails.forEach((email) => { + console.log(`[MOCK] Unhiding sender: ${email}`); + }); + this.mockHiddenSenders = this.mockHiddenSenders.filter( + (email) => !emails.includes(email), + ); + return Promise.resolve(); + } } diff --git a/src/domain/repositories/storage_repo.ts b/src/domain/repositories/storage_repo.ts index 8ed3a88..30ac0e2 100644 --- a/src/domain/repositories/storage_repo.ts +++ b/src/domain/repositories/storage_repo.ts @@ -23,4 +23,27 @@ export interface StorageRepo { * @param accountEmail - The email address of the account to associate the deletion with. */ deleteSenders(senderEmails: string[], accountEmail: string): Promise; + + /** + * Stores a list of hidden sender emails for a specific account. + * + * @param emails - An array of email addresses to hide. + * @param accountEmail - The email address of the account to associate the hidden senders with. + */ + storeHiddenSenders(emails: string[], accountEmail: string): Promise; + + /** + * Retrieves the list of hidden sender emails for a specific account. + * + * @param accountEmail - The email address of the account to retrieve hidden senders for. + */ + readHiddenSenders(accountEmail: string): Promise; + + /** + * Removes sender emails from the hidden list for a specific account. + * + * @param emails - An array of email addresses to unhide. + * @param accountEmail - The email address of the account to associate the unhiding with. + */ + removeHiddenSenders(emails: string[], accountEmail: string): Promise; } diff --git a/src/presentation/apps/sidebar/App.css b/src/presentation/apps/sidebar/App.css index c6f86c6..8df95b1 100644 --- a/src/presentation/apps/sidebar/App.css +++ b/src/presentation/apps/sidebar/App.css @@ -38,10 +38,13 @@ :root, .light { --bg-primary: #fff; + --bg-secondary: #f8f9fa; + --bg-hover: #e8eaed; --text-primary: #333; --text-secondary: #5f6368; --selected-bg: #c2dbff; --button-bg: #e9e9e9; + --button-hover: #d8d8d8; --border-color: #c4c4c4; --line-color: #f2f6fc; --plain-button: #000; @@ -54,10 +57,13 @@ .dark { --bg-primary: #1e1e1e; + --bg-secondary: #2a2a2a; + --bg-hover: #353535; --text-primary: #f0f0f0; --text-secondary: #b0b3b8; --selected-bg: #34527a; --button-bg: #3a3a3a; + --button-hover: #4a4a4a; --border-color: #444; --line-color: #555; --plain-button: #555; @@ -113,3 +119,18 @@ overflow: hidden auto; flex-grow: 1; } + +.icon-button { + border: none; + background-color: var(--bg-primary); + color: var(--text-primary); + box-shadow: none; + cursor: pointer; + font-size: 18px; + padding: 4px 8px; +} + +.icon-button:hover { + transform: scale(1.2); + transition: transform 0.2s ease-in-out; +} diff --git a/src/presentation/apps/sidebar/App.tsx b/src/presentation/apps/sidebar/App.tsx index b61258c..b9c6c8d 100644 --- a/src/presentation/apps/sidebar/App.tsx +++ b/src/presentation/apps/sidebar/App.tsx @@ -1,16 +1,16 @@ import "./App.css"; import { useTheme } from "../../providers/theme_provider.tsx"; import { ActionButton } from "./components/actionButton.tsx"; -import { ReloadButton } from "./components/reloadButton.tsx"; import { ModalPopup } from "./components/modalPopup.tsx"; import { SendersContainer } from "./components/sendersContainer.tsx"; import { DeclutterHeader } from "./components/header.tsx"; import { ModalProvider } from "./providers/modalContext.tsx"; -import ThemeToggle from "./components/themeToggle.tsx"; import { AppProvider } from "../../providers/app_provider.tsx"; import { ThemeProvider } from "../../providers/theme_provider.tsx"; import { SearchInput } from "./components/searchInput.tsx"; import { useApp } from "../../providers/app_provider.tsx"; +import { SettingsModal } from "./components/settingsModal.tsx"; +import { useState } from "react"; function App() { return ( @@ -25,21 +25,20 @@ function App() { function AppWithTheme() { const { theme } = useTheme(); const { searchTerm, setSearchTerm } = useApp(); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); return (
- + setIsSettingsOpen(true)} + />
-
- -
- - +
@@ -48,6 +47,10 @@ function AppWithTheme() { + setIsSettingsOpen(false)} + />
); diff --git a/src/presentation/apps/sidebar/components/actionButton.css b/src/presentation/apps/sidebar/components/actionButton.css index b64f76d..1450573 100644 --- a/src/presentation/apps/sidebar/components/actionButton.css +++ b/src/presentation/apps/sidebar/components/actionButton.css @@ -27,6 +27,15 @@ background-color: #ca2633; } +#hide-button { + background-color: #5a6c7d; + color: white; +} + +#hide-button:hover { + background-color: #6b7c8d; +} + .action-button .i { margin-right: 5px; } diff --git a/src/presentation/apps/sidebar/components/actionButton.tsx b/src/presentation/apps/sidebar/components/actionButton.tsx index 8ea3829..01bf398 100644 --- a/src/presentation/apps/sidebar/components/actionButton.tsx +++ b/src/presentation/apps/sidebar/components/actionButton.tsx @@ -1,31 +1,52 @@ import "./actionButton.css"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBan, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faBan, faTrash, faEyeSlash } from "@fortawesome/free-solid-svg-icons"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { useModal } from "../providers/modalContext"; import { useApp } from "../../../providers/app_provider"; export const ActionButton = ({ id }: { id: string }) => { - const text: string = id == "unsubscribe-button" ? "Unsubscribe" : "Delete"; - const icon: IconProp = id == "unsubscribe-button" ? faBan : faTrash; - const { selectedSenders } = useApp(); + let text: string; + let icon: IconProp; + let action: "unsubscribe" | "delete" | "hide"; + + if (id === "unsubscribe-button") { + text = "Unsubscribe"; + icon = faBan; + action = "unsubscribe"; + } else if (id === "delete-button") { + text = "Delete"; + icon = faTrash; + action = "delete"; + } else { + text = "Hide"; + icon = faEyeSlash; + action = "hide"; + } + + const { selectedSenders, hideSenders } = useApp(); const { setModal } = useModal(); - const handleClick = () => { + const handleClick = async () => { const selectedSenderKeys: string[] = Object.keys(selectedSenders); if (selectedSenderKeys.length > 0) { - // open confirmation modal - setModal({ - action: id === "unsubscribe-button" ? "unsubscribe" : "delete", - type: "confirm", - extras: { - emailsNum: selectedSenderKeys.reduce( - (sum, key) => sum + selectedSenders[key], - 0, - ), - sendersNum: selectedSenderKeys.length, - }, - }); + if (action === "hide") { + // Hide doesn't need confirmation - just hide directly + await hideSenders(selectedSenderKeys); + } else { + // Open confirmation modal for destructive actions + setModal({ + action: action, + type: "confirm", + extras: { + emailsNum: selectedSenderKeys.reduce( + (sum, key) => sum + selectedSenders[key], + 0, + ), + sendersNum: selectedSenderKeys.length, + }, + }); + } } else { // open no-senders modal setModal({ type: "no-sender" }); diff --git a/src/presentation/apps/sidebar/components/header.css b/src/presentation/apps/sidebar/components/header.css index 1eafad7..cb16855 100644 --- a/src/presentation/apps/sidebar/components/header.css +++ b/src/presentation/apps/sidebar/components/header.css @@ -6,6 +6,30 @@ flex-direction: column; justify-content: center; align-items: center; + position: relative; +} + +.header-left { + position: absolute; + top: 10px; + left: 10px; +} + +.settings-button { + position: absolute; + top: 10px; + right: 10px; + background: none; + border: none; + color: var(--text-primary); + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + transition: transform 0.2s ease-in-out; +} + +.settings-button:hover { + transform: scale(1.2); } .header-icon { @@ -19,3 +43,7 @@ align-items: center; border-radius: 50%; } + +.email-text { + word-break: break-all; +} diff --git a/src/presentation/apps/sidebar/components/header.tsx b/src/presentation/apps/sidebar/components/header.tsx index 6266caa..b5c159d 100644 --- a/src/presentation/apps/sidebar/components/header.tsx +++ b/src/presentation/apps/sidebar/components/header.tsx @@ -1,10 +1,15 @@ -import { faUser } from "@fortawesome/free-solid-svg-icons"; +import { faUser, faGear } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import "./header.css"; import { useEffect, useState } from "react"; import { useApp } from "../../../providers/app_provider"; +import { ReloadButton } from "./reloadButton"; -export function DeclutterHeader() { +interface DeclutterHeaderProps { + onOpenSettings: () => void; +} + +export function DeclutterHeader({ onOpenSettings }: DeclutterHeaderProps) { const { getEmailAccount } = useApp(); const [email, setEmail] = useState(null); @@ -17,10 +22,21 @@ export function DeclutterHeader() { return (
+
+ +
+
- {email} +
{email}
); } diff --git a/src/presentation/apps/sidebar/components/reloadButton.css b/src/presentation/apps/sidebar/components/reloadButton.css index 070dab9..835e7be 100644 --- a/src/presentation/apps/sidebar/components/reloadButton.css +++ b/src/presentation/apps/sidebar/components/reloadButton.css @@ -1,9 +1,13 @@ .reload-button { border: none; - background-color: var(--bg-primary); + background-color: transparent; + background: none; color: var(--text-primary); box-shadow: none; cursor: pointer; + padding: 4px 8px; + font-size: 18px; + transition: transform 0.2s ease-in-out; } .reload-button .i { @@ -12,5 +16,4 @@ .reload-button:hover { transform: scale(1.2); - transition: transform 0.2s ease-in-out; } diff --git a/src/presentation/apps/sidebar/components/senderLine.css b/src/presentation/apps/sidebar/components/senderLine.css index 8c8ba14..3a289dc 100644 --- a/src/presentation/apps/sidebar/components/senderLine.css +++ b/src/presentation/apps/sidebar/components/senderLine.css @@ -45,3 +45,9 @@ font-weight: bold; padding: 0 7px; } + +.email-count { + display: flex; + align-items: center; + gap: 6px; +} diff --git a/src/presentation/apps/sidebar/components/senderLine.tsx b/src/presentation/apps/sidebar/components/senderLine.tsx index b02ddc3..84019f7 100644 --- a/src/presentation/apps/sidebar/components/senderLine.tsx +++ b/src/presentation/apps/sidebar/components/senderLine.tsx @@ -28,11 +28,9 @@ export const SenderLine = ({ return (
diff --git a/src/presentation/apps/sidebar/components/settingsModal.css b/src/presentation/apps/sidebar/components/settingsModal.css new file mode 100644 index 0000000..0498e32 --- /dev/null +++ b/src/presentation/apps/sidebar/components/settingsModal.css @@ -0,0 +1,182 @@ +.settings-modal { + position: absolute; + z-index: 100; + left: 0; + top: 0; + width: 100%; + height: 100%; + border-radius: 16px; + background-color: rgb(0 0 0 / 40%); + display: flex; + align-items: center; + justify-content: center; +} + +.settings-modal-content { + background-color: var(--bg-primary); + color: var(--text-primary); + padding: 20px; + border: 1px solid var(--border-color); + width: 85%; + max-width: 500px; + max-height: 80%; + border-radius: 12px; +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-color); +} + +.settings-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; +} + +.close-button { + background: none; + border: none; + color: var(--text-secondary); + font-size: 20px; + cursor: pointer; + padding: 5px 10px; + transition: color 0.2s; +} + +.close-button:hover { + color: var(--text-primary); +} + +.settings-section { + margin-bottom: 20px; +} + +.settings-section h3 { + font-size: 16px; + font-weight: 600; + margin: 0 0 15px 0; +} + +.theme-options { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.theme-option { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px 8px; + background-color: var(--bg-secondary); + border: 2px solid transparent; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + font-size: 14px; + color: var(--text-primary); +} + +.theme-option:hover { + background-color: var(--bg-hover); +} + +.theme-option.active { + background-color: var(--bg-hover); + border-color: var(--modal-button-primary); +} + +.theme-option svg { + font-size: 20px; +} + +.empty-state { + text-align: center; + padding: 30px 20px; + color: var(--text-secondary); +} + +.empty-state p { + margin: 5px 0; +} + +.empty-state .note { + font-size: 13px; + margin-top: 10px; +} + +.hidden-senders-list { + max-height: 300px; + overflow-y: auto; + margin-bottom: 15px; +} + +.hidden-sender-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + margin-bottom: 8px; + background-color: var(--bg-secondary); + border-radius: 6px; + transition: background-color 0.2s; +} + +.hidden-sender-item:hover { + background-color: var(--bg-hover); +} + +.sender-email { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + margin-right: 10px; +} + +.unhide-button { + background-color: transparent; + color: var(--text-primary); + border: none; + padding: 6px; + border-radius: 50%; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + transition: all 0.2s; + flex-shrink: 0; +} + +.unhide-button:hover { + background-color: var(--bg-hover); + transform: scale(1.1); +} + +.unhide-all-button { + width: 100%; + padding: 10px; + background-color: var(--modal-button-primary); + color: var(--modal-button-secondary); + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s; +} + +.unhide-all-button:hover { + opacity: 0.9; +} diff --git a/src/presentation/apps/sidebar/components/settingsModal.tsx b/src/presentation/apps/sidebar/components/settingsModal.tsx new file mode 100644 index 0000000..4ad7ec5 --- /dev/null +++ b/src/presentation/apps/sidebar/components/settingsModal.tsx @@ -0,0 +1,110 @@ +import "./settingsModal.css"; +import { useApp } from "../../../providers/app_provider"; +import { useTheme } from "../../../providers/theme_provider"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEye, faTimes, faSun, faMoon, faDesktop } from "@fortawesome/free-solid-svg-icons"; + +interface SettingsModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const SettingsModal = ({ isOpen, onClose }: SettingsModalProps) => { + const { hiddenSenders, unhideSender } = useApp(); + const { setting: themeSetting, setSetting: setThemeSetting } = useTheme(); + + if (!isOpen) return null; + + const handleBackgroundClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + onClose(); + } + }; + + const handleUnhideAll = async () => { + // Unhide all senders + for (const email of hiddenSenders) { + await unhideSender(email); + } + }; + + return ( +
+
+
+

Settings

+ +
+ +
+

Appearance

+
+ + + +
+
+ +
+

Hidden Senders

+ + {hiddenSenders.length === 0 ? ( +
+

No hidden senders

+

+ Use the Hide button to remove senders from your view. +

+
+ ) : ( + <> +
+ {hiddenSenders.map((email) => ( +
+ {email} + +
+ ))} +
+ + {hiddenSenders.length > 1 && ( + + )} + + )} +
+
+
+ ); +}; diff --git a/src/presentation/apps/sidebar/providers/modalContext.tsx b/src/presentation/apps/sidebar/providers/modalContext.tsx index a867988..074819a 100644 --- a/src/presentation/apps/sidebar/providers/modalContext.tsx +++ b/src/presentation/apps/sidebar/providers/modalContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useState } from "react"; type ModalState = null | { - action?: "delete" | "unsubscribe"; + action?: "delete" | "unsubscribe" | "hide"; type: "confirm" | "pending" | "continue" | "success" | "error" | "no-sender"; subtype?: "working" | "finding-link" | "blocking"; extras?: any; diff --git a/src/presentation/providers/app_provider.tsx b/src/presentation/providers/app_provider.tsx index 08b41f0..8ad474e 100644 --- a/src/presentation/providers/app_provider.tsx +++ b/src/presentation/providers/app_provider.tsx @@ -41,6 +41,9 @@ type AppContextType = { filteredSenders: Sender[]; fetchProgress: FetchProgress | null; cancelFetch: () => void; + hiddenSenders: string[]; + hideSenders: (emails: string[]) => Promise; + unhideSender: (email: string) => Promise; }; const AppContext = createContext(undefined); @@ -57,6 +60,7 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ const [fetchProgress, setFetchProgress] = useState( null, ); + const [hiddenSenders, setHiddenSenders] = useState([]); // - REPOS - const useMock = import.meta.env.VITE_USE_MOCK === "true"; @@ -158,27 +162,72 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ [emailRepo], ); + const hideSenders = useCallback( + async (emails: string[]) => { + const accountEmail = await pageInteractionRepo.getActiveTabEmailAccount(); + await storageRepo.storeHiddenSenders(emails, accountEmail); + setHiddenSenders((prev) => Array.from(new Set([...prev, ...emails]))); + // Clear selection after hiding + setSelectedSenders({}); + }, + [pageInteractionRepo, storageRepo], + ); + + const unhideSender = useCallback( + async (email: string) => { + const accountEmail = await pageInteractionRepo.getActiveTabEmailAccount(); + await storageRepo.removeHiddenSenders([email], accountEmail); + setHiddenSenders((prev) => prev.filter((e) => e !== email)); + }, + [pageInteractionRepo, storageRepo], + ); + // Add filtered senders computation const filteredSenders = useMemo(() => { - if (!searchTerm.trim()) { - return senders; + let result = senders; + + // Always filter out hidden senders + if (hiddenSenders.length > 0) { + result = result.filter((sender) => !hiddenSenders.includes(sender.email)); } - const lowerSearchTerm = searchTerm.toLowerCase(); - return senders.filter((sender) => { - const matchesEmail = sender.email.toLowerCase().includes(lowerSearchTerm); - const matchesName = Array.from(sender.names).some((name) => - name.toLowerCase().includes(lowerSearchTerm), - ); - return matchesEmail || matchesName; - }); - }, [senders, searchTerm]); + // Apply search filter + if (searchTerm.trim()) { + const lowerSearchTerm = searchTerm.toLowerCase(); + result = result.filter((sender) => { + const matchesEmail = sender.email + .toLowerCase() + .includes(lowerSearchTerm); + const matchesName = Array.from(sender.names).some((name) => + name.toLowerCase().includes(lowerSearchTerm), + ); + return matchesEmail || matchesName; + }); + } + + return result; + }, [senders, searchTerm, hiddenSenders]); // Automatically load senders from storage when the component mounts useEffect(() => { reloadSenders(); }, [reloadSenders]); + // Load hidden senders when the component mounts + useEffect(() => { + const loadHiddenSenders = async () => { + try { + const accountEmail = + await pageInteractionRepo.getActiveTabEmailAccount(); + const hidden = await storageRepo.readHiddenSenders(accountEmail); + setHiddenSenders(hidden); + } catch (error) { + console.error("Failed to load hidden senders:", error); + } + }; + loadHiddenSenders(); + }, [pageInteractionRepo, storageRepo]); + return ( = ({ filteredSenders, fetchProgress, cancelFetch, + hiddenSenders, + hideSenders, + unhideSender, }} > {children} diff --git a/test/ui/sidebar/helpers.ts b/test/ui/sidebar/helpers.ts index 4bb3545..e89c648 100644 --- a/test/ui/sidebar/helpers.ts +++ b/test/ui/sidebar/helpers.ts @@ -2,7 +2,7 @@ import { Page } from "@playwright/test"; export const selectAliceBob = async ( page: Page, - action: "delete" | "unsubscribe", + action: "delete" | "unsubscribe" | "hide", ) => { // Helper function to select Alice and Bob senders, then click action await page @@ -20,7 +20,7 @@ export const selectAliceBob = async ( export const selectEveFrank = async ( page: Page, - action: "delete" | "unsubscribe", + action: "delete" | "unsubscribe" | "hide", ) => { // Helper function to select Eve and Frank senders, then click action await page diff --git a/test/ui/sidebar/hideSenders.spec.ts b/test/ui/sidebar/hideSenders.spec.ts new file mode 100644 index 0000000..8d46d29 --- /dev/null +++ b/test/ui/sidebar/hideSenders.spec.ts @@ -0,0 +1,178 @@ +import { test, expect } from "@playwright/test"; +import { selectAliceBob, setupSidebarTest } from "./helpers"; + +test.describe("UI tests for Hide Senders Functionality", () => { + const logs: string[] = []; + + test.beforeEach(async ({ page }) => { + await setupSidebarTest(page, logs); + }); + + test("hiding senders removes them from list", async ({ page }) => { + // Select two senders + await selectAliceBob(page, "hide"); + + // Wait for senders to be hidden (check they disappear) + await expect( + page.locator("div").filter({ hasText: /^Alicealice@email\.com32$/ }), + ).not.toBeVisible(); + await expect( + page.locator("div").filter({ hasText: /^Bobbob@email\.com78$/ }), + ).not.toBeVisible(); + + // Check that hide function was called + expect(logs).toContain("[MOCK] Hiding sender: alice@email.com"); + expect(logs).toContain("[MOCK] Hiding sender: bob@email.com"); + + // Other senders should still be visible + await expect( + page.locator("div").filter({ hasText: /^Carolcarol@email\.com15$/ }), + ).toBeVisible(); + }); + + test("settings modal displays hidden senders with unhide buttons", async ({ + page, + }) => { + // First hide some senders + await selectAliceBob(page, "hide"); + + // Wait for senders to be hidden + await expect( + page.locator("div").filter({ hasText: /^Alicealice@email\.com32$/ }), + ).not.toBeVisible(); + + // Open settings modal + await page.click("button[aria-label='Settings']"); + + // Settings modal should be visible + const settingsModal = page.locator(".settings-modal"); + await expect(settingsModal).toBeVisible(); + await expect(settingsModal).toContainText("Settings"); + await expect(settingsModal).toContainText("Hidden Senders"); + + // Hidden senders should be listed + await expect(settingsModal).toContainText("alice@email.com"); + await expect(settingsModal).toContainText("bob@email.com"); + + // Unhide buttons should be visible (icon-only buttons with aria-label) + const unhideButtons = settingsModal.locator(".unhide-button"); + await expect(unhideButtons).toHaveCount(2); + + // Unhide All button should be visible + await expect( + settingsModal.locator(".unhide-all-button"), + ).toBeVisible(); + }); + + test("unhiding a sender restores it to the main list", async ({ page }) => { + // First hide some senders + await selectAliceBob(page, "hide"); + + // Wait for senders to be hidden + await expect( + page.locator("div").filter({ hasText: /^Alicealice@email\.com32$/ }), + ).not.toBeVisible(); + + // Open settings modal + await page.click("button[aria-label='Settings']"); + + // Unhide Alice + const aliceItem = page + .locator(".hidden-sender-item") + .filter({ hasText: "alice@email.com" }); + await aliceItem.locator(".unhide-button").click(); + + // Check that unhide function was called + expect(logs).toContain("[MOCK] Unhiding sender: alice@email.com"); + + // Close settings modal + await page.click("button[aria-label='Close']"); + + // Alice should now be visible in the main list + await expect( + page.locator("div").filter({ hasText: /^Alicealice@email\.com32$/ }), + ).toBeVisible(); + + // Bob should still be hidden + await expect( + page.locator("div").filter({ hasText: /^Bobbob@email\.com78$/ }), + ).not.toBeVisible(); + }); + + test("unhide all button restores all hidden senders", async ({ page }) => { + // First hide some senders + await selectAliceBob(page, "hide"); + + // Wait for senders to be hidden + await expect( + page.locator("div").filter({ hasText: /^Alicealice@email\.com32$/ }), + ).not.toBeVisible(); + + // Open settings modal + await page.click("button[aria-label='Settings']"); + + // Click Unhide All + await page.click(".unhide-all-button"); + + // Check that unhide function was called for both + expect(logs).toContain("[MOCK] Unhiding sender: alice@email.com"); + expect(logs).toContain("[MOCK] Unhiding sender: bob@email.com"); + + // Close settings modal + await page.click("button[aria-label='Close']"); + + // Both senders should now be visible + await expect( + page.locator("div").filter({ hasText: /^Alicealice@email\.com32$/ }), + ).toBeVisible(); + await expect( + page.locator("div").filter({ hasText: /^Bobbob@email\.com78$/ }), + ).toBeVisible(); + }); + + test("shows empty state when no senders are hidden", async ({ page }) => { + // Open settings modal without hiding anything + await page.click("button[aria-label='Settings']"); + + const settingsModal = page.locator(".settings-modal"); + await expect(settingsModal).toBeVisible(); + + // Should show empty state + await expect(settingsModal).toContainText("No hidden senders"); + await expect(settingsModal).toContainText( + "Use the Hide button to remove senders from your view.", + ); + + // Unhide All button should not be visible + await expect( + settingsModal.locator(".unhide-all-button"), + ).not.toBeVisible(); + }); + + test("clicking background closes settings modal", async ({ page }) => { + // Open settings modal + await page.click("button[aria-label='Settings']"); + + const settingsModal = page.locator(".settings-modal"); + await expect(settingsModal).toBeVisible(); + + // Click close button + await page.click("button[aria-label='Close']"); + + // Modal should be closed + await expect(settingsModal).not.toBeVisible(); + }); + + test("shows no-sender modal when hide is clicked with no selection", async ({ + page, + }) => { + // Click hide button without selecting any senders + await page.click("#hide-button"); + + // No-sender modal should appear + const modal = page.locator("#no-sender-modal"); + await expect(modal).toBeVisible(); + await expect(modal).toContainText("Oops!"); + await expect(modal).toContainText("You haven't selected a sender yet."); + }); +}); From 19aac3f84f928867430279b4713236c1466eb77d Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Oickle Date: Thu, 12 Feb 2026 12:13:33 -0800 Subject: [PATCH 2/9] Code cleanup --- src/presentation/apps/popup/Popup.css | 4 ++ src/presentation/apps/popup/Popup.tsx | 7 --- src/presentation/apps/sidebar/App.css | 58 ++++++++----------- .../apps/sidebar/components/actionButton.css | 1 - .../apps/sidebar/components/modalPopup.css | 9 ++- .../apps/sidebar/components/modalPopup.tsx | 6 +- .../apps/sidebar/components/searchBar.css | 18 ------ .../apps/sidebar/components/searchBar.tsx | 18 ------ .../apps/sidebar/components/senderLine.css | 4 ++ .../apps/sidebar/components/senderLine.tsx | 2 +- .../sidebar/components/senderLineSkeleton.tsx | 2 +- .../sidebar/components/sendersContainer.tsx | 8 +-- src/presentation/apps/tutorial/Tutorial.css | 17 ++++++ .../apps/tutorial/components/steps.tsx | 22 ++----- src/presentation/apps/tutorial/index.css | 23 -------- 15 files changed, 68 insertions(+), 131 deletions(-) delete mode 100644 src/presentation/apps/sidebar/components/searchBar.css delete mode 100644 src/presentation/apps/sidebar/components/searchBar.tsx delete mode 100644 src/presentation/apps/tutorial/index.css diff --git a/src/presentation/apps/popup/Popup.css b/src/presentation/apps/popup/Popup.css index c6c9262..a84be7a 100644 --- a/src/presentation/apps/popup/Popup.css +++ b/src/presentation/apps/popup/Popup.css @@ -51,11 +51,15 @@ cursor: pointer; display: flex; align-items: center; + justify-content: center; + margin: 16px auto; gap: 8px; } .open-gmail-button img { background-color: #233b86; + width: 20px; + height: 20px; } .open-gmail-button:hover { diff --git a/src/presentation/apps/popup/Popup.tsx b/src/presentation/apps/popup/Popup.tsx index 749b5b7..762ca21 100644 --- a/src/presentation/apps/popup/Popup.tsx +++ b/src/presentation/apps/popup/Popup.tsx @@ -19,18 +19,11 @@ const PopupApp = () => { diff --git a/src/presentation/apps/sidebar/App.css b/src/presentation/apps/sidebar/App.css index 8df95b1..c7ab61f 100644 --- a/src/presentation/apps/sidebar/App.css +++ b/src/presentation/apps/sidebar/App.css @@ -79,31 +79,31 @@ color: var(--text-primary); } -#declutter-body { - font-family: "Google Sans", Roboto, RobotoDraft, Helvetica, Arial, sans-serif; - height: 97vh; - display: flex; - flex-direction: column; - margin: 5px; +/* Utility classes */ +.spacer-5 { + height: 5px; } -.declutter-header { - background-color: var(--header-bg); +.spacer-10 { + height: 10px; } -#declutter-body.login { - height: 100vh; - color: white; - background-color: #233b86; - align-items: center; - justify-content: center; - font-size: 1rem; - margin: 0; +.spacer-20 { + height: 20px; +} + +.no-results-message { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); } -#declutter-body.login #email-account { - margin-top: 7px; - font-size: 0.75rem; +#declutter-body { + font-family: "Google Sans", Roboto, RobotoDraft, Helvetica, Arial, sans-serif; + height: 97vh; + display: flex; + flex-direction: column; + margin: 5px; } .button-bar { @@ -114,23 +114,13 @@ align-items: center; } +.sender-actions { + display: flex; + gap: 8px; +} + #senders { padding: 10px; overflow: hidden auto; flex-grow: 1; } - -.icon-button { - border: none; - background-color: var(--bg-primary); - color: var(--text-primary); - box-shadow: none; - cursor: pointer; - font-size: 18px; - padding: 4px 8px; -} - -.icon-button:hover { - transform: scale(1.2); - transition: transform 0.2s ease-in-out; -} diff --git a/src/presentation/apps/sidebar/components/actionButton.css b/src/presentation/apps/sidebar/components/actionButton.css index 1450573..fdb6fab 100644 --- a/src/presentation/apps/sidebar/components/actionButton.css +++ b/src/presentation/apps/sidebar/components/actionButton.css @@ -4,7 +4,6 @@ cursor: pointer; font-size: 14px; height: 32px; - margin: 4px; padding: 0 16px; box-shadow: 0 3px 5px rgb(0 0 0 / 20%); } diff --git a/src/presentation/apps/sidebar/components/modalPopup.css b/src/presentation/apps/sidebar/components/modalPopup.css index bfb4ea4..e993d8e 100644 --- a/src/presentation/apps/sidebar/components/modalPopup.css +++ b/src/presentation/apps/sidebar/components/modalPopup.css @@ -21,14 +21,19 @@ text-align: center; } -p { +.modal-content p { font-size: 16px; } -.note { +.modal-content .note { font-size: 14px; } +.toggle-options { + margin: 15px 0; + text-align: left; +} + .modal-content button { border: none; display: block; diff --git a/src/presentation/apps/sidebar/components/modalPopup.tsx b/src/presentation/apps/sidebar/components/modalPopup.tsx index 92d847d..6dc2030 100644 --- a/src/presentation/apps/sidebar/components/modalPopup.tsx +++ b/src/presentation/apps/sidebar/components/modalPopup.tsx @@ -79,7 +79,7 @@ const UnsubscribePending = ({ subtype }: { subtype: string }) => { return ( <>

{message}

-
+
); @@ -224,7 +224,7 @@ const DeletePending = () => { return ( <>

Deleting emails...

-
+
); @@ -258,7 +258,7 @@ const NoSender = () => {

Oops!

You haven't selected a sender yet.

-
+
-
+
); }; diff --git a/src/presentation/apps/tutorial/index.css b/src/presentation/apps/tutorial/index.css deleted file mode 100644 index 3add07b..0000000 --- a/src/presentation/apps/tutorial/index.css +++ /dev/null @@ -1,23 +0,0 @@ -:root { - /* Default light mode */ - --bg-primary: #fff; - --text-primary: #000; -} - -:root.dark { - /* Dark mode vars */ - --bg-primary: #121212; - --text-primary: #fff; -} - -@media (prefers-color-scheme: dark) { - :root:not(.light) { - --bg-primary: #121212; - --text-primary: #fff; - } -} - -:root, -body { - background: transparent !important; -} From 65222a12fab046c93f556e5f79c31df59517af64 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Oickle <134335292+anna-st-40@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:38:00 -0800 Subject: [PATCH 3/9] hide/unhide error handling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/presentation/providers/app_provider.tsx | 30 +++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/presentation/providers/app_provider.tsx b/src/presentation/providers/app_provider.tsx index 8ad474e..b32bbc4 100644 --- a/src/presentation/providers/app_provider.tsx +++ b/src/presentation/providers/app_provider.tsx @@ -164,20 +164,34 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ const hideSenders = useCallback( async (emails: string[]) => { - const accountEmail = await pageInteractionRepo.getActiveTabEmailAccount(); - await storageRepo.storeHiddenSenders(emails, accountEmail); - setHiddenSenders((prev) => Array.from(new Set([...prev, ...emails]))); - // Clear selection after hiding - setSelectedSenders({}); + try { + const accountEmail = + await pageInteractionRepo.getActiveTabEmailAccount(); + await storageRepo.storeHiddenSenders(emails, accountEmail); + setHiddenSenders((prev) => + Array.from(new Set([...prev, ...emails])), + ); + // Clear selection after hiding + setSelectedSenders({}); + } catch (error) { + console.error("Failed to hide senders:", error); + throw error; + } }, [pageInteractionRepo, storageRepo], ); const unhideSender = useCallback( async (email: string) => { - const accountEmail = await pageInteractionRepo.getActiveTabEmailAccount(); - await storageRepo.removeHiddenSenders([email], accountEmail); - setHiddenSenders((prev) => prev.filter((e) => e !== email)); + try { + const accountEmail = + await pageInteractionRepo.getActiveTabEmailAccount(); + await storageRepo.removeHiddenSenders([email], accountEmail); + setHiddenSenders((prev) => prev.filter((e) => e !== email)); + } catch (error) { + console.error("Failed to unhide sender:", error); + throw error; + } }, [pageInteractionRepo, storageRepo], ); From 7166ff2b96e9ca10b3296c0765e468d2de260623 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Oickle <134335292+anna-st-40@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:39:40 -0800 Subject: [PATCH 4/9] Unhide all in a promise Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/presentation/apps/sidebar/components/settingsModal.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/presentation/apps/sidebar/components/settingsModal.tsx b/src/presentation/apps/sidebar/components/settingsModal.tsx index 4ad7ec5..f9abc64 100644 --- a/src/presentation/apps/sidebar/components/settingsModal.tsx +++ b/src/presentation/apps/sidebar/components/settingsModal.tsx @@ -22,10 +22,8 @@ export const SettingsModal = ({ isOpen, onClose }: SettingsModalProps) => { }; const handleUnhideAll = async () => { - // Unhide all senders - for (const email of hiddenSenders) { - await unhideSender(email); - } + // Unhide all senders concurrently + await Promise.all(hiddenSenders.map((email) => unhideSender(email))); }; return ( From c672d699f7aae77f03337dba9d359c9d219199b3 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Oickle Date: Thu, 12 Feb 2026 12:43:08 -0800 Subject: [PATCH 5/9] Focus management --- src/presentation/apps/sidebar/App.css | 13 ------------- src/presentation/apps/sidebar/App.tsx | 5 ++++- .../apps/sidebar/components/header.tsx | 4 +++- .../apps/sidebar/components/modalPopup.tsx | 6 +++--- .../apps/sidebar/components/reloadButton.css | 1 - .../apps/sidebar/components/settingsModal.tsx | 18 +++++++++++++++--- .../apps/tutorial/components/steps.tsx | 6 +++--- 7 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/presentation/apps/sidebar/App.css b/src/presentation/apps/sidebar/App.css index c7ab61f..f9d84ff 100644 --- a/src/presentation/apps/sidebar/App.css +++ b/src/presentation/apps/sidebar/App.css @@ -79,19 +79,6 @@ color: var(--text-primary); } -/* Utility classes */ -.spacer-5 { - height: 5px; -} - -.spacer-10 { - height: 10px; -} - -.spacer-20 { - height: 20px; -} - .no-results-message { text-align: center; padding: 40px 20px; diff --git a/src/presentation/apps/sidebar/App.tsx b/src/presentation/apps/sidebar/App.tsx index b9c6c8d..906d43e 100644 --- a/src/presentation/apps/sidebar/App.tsx +++ b/src/presentation/apps/sidebar/App.tsx @@ -10,7 +10,7 @@ import { ThemeProvider } from "../../providers/theme_provider.tsx"; import { SearchInput } from "./components/searchInput.tsx"; import { useApp } from "../../providers/app_provider.tsx"; import { SettingsModal } from "./components/settingsModal.tsx"; -import { useState } from "react"; +import { useState, useRef } from "react"; function App() { return ( @@ -26,12 +26,14 @@ function AppWithTheme() { const { theme } = useTheme(); const { searchTerm, setSearchTerm } = useApp(); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const settingsButtonRef = useRef(null); return (
setIsSettingsOpen(true)} + settingsButtonRef={settingsButtonRef} />
@@ -50,6 +52,7 @@ function AppWithTheme() { setIsSettingsOpen(false)} + triggerRef={settingsButtonRef} />
diff --git a/src/presentation/apps/sidebar/components/header.tsx b/src/presentation/apps/sidebar/components/header.tsx index b5c159d..ed929d1 100644 --- a/src/presentation/apps/sidebar/components/header.tsx +++ b/src/presentation/apps/sidebar/components/header.tsx @@ -7,9 +7,10 @@ import { ReloadButton } from "./reloadButton"; interface DeclutterHeaderProps { onOpenSettings: () => void; + settingsButtonRef?: React.RefObject; } -export function DeclutterHeader({ onOpenSettings }: DeclutterHeaderProps) { +export function DeclutterHeader({ onOpenSettings, settingsButtonRef }: DeclutterHeaderProps) { const { getEmailAccount } = useApp(); const [email, setEmail] = useState(null); @@ -26,6 +27,7 @@ export function DeclutterHeader({ onOpenSettings }: DeclutterHeaderProps) {
-
+
); }; From dfdfa2d3400d703b1dba84812be60f1b848df3a5 Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Oickle Date: Thu, 12 Feb 2026 12:44:24 -0800 Subject: [PATCH 6/9] Keyboard accessibility --- .../apps/sidebar/components/settingsModal.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/presentation/apps/sidebar/components/settingsModal.tsx b/src/presentation/apps/sidebar/components/settingsModal.tsx index 6aa152d..ca592b7 100644 --- a/src/presentation/apps/sidebar/components/settingsModal.tsx +++ b/src/presentation/apps/sidebar/components/settingsModal.tsx @@ -25,6 +25,22 @@ export const SettingsModal = ({ isOpen, onClose, triggerRef }: SettingsModalProp triggerRef.current.focus(); } }, [isOpen, triggerRef]); + + // Keyboard accessibility - close on Escape key + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); if (!isOpen) return null; const handleBackgroundClick = (event: React.MouseEvent) => { From 7751c6c018de9fa06a1a4999e73b872937badbda Mon Sep 17 00:00:00 2001 From: Anna Stefaniv Oickle Date: Thu, 12 Feb 2026 12:50:26 -0800 Subject: [PATCH 7/9] Formatting --- .../repositories/chrome_local_storage_repo.ts | 3 +-- .../repositories/mocks/mock_storage_repo.ts | 4 +--- src/presentation/apps/popup/Popup.tsx | 5 +--- .../apps/sidebar/components/header.tsx | 5 +++- .../apps/sidebar/components/settingsModal.tsx | 23 +++++++++++++++---- src/presentation/providers/app_provider.tsx | 4 +--- test/ui/sidebar/hideSenders.spec.ts | 8 ++----- 7 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/data/repositories/chrome_local_storage_repo.ts b/src/data/repositories/chrome_local_storage_repo.ts index 443f993..4f6a7d1 100644 --- a/src/data/repositories/chrome_local_storage_repo.ts +++ b/src/data/repositories/chrome_local_storage_repo.ts @@ -91,8 +91,7 @@ export class ChromeLocalStorageRepo implements StorageRepo { return; } - const hiddenSenders = - result[accountEmail]?.hiddenSenderEmails || []; + const hiddenSenders = result[accountEmail]?.hiddenSenderEmails || []; resolve(hiddenSenders); }); }); diff --git a/src/data/repositories/mocks/mock_storage_repo.ts b/src/data/repositories/mocks/mock_storage_repo.ts index 63fe44f..e545a26 100644 --- a/src/data/repositories/mocks/mock_storage_repo.ts +++ b/src/data/repositories/mocks/mock_storage_repo.ts @@ -53,9 +53,7 @@ export class MockStorageRepo implements StorageRepo { } removeHiddenSenders(emails: string[], accountEmail: string): Promise { - console.log( - `[MOCK] Removing hidden senders for account: ${accountEmail}`, - ); + console.log(`[MOCK] Removing hidden senders for account: ${accountEmail}`); emails.forEach((email) => { console.log(`[MOCK] Unhiding sender: ${email}`); }); diff --git a/src/presentation/apps/popup/Popup.tsx b/src/presentation/apps/popup/Popup.tsx index 762ca21..68eeb64 100644 --- a/src/presentation/apps/popup/Popup.tsx +++ b/src/presentation/apps/popup/Popup.tsx @@ -16,10 +16,7 @@ const PopupApp = () => {

InboxWhiz

Manage your inbox effortlessly with InboxWhiz!

-