Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ npm-debug.log*

# Local task list
TODO.md

# Local design/spec docs
docs/superpowers/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ A browser extension for multi-account Gmail™ notifications. Get a badge counte
- **Quick actions** — mark as read, star, archive, move to spam, delete, reply, or open in Gmail™, all without leaving the extension
- **Conversation threads** — emails that belong to the same Gmail thread are grouped together in the popup; expand a thread inline to see each message individually, with per-message and thread-level actions (reply, star, mark all read, archive all, spam, delete all, open in Gmail™); the detail view shows a collapsible thread context panel so you can jump to any sibling message; threading can be turned off in Settings if you prefer the flat view
- **Bulk selection** — enter selection mode to pick multiple messages (or whole threads) and mark them read, archive, or delete in one go
- **Search** — click the looking-glass in the toolbar to filter the active inbox by sender name, sender email, subject, or snippet; search is per inbox and clears when you switch accounts
- **Notification sounds** — optional audio alert on new mail; choose the built-in sound or upload your own (WAV/MP3/OGG, max 500 KB / 5 s) with adjustable volume
- **Themes** — light, dark, or auto (follows system preference)
- **Privacy controls** — optionally block external images in email previews (including tracking pixels); disabled by default so images load normally
Expand Down
10 changes: 8 additions & 2 deletions src/popup/list.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { groupByThread } from './thread-utils.js';
import { filterMessages } from './search.js';
import { openInGmail, openReply, performAction, performThreadAction } from './actions.js';
import { ICONS, makeIconBtn, makeMarkReadToggleBtn, makeStarBtn, makeSvgIcon } from './icons.js';
import { openDetail } from './detail.js';
import { api, dimmedMessages, els, state } from './state.js';
import { resetSearch, updateSearchBtn } from './search-ui.js';
import { clearNode, formatRelativeTime, sendMessage, setLoading, showError } from './utils.js';

// Injected by popup.js via initList() to avoid a circular dependency.
Expand Down Expand Up @@ -475,6 +477,7 @@ export function renderTabs() {
tab.addEventListener('click', () => {
state.activeAccountId = account.id;
state.pageByAccount[account.id] = 0;
resetSearch();
if (state.selectionMode) {
state.selectedMessages.clear();
updateBulkBar();
Expand All @@ -486,6 +489,7 @@ export function renderTabs() {
updateGmailBtn();
updateMarkAllBtn();
updateSelectBtn();
updateSearchBtn();
});
els.tabs.appendChild(tab);
}
Expand Down Expand Up @@ -586,7 +590,7 @@ export function renderList() {
} else {
showError(null);
}
const messages = account.messages || [];
const messages = filterMessages(account.messages || [], state.searchQuery);
const threads = getThreads(messages);
const perPage = state.settings?.maxMessagesPerAccount || 20;
const totalPages = Math.max(1, Math.ceil(threads.length / perPage));
Expand All @@ -597,7 +601,9 @@ export function renderList() {
const empty = document.createElement('li');
empty.className = 'empty-state';
const p = document.createElement('p');
p.textContent = 'No unread messages.';
p.textContent = state.searchQuery.trim()
? 'No messages match your search.'
: 'No unread messages.';
empty.appendChild(p);
els.list.appendChild(empty);
els.pagination.hidden = true;
Expand Down
54 changes: 54 additions & 0 deletions src/popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,60 @@ body {
cursor: default;
}

/* ── Search bar ──────────────────────────────────────── */
.search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}

.search-bar-icon {
flex: 0 0 auto;
color: var(--text-muted);
}

.search-input {
flex: 1 1 auto;
min-width: 0;
padding: 5px 8px;
font-size: 13px;
color: var(--text-primary);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
}

.search-input:focus {
border-color: var(--accent);
}

.search-clear-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
font-size: 13px;
line-height: 1;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
}

.search-clear-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}

