diff options
| -rw-r--r-- | desktop/modules/mail/Accounts.qml | 19 | ||||
| -rw-r--r-- | desktop/modules/mail/MailModule.qml | 2 | ||||
| -rw-r--r-- | desktop/modules/mail/MailPage.qml | 17 | ||||
| -rw-r--r-- | desktop/modules/mail/README.md | 38 | ||||
| -rwxr-xr-x | desktop/modules/mail/mail-notify.sh | 73 | ||||
| -rwxr-xr-x | desktop/modules/mail/mail-open.sh | 46 | ||||
| -rwxr-xr-x | desktop/modules/mail/test-mail-notify.sh | 13 |
7 files changed, 153 insertions, 55 deletions
diff --git a/desktop/modules/mail/Accounts.qml b/desktop/modules/mail/Accounts.qml index c2bc9d8..4f8b114 100644 --- a/desktop/modules/mail/Accounts.qml +++ b/desktop/modules/mail/Accounts.qml @@ -35,7 +35,7 @@ Singleton { // it is singular and the rows always sum to the header. readonly property string scope: "tag:unread and tag:inbox" - // [{ key, label, color, count, threads: [{ authors, date, subject }] }] + // [{ key, label, color, count, threads: [{ thread, authors, date, subject }] }] // count is -1 until known, and stays -1 on failure: a zero there would // read as an empty inbox. property var accounts: [] @@ -170,6 +170,7 @@ Singleton { const rows = JSON.parse(parts[1]); if (Array.isArray(rows)) { acct.threads = rows.map(r => ({ + thread: String(r.thread ?? ""), authors: String(r.authors ?? ""), date: String(r.date_relative ?? ""), subject: String(r.subject ?? "(no subject)"), @@ -206,12 +207,16 @@ Singleton { Timer { id: debounce; interval: 300; onTriggered: root.refresh() } Timer { id: respawn; interval: 5000; onTriggered: watcher.running = true } - // Launching qtmaildir. It takes no arguments, so there is nothing to tell - // it about the account or thread clicked. - Process { id: openProc; command: [`${Quickshell.env("HOME")}/bin/qtmaildir`] } - + // Launching qtmaildir, detached: a running client takes the selectors + // over its socket and the launch exits, but a first launch is the client + // itself and must not belong to a Process that the next click restarts. function openClient() { - openProc.running = false; - openProc.running = true; + Quickshell.execDetached([`${Quickshell.env("HOME")}/bin/qtmaildir`]); + } + + // An account, or a thread's newest unread message in it. mail-open.sh is + // shared with the notifications, so both open mail the same way. + function open(key, thread) { + Quickshell.execDetached([`${Quickshell.shellDir}/modules/mail/mail-open.sh`, key, thread || ""]); } } diff --git a/desktop/modules/mail/MailModule.qml b/desktop/modules/mail/MailModule.qml index 5f501ba..0c71b2c 100644 --- a/desktop/modules/mail/MailModule.qml +++ b/desktop/modules/mail/MailModule.qml @@ -40,7 +40,7 @@ Module { Component.onCompleted: Accounts.refresh() MailPage { width: parent.width - onClose: mailPage.back() + onClose: mailPage.dismiss() } } } diff --git a/desktop/modules/mail/MailPage.qml b/desktop/modules/mail/MailPage.qml index 85b0876..1b41412 100644 --- a/desktop/modules/mail/MailPage.qml +++ b/desktop/modules/mail/MailPage.qml @@ -97,6 +97,7 @@ Column { model: Accounts.accounts Column { + id: account required property var modelData width: page.width spacing: 4 @@ -105,6 +106,12 @@ Column { width: parent.width implicitHeight: 26 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { Accounts.open(modelData.key); page.close(); } + } + Rectangle { id: dot anchors.verticalCenter: parent.verticalCenter @@ -132,12 +139,13 @@ Column { } } - // The newest three unread threads. Read-only: qtmaildir takes no - // arguments, so there is no way to ask it for a particular thread. + // The newest three unread threads. A click opens the thread's + // newest unread message, in this account. Repeater { model: modelData.threads Column { + id: row required property var modelData width: page.width - 18 x: 18 @@ -177,6 +185,11 @@ Column { font.family: Theme.fontFamily font.pixelSize: Theme.fontSize - 2 } + + // Handlers, not a MouseArea: a Column would lay one out + // as a row of its own rather than let it fill. + HoverHandler { cursorShape: Qt.PointingHandCursor } + TapHandler { onTapped: { Accounts.open(account.modelData.key, row.modelData.thread); page.close(); } } } } } diff --git a/desktop/modules/mail/README.md b/desktop/modules/mail/README.md index 2349da6..5083c34 100644 --- a/desktop/modules/mail/README.md +++ b/desktop/modules/mail/README.md @@ -156,15 +156,19 @@ login. The same applies to a restart, so mail that arrived while it was down is never notified. The drawer count is still right and still shows it, so nothing is lost but a stale popup. -The notification is sent with dunstify. The icon is passed as an **absolute -path** to the active icon theme's `mail-unread-multiple`, because this dunst -resolves an icon *name* only through the `icon_path` in `dunstrc`, and that -path holds no mail icon, so a name renders no icon at all. The click action -(`-A default,open`) makes dunst prepend an `(A)` action indicator when -`show_indicators` is on, so `dunstrc` sets `show_indicators = no`. A middle -click (dunst's `do_action`) opens qtmaildir, but not the account it belongs -to: `qtmaildir` takes no arguments and `startup_account` is a static setting -rather than a flag, the same limitation the thread rows have. +The notification is sent with `notify-send` (libnotify 0.8), whose `-A +NAME=Text` prints the picked action's name; dunstify is not installed. The +icon is passed as an **absolute path** to the active icon theme's +`mail-unread-multiple`, so it does not depend on how the daemon resolves icon +names. + +Each listed thread is an action, keyed by its thread id, and the account is +the `default` one. A click on the balloon offers them in the notifications' +picker. The picked one runs `mail-open.sh`: the account opens qtmaildir on that +account, a thread on its newest unread message. Thread ids rather than +Message-IDs are the keys because they are hex, so a key never carries the `=` +that separates it from its label; the message is looked up when the action is +picked, and if it was read in the meantime the whole thread opens instead. `test-mail-notify.sh` is the check. It sources the script as a library and asserts on the functions that only move text around, so it needs no mail and no @@ -191,15 +195,15 @@ respected. `Keys.onEscapePressed` on a `PanelWindow` never fires, so the focus sits on an inner `Item`. -## Thread rows are read-only +## Opening mail from the page -Each account shows its three newest unread threads as author, relative date and -subject. Clicking one launches qtmaildir and nothing more, because `qtmaildir` -accepts no command line arguments: there is no way to tell it which thread to -open. `startup_account` in its config is a static setting, not a flag, so -"open on this account" is unavailable for the same reason. Marking read from -here was rejected rather than deferred: it would write the database behind a -possibly running client. +A click on an account's header opens qtmaildir on that account, and a click on +a thread row opens that thread's newest unread message, through the same +`mail-open.sh` the notifications use. qtmaildir hands a second launch's +selectors to the running window, so neither opens a second client. Launches +are detached: a first launch *is* the client, and a reused `Process` would kill +it on the next click. Marking read from here was rejected rather than +deferred: it would write the database behind a possibly running client. "Open qtmaildir" launches the client. There is no "Sync now" button: the watcher triggers a sync the moment mail lands, the cron tick is the backstop, diff --git a/desktop/modules/mail/mail-notify.sh b/desktop/modules/mail/mail-notify.sh index 968799c..0052a8d 100755 --- a/desktop/modules/mail/mail-notify.sh +++ b/desktop/modules/mail/mail-notify.sh @@ -32,6 +32,7 @@ set -u STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}" CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}" SCOPE='tag:unread and tag:inbox' +OPEN="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/mail-open.sh" # How many threads a notification body lists before eliding into "+N more". # Three matches the drawer's own --limit=3. @@ -119,6 +120,25 @@ build_body() { printf '\n' } +# One action per listed thread, "<thread-id>=<label>" per line, +# followed by the account itself as the default action. The balloon shell +# offers them in a picker on click. +# +# The key is the thread id, not a Message-ID: thread ids are hex, so a key +# never carries an "=" or a quote, and mail-open.sh finds the message when +# the action is picked. The label is plain text for rofi, so no escaping, but +# it is flattened to one line and shortened, since it is a menu entry. +build_actions() { + local json="$1" label="$2" + + printf '%s' "$json" | jq -r ' + .[] | select(.thread | test("^[0-9a-f]+$")) + | .thread + "=" + ((.authors // "(unknown)") + " — " + (.subject // "(no subject)") + | gsub("[\r\n]+"; " ") | .[0:80]) + ' 2>/dev/null + printf 'default=Open %s\n' "$label" +} + # The last revision this script notified up to, or empty when there is none # to trust. Empty means "seed silently": record where we are now and notify # nothing. @@ -158,41 +178,38 @@ write_state() { # Sends one notification for one account. # -# dunstify rather than notify-send because actions need it. A stack tag per -# account means a second batch for the same account replaces the first rather -# than stacking, which is what "one notification per account" has to mean when -# mail keeps arriving. +# notify-send, whose -A takes NAME=Text and prints the picked NAME, which is +# all the actions need; dunstify is not installed here. NAME is split on the +# first "=", so a label may contain one. A stack tag per account means a +# second batch for the same account replaces the first rather than stacking, +# which is what "one notification per account" has to mean when mail keeps +# arriving. # # Normal urgency and an explicit 10s timeout, deliberately not -u critical: -# on most dunst configurations critical notifications never expire, which -# would leave mail popups on screen until clicked. +# critical notifications commonly never expire, which would leave mail popups +# on screen until clicked. # -# The click cannot open the account it belongs to. qtmaildir accepts no -# command line arguments and startup_account is a static config setting, not -# a flag, which is the same limitation the drawer's thread rows already have. +# The picked action opens qtmaildir through mail-open.sh: a thread's key opens +# its newest unread message, "default" the account. notify_account() { - local label="$1" key="$2" count="$3" body="$4" - - # -a carries "New Mail" because the user's dunst format renders %a as the - # bold heading line, with %s italic below it. - if ! command -v dunstify >/dev/null 2>&1; then - # No actions available, but a notification without a click is still - # worth having. - notify-send -a "New Mail" -u normal -t 10000 -i "$MAIL_ICON" \ - "$label ($count)" "$body" - return 0 - fi + local label="$1" key="$2" count="$3" body="$4" rows="$5" + local acts=() line picked + + while IFS= read -r line; do acts+=(-A "$line"); done < <(build_actions "$rows" "$label") - # Backgrounded because -b blocks until the notification is dismissed or - # clicked. Without this the loop would stall for the full timeout on + # Backgrounded because -A waits until the notification is dismissed or + # acted on. Without this the loop would stall for the full timeout on # every account, and a five-account batch would take most of a minute. + # -a carries "New Mail" because it renders as the bold heading line. ( - if [[ "$(dunstify -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 -b \ + picked="$(notify-send -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 \ -h "string:x-dunst-stack-tag:mail-$key" \ - -A "default,open" \ - "$label ($count)" "$body")" == "default" ]]; then - "$HOME/bin/qtmaildir" & - fi + "${acts[@]}" \ + "$label ($count)" "$body")" + case "$picked" in + default) "$OPEN" "$key" & ;; + [0-9a-f]*) "$OPEN" "$key" "$picked" & ;; + esac ) >/dev/null 2>&1 & } @@ -243,7 +260,7 @@ tick() { rows="$(notmuch search --format=json --limit="$ROWS" \ --sort=newest-first "$query" 2>/dev/null)" || rows="[]" - notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")" + notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")" "$rows" done < <(parse_accounts < "$CONFIG") # Written only after every account is done. A single account whose count diff --git a/desktop/modules/mail/mail-open.sh b/desktop/modules/mail/mail-open.sh new file mode 100755 index 0000000..90877bd --- /dev/null +++ b/desktop/modules/mail/mail-open.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. <danix@danix.xyz> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# Opens qtmaildir on an account, or on a thread's newest unread message. +# Shared by the drawer's mail page and mail-notify.sh, so both open mail the +# same way. +# +# mail-open.sh <account-key> [thread-id] +# +# A running qtmaildir takes the selectors over its socket and raises, so this +# never opens a second window. +# +# The message is looked up at click time rather than when the row or the +# notification was built: by then it may have been read, and an unread-only +# lookup that finds nothing falls back to the whole thread instead of a miss. +# Newest, because that is the message the row's date and the notification +# are about. + +set -u + +key="${1:?usage: mail-open.sh <account-key> [thread-id]}" +tid="${2:-}" +args=(--account "$key") + +# notmuch thread ids are hex; anything else never reaches a query. +if [[ "$tid" =~ ^[0-9a-f]+$ ]]; then + id="$(notmuch search --format=json --output=messages --sort=newest-first --limit=1 \ + "thread:$tid and tag:unread and tag:inbox" 2>/dev/null | jq -r '.[0] // empty' 2>/dev/null)" + if [[ -n "$id" ]]; then + args+=(--message "$id") + else + args+=(--thread "$tid") + fi +fi + +exec "$HOME/bin/qtmaildir" "${args[@]}" diff --git a/desktop/modules/mail/test-mail-notify.sh b/desktop/modules/mail/test-mail-notify.sh index 1c3e01a..4ca5d00 100755 --- a/desktop/modules/mail/test-mail-notify.sh +++ b/desktop/modules/mail/test-mail-notify.sh @@ -119,6 +119,19 @@ check "a newline in a subject stays on one row" \ "• Alice Example — line one line two" \ "$(build_body '[{"authors":"Alice Example","subject":"line one\nline two"}]' 1)" +check "one action per thread, then the account as default" \ + "00a1=Alice Example — First subject +default=Open Work" \ + "$(build_actions '[{"thread":"00a1","authors":"Alice Example","subject":"First subject"}]' Work)" + +check "a thread id that is not hex gets no action" \ + "default=Open Work" \ + "$(build_actions '[{"thread":"x=y","authors":"A","subject":"B"}]' Work)" + +check "malformed json still leaves the account action" \ + "default=Open Work" \ + "$(build_actions 'not json' Work)" + # A scratch state file, removed at exit. Never the real one: that is the # user's live notification position, and a test must not move it. tmpstate="$(mktemp)" |
