diff --git a/README.md b/README.md index 0af0432..23df551 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,38 @@ that lets you pick any mailbox (folder) and instantly see: - **Unread messages per month** — bucketed by calendar month, sorted chronologically, and drawn as a bar chart. - **Senders by frequency** — every sender in the mailbox ranked by how many - messages they sent, with their unread count alongside. + messages they sent, with their unread count alongside. Senders with a single + message are collapsed behind a toggle to keep the list scannable. +- **Threads by size** — messages grouped by (normalised) subject, largest + conversations first. It also shows summary cards for total messages, total unread, and the number of unique senders. +Click any **month** (bar or table row), **sender name**, or **thread** to open +an in-page reader listing exactly those messages, **grouped into threads**. Skim +message bodies with the keyboard and triage without leaving the report: + +- ↑/↓ (or j/k) — move between rows +- ←/→ — collapse/expand the selected thread +- Del/Backspace — move to Trash +- A — archive +- M — move to another mailbox (a filterable folder list pops up) +- Esc — close the reader (or the move popup) + +When a **thread header** is selected, Del/A/M +apply to the **whole thread**; when an individual message is selected they apply +to just that message. + +The chart and tables update live as you delete, archive, or move. Skimming a +message does **not** mark it as read, so your unread counts stay meaningful. +Threads are approximated from subjects (Thunderbird's message headers don't +expose reply references without fetching each message in full). + +The report stays in sync with the folder while it's open: new mail, moves, +archiving, deletions, and read/unread changes made anywhere in Thunderbird are +reflected in the stats and tables (and in the reader, if open) automatically. + ## How it works Click the **⚡ Mailbox Analytics** toolbar button. A tab opens with a mailbox @@ -36,8 +63,12 @@ the root of the zip) and rename it to `thunderstruck.xpi`. ## Permissions - `accountsRead` — to enumerate accounts and folders for the picker. -- `messagesRead` — to read message headers (author, date, read state) in the - selected folder. Message bodies are never accessed. +- `messagesRead` — to read message headers (author, date, read state) for the + report, and message bodies on demand when you open one in the reader. +- `messagesMove` — to archive a message from the reader (A). +- `messagesDelete` — to move a message to Trash from the reader (Del). +- `tabs` — to open the analytics page in a tab and reuse it instead of opening + a new one on every click. ## Notes & compatibility diff --git a/analytics.css b/analytics.css index 3ebd81a..849a226 100644 --- a/analytics.css +++ b/analytics.css @@ -186,3 +186,322 @@ canvas { color: var(--muted); font-size: 0.82rem; } + +/* Clickable rows / cells that open the reader. */ +.data-table tr.clickable, +.data-table td.clickable { + cursor: pointer; +} + +.data-table tr.clickable:hover, +.data-table td.clickable:hover { + background: var(--accent-soft); +} + +.data-table td.clickable > div:first-child { + color: var(--accent); +} + +.data-table td.clickable { + color: var(--accent); +} + +/* Expand/collapse toggle row (e.g. one-message senders). */ +.data-table tr.toggle-row td { + color: var(--accent); + cursor: pointer; + font-size: 0.85rem; + user-select: none; +} + +.data-table tr.toggle-row:hover td { + background: var(--accent-soft); +} + +/* --- Reader overlay ------------------------------------------------------ */ + +.reader { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + padding: 2vh 2vw; + background: rgba(0, 0, 0, 0.45); +} + +.reader[hidden] { + display: none; +} + +.reader-panel { + position: relative; + display: flex; + flex-direction: column; + width: min(1100px, 100%); + height: min(92vh, 100%); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} + +.reader-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); +} + +.reader-header h2 { + margin: 0; + font-size: 1.05rem; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reader-status { + color: var(--muted); + font-size: 0.85rem; + flex: 0 0 auto; +} + +.reader-close { + flex: 0 0 auto; + padding: 0.3rem 0.6rem; + background: transparent; + color: var(--muted); + font-size: 1rem; +} + +.reader-close:hover { + color: var(--fg); +} + +.reader-body { + flex: 1 1 auto; + display: grid; + grid-template-columns: minmax(240px, 340px) 1fr; + min-height: 0; +} + +.reader-list { + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; + border-right: 1px solid var(--border); + outline: none; +} + +.reader-item { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + cursor: pointer; +} + +.reader-item.selected { + background: var(--accent-soft); +} + +.reader-item .reader-subject { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reader-item.unread .reader-subject::before { + content: ""; + display: inline-block; + width: 7px; + height: 7px; + margin-right: 0.4rem; + border-radius: 50%; + background: var(--unread); + vertical-align: middle; +} + +.reader-item .reader-meta { + color: var(--muted); + font-size: 0.8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Thread header rows and their child messages. */ +.reader-item.thread-row .reader-line { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.reader-item .twisty { + flex: 0 0 auto; + width: 1em; + color: var(--muted); + cursor: pointer; + user-select: none; +} + +.reader-item.thread-row .reader-subject { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reader-item .thread-count { + flex: 0 0 auto; + background: var(--accent-soft); + color: var(--accent); + border-radius: 999px; + padding: 0 0.5rem; + font-size: 0.75rem; + font-weight: 600; +} + +.reader-item.child { + padding-left: 1.75rem; +} + +.reader-item.child .reader-subject { + font-weight: 400; +} + +.preview-note { + display: inline-block; + margin-bottom: 0.4rem; + padding: 0.1rem 0.5rem; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.78rem; + font-weight: 600; +} + +/* Move-to-mailbox popup, layered over the reader panel. */ +.move-popup { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12vh; + background: rgba(0, 0, 0, 0.35); +} + +.move-popup[hidden] { + display: none; +} + +.move-panel { + width: min(520px, 92%); + max-height: 70%; + display: flex; + flex-direction: column; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4); +} + +#move-filter { + border: none; + border-bottom: 1px solid var(--border); + padding: 0.7rem 0.9rem; + font-size: 0.95rem; + background: var(--bg); + color: var(--fg); + outline: none; +} + +.move-list { + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; +} + +.move-item { + padding: 0.45rem 0.9rem; + cursor: pointer; + font-size: 0.9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.move-item.selected, +.move-item:hover { + background: var(--accent-soft); +} + +.reader-preview { + overflow-y: auto; + padding: 1rem 1.25rem; + min-width: 0; +} + +.preview-head { + border-bottom: 1px solid var(--border); + padding-bottom: 0.6rem; + margin-bottom: 0.8rem; +} + +.preview-head h3 { + margin: 0 0 0.3rem; + font-size: 1.1rem; +} + +.preview-from { + font-size: 0.9rem; +} + +.preview-when { + color: var(--muted); + font-size: 0.82rem; +} + +.preview-body { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-word; +} + +.reader-help { + padding: 0.5rem 1rem; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 0.8rem; +} + +.reader-help kbd { + font-family: inherit; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.05rem 0.35rem; + margin: 0 0.1rem; +} + +@media (max-width: 640px) { + .reader-body { + grid-template-columns: 1fr; + grid-template-rows: 40% 60%; + } + .reader-list { + border-right: none; + border-bottom: 1px solid var(--border); + } +} diff --git a/analytics.html b/analytics.html index 4f7bbd0..b4ee121 100644 --- a/analytics.html +++ b/analytics.html @@ -38,6 +38,18 @@ +
+