/* ── Dark mode via media query (auto mode) ──────────── */
@media (prefers-color-scheme: dark) {
html:not(.theme-light) {
Expand Down
47 changes: 47 additions & 0 deletions src/popup/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@
<span>Geething</span>
</div>
<div class="topbar-actions">
<button
id="search-btn"
class="icon-btn"
title="Search this inbox"
aria-label="Search this inbox"
aria-expanded="false"
hidden
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="currentColor">
<path
d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"
/>
</svg>
</button>
<div id="mute-btn-wrapper" class="mute-btn-wrapper">
<button
id="mute-btn"
Expand Down Expand Up @@ -106,6 +120,39 @@

<nav id="account-tabs" class="account-tabs" role="tablist" aria-label="Gmail accounts"></nav>

<div id="search-bar" class="search-bar" hidden>
<svg
class="search-bar-icon"
viewBox="0 0 24 24"
width="14"
height="14"
aria-hidden="true"
fill="currentColor"
>
<path
d="M15.5 14h-.79l-.28-.27a6.5 6.5 0 1 0-.7.7l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0A4.5 4.5 0 1 1 14 9.5 4.5 4.5 0 0 1 9.5 14z"
/>
</svg>
<input
id="search-input"
type="text"
class="search-input"
placeholder="Search sender, subject, snippet…"
autocomplete="off"
spellcheck="false"
aria-label="Search this inbox"
/>
<button
id="search-clear-btn"
type="button"
class="search-clear-btn"
title="Clear search"
aria-label="Clear search"
>
</button>
</div>

<main id="content" class="content">
<div id="empty-state" class="empty-state" hidden>
<img src="../icons/icon-48.png" alt="" width="48" height="48" />
Expand Down
4 changes: 4 additions & 0 deletions src/popup/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
updateSelectBtn,
} from './list.js';
import { closeMuteDropdown, initMuteUi, registerMuteListeners, updateMuteBtn } from './mute-ui.js';
import { initSearchUi, registerSearchListeners, updateSearchBtn } from './search-ui.js';
import { api, dimmedMessages, els, state } from './state.js';
import { sendMessage, setLoading, showError } from './utils.js';

Expand Down Expand Up @@ -54,6 +55,7 @@ export async function loadState() {
updateMarkAllBtn();
updateSelectBtn();
updateMuteBtn();
updateSearchBtn();
els.addBtn.hidden = state.accounts.length > 0;
setLoading(false);
}
Expand All @@ -76,7 +78,9 @@ async function refresh({ silent = false } = {}) {
initActions({ refresh, loadState });
initMuteUi({ loadState });
initList({ loadState });
initSearchUi({ renderList });
registerMuteListeners();
registerSearchListeners();

// ── Detail back button ─────────────────────────────────────────────────────
els.backBtn.addEventListener('click', () => {
Expand Down
72 changes: 72 additions & 0 deletions src/popup/search-ui.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { els, state } from './state.js';

// Injected by popup.js via initSearchUi() to avoid circular dependencies.
let _renderList;

export function initSearchUi({ renderList }) {
_renderList = renderList;
}

// Clears the query and collapses the bar without re-rendering. Callers that
// render on their own (e.g. the tab-switch handler) use this directly; the
// interactive close path uses closeSearch() which also re-renders.
export function resetSearch() {
state.searchQuery = '';
els.searchInput.value = '';
els.searchBar.hidden = true;
els.searchBtn.classList.remove('active');
els.searchBtn.setAttribute('aria-expanded', 'false');
}

export function closeSearch() {
resetSearch();
_renderList();
}

export function openSearch() {
els.searchBar.hidden = false;
els.searchBtn.classList.add('active');
els.searchBtn.setAttribute('aria-expanded', 'true');
els.searchInput.focus();
}

// Shows the search button only when the active account has cached messages.
// Mirrors updateSelectBtn / updateMarkAllBtn gating. Closes an open bar if the
// active inbox no longer has any messages to search.
export function updateSearchBtn() {
const account = state.accounts.find((a) => a.id === state.activeAccountId) || null;
const hasMessages = (account?.messages?.length || 0) > 0;
els.searchBtn.hidden = !hasMessages;
if (!hasMessages && !els.searchBar.hidden) {
closeSearch();
}
}

export function registerSearchListeners() {
els.searchBtn.addEventListener('click', (e) => {
e.stopPropagation();
if (els.searchBar.hidden) {
openSearch();
} else {
closeSearch();
}
});

els.searchInput.addEventListener('input', () => {
state.searchQuery = els.searchInput.value;
_renderList();
});

els.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
closeSearch();
els.searchBtn.focus();
}
});

els.searchClearBtn.addEventListener('click', () => {
closeSearch();
els.searchBtn.focus();
});
}
16 changes: 16 additions & 0 deletions src/popup/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export function matchesQuery(message, query) {
const q = query.trim().toLowerCase();
if (!q) {
return true;
}
const fields = [message.from?.name, message.from?.email, message.subject, message.snippet];
return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(q));
}