Threads by size

+

Messages grouped by subject, largest conversations first. Click one to open it.

+ + + + + + +
#ThreadMessagesUnread
+
+

Senders by frequency

Counts every message in the mailbox grouped by sender, most frequent first.

@@ -50,6 +62,30 @@
+ + diff --git a/analytics.js b/analytics.js index 3517f4b..ea1ad3b 100644 --- a/analytics.js +++ b/analytics.js @@ -4,6 +4,10 @@ // 1. Unread messages bucketed by calendar month (drawn as a bar chart). // 2. Senders ranked by how many messages each one sent. // +// Clicking a month (bar or table row) or a sender name opens an in-page reader +// showing exactly those messages, where you can skim bodies with the keyboard +// and delete (Del/Backspace) or archive (A) them. +// // The chart is drawn by hand on a so the extension has zero runtime // dependencies and works offline under the default WebExtension CSP. @@ -16,11 +20,37 @@ const chartCanvas = document.getElementById("chart"); const chartEmpty = document.getElementById("chart-empty"); const monthTableBody = document.querySelector("#month-table tbody"); const senderTableBody = document.querySelector("#sender-table tbody"); +const threadTableBody = document.querySelector("#thread-table tbody"); +const threadEmptyEl = document.getElementById("thread-empty"); + +const readerEl = document.getElementById("reader"); +const readerTitleEl = document.getElementById("reader-title"); +const readerStatusEl = document.getElementById("reader-status"); +const readerListEl = document.getElementById("reader-list"); +const readerPreviewEl = document.getElementById("reader-preview"); +const readerCloseBtn = document.getElementById("reader-close"); +const movePopupEl = document.getElementById("move-popup"); +const moveFilterEl = document.getElementById("move-filter"); +const moveListEl = document.getElementById("move-list"); // Flat list of folder objects;