export function filterMessages(messages, query) {
const q = query.trim();
if (!q) {
return messages;
}
return messages.filter((m) => matchesQuery(m, q));
}
5 changes: 5 additions & 0 deletions src/popup/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const state = {
selectedMessages: new Set(),
expandedThreads: new Set(),
globalMute: null,
searchQuery: '',
};

// Message IDs marked read in 'dim' mode — cleared on every loadState().
Expand All @@ -35,6 +36,10 @@ export const els = {
selectBtn: document.getElementById('select-btn'),
onboardingAddBtn: document.getElementById('onboarding-add-btn'),
optionsBtn: document.getElementById('options-btn'),
searchBtn: document.getElementById('search-btn'),
searchBar: document.getElementById('search-bar'),
searchInput: document.getElementById('search-input'),
searchClearBtn: document.getElementById('search-clear-btn'),
pagination: document.getElementById('pagination'),
paginationPrev: document.getElementById('pagination-prev'),
paginationNext: document.getElementById('pagination-next'),
Expand Down
78 changes: 78 additions & 0 deletions tests/unit/search.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import { filterMessages, matchesQuery } from '../../src/popup/search.js';

const msg = (over = {}) => ({
id: 'm1',
threadId: 't1',
from: { name: 'Jane Doe', email: 'jane@example.com' },
subject: 'Quarterly invoice',
snippet: 'Please find attached the report',
internalDate: 1000,
...over,
});

describe('matchesQuery', () => {
it('matches on sender name (case-insensitive)', () => {
expect(matchesQuery(msg(), 'jane')).toBe(true);
expect(matchesQuery(msg(), 'DOE')).toBe(true);
});

it('matches on sender email', () => {
expect(matchesQuery(msg(), 'example.com')).toBe(true);
});

it('matches on subject', () => {
expect(matchesQuery(msg(), 'invoice')).toBe(true);
});

it('matches on snippet', () => {
expect(matchesQuery(msg(), 'report')).toBe(true);
});

it('returns false when no field contains the query', () => {
expect(matchesQuery(msg(), 'zzz')).toBe(false);
});

it('tolerates missing fields', () => {
expect(matchesQuery({ id: 'x' }, 'anything')).toBe(false);
});
});

describe('filterMessages', () => {
const messages = [
msg({ id: 'a', subject: 'invoice due', from: { name: 'Acme', email: 'a@acme.com' } }),
msg({
id: 'b',
subject: 'lunch?',
from: { name: 'Bob', email: 'bob@x.com' },
snippet: 'pizza',
}),
];

it('returns the input unchanged for an empty query', () => {
expect(filterMessages(messages, '')).toBe(messages);
});

it('returns the input unchanged for a whitespace-only query', () => {
expect(filterMessages(messages, ' ')).toBe(messages);
});

it('returns only matching messages', () => {
const result = filterMessages(messages, 'invoice');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('a');
});

it('returns an empty array when nothing matches', () => {
expect(filterMessages(messages, 'zzz')).toEqual([]);
});

it('keeps a thread member that matches (filter is per-message)', () => {
const thread = [
msg({ id: 'c', threadId: 'tX', subject: 'no match here', snippet: 'plain' }),
msg({ id: 'd', threadId: 'tX', subject: 'has invoice', snippet: 'plain' }),
];
const result = filterMessages(thread, 'invoice');
expect(result.map((m) => m.id)).toEqual(['d']);
});
});
Loading