diff options
Diffstat (limited to 'desktop/modules/mail/MailPage.qml')
| -rw-r--r-- | desktop/modules/mail/MailPage.qml | 220 |
1 files changed, 220 insertions, 0 deletions
diff --git a/desktop/modules/mail/MailPage.qml b/desktop/modules/mail/MailPage.qml new file mode 100644 index 0000000..85b0876 --- /dev/null +++ b/desktop/modules/mail/MailPage.qml @@ -0,0 +1,220 @@ +// 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. + +import Quickshell +import Quickshell.Io +import QtQuick +import "../.." + +Column { + id: page + + signal close + + // mail-watcher's heartbeat, written every 60s. Read once per open: the + // page is behind a Loader, so opening it rebuilds the FileView and reads + // the current file. Watch is deliberately not used, because the heartbeat + // is written by atomic replace (tmpfile + rename) and an inotify watch + // held on the old inode dies with it. + property bool watcherAlive: false + property int watcherDead: 0 + property int watcherExpected: 0 + + // The same staleness rule as mail-watcher's heartbeat_is_healthy: dead + // threads or a heartbeat older than 300s mean the watcher needs a look. + // Backoff is healthy, so it never turns the dot. + function readHeartbeat(payload) { + page.watcherAlive = false; + page.watcherDead = 0; + page.watcherExpected = 0; + + let data = null; + try { data = JSON.parse(payload); } catch (e) { return; } + if (!data || typeof data.ts !== "string") return; + + const ts = Date.parse(data.ts); + if (isNaN(ts) || (Date.now() - ts) / 1000 > 300) return; + + page.watcherAlive = true; + page.watcherDead = Number(data.dead) || 0; + page.watcherExpected = Number(data.expected) || 0; + } + + readonly property color watcherColor: + !watcherAlive ? Theme.red + : watcherDead > 0 ? Theme.yellow + : Theme.green + + readonly property string watcherText: + !watcherAlive ? "watcher not running" + : watcherDead > 0 ? `${watcherDead} folder(s) dead, check the log` + : `watcher ok · ${watcherExpected} folders` + + spacing: 10 + + FileView { + id: heartbeat + path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat` + onLoaded: page.readHeartbeat(text()) + onLoadFailed: page.readHeartbeat("") + } + + Item { + width: parent.width + implicitHeight: totalText.implicitHeight + + Text { + id: totalText + anchors.right: parent.right + // A dash rather than a possibly-wrong number while any account is + // still unknown. + text: Accounts.anyUnknown ? "—" : `${Accounts.total} unread` + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + Text { + visible: Accounts.error !== "" + width: parent.width + text: Accounts.error + color: Theme.red + wrapMode: Text.WordWrap + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + + Repeater { + model: Accounts.accounts + + Column { + required property var modelData + width: page.width + spacing: 4 + + Item { + width: parent.width + implicitHeight: 26 + + Rectangle { + id: dot + anchors.verticalCenter: parent.verticalCenter + width: 8; height: 8; radius: 4 + // The account's own colour from the config. Per-account + // identity, not a palette. + color: modelData.color || Theme.accent + } + + Text { + anchors { left: dot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: modelData.label + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Text { + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: modelData.count < 0 ? "—" : String(modelData.count) + color: modelData.count > 0 ? Theme.text : Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.bold: modelData.count > 0 + } + } + + // The newest three unread threads. Read-only: qtmaildir takes no + // arguments, so there is no way to ask it for a particular thread. + Repeater { + model: modelData.threads + + Column { + required property var modelData + width: page.width - 18 + x: 18 + spacing: 1 + bottomPadding: 4 + + Item { + width: parent.width + implicitHeight: who.implicitHeight + + Text { + id: who + anchors.left: parent.left + width: parent.width - when.implicitWidth - 10 + text: modelData.authors + elide: Text.ElideRight + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 3 + } + + Text { + id: when + anchors.right: parent.right + text: modelData.date + color: Theme.overlay + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 3 + } + } + + Text { + width: parent.width + text: modelData.subject + elide: Text.ElideRight + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + } + } + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + // Watcher health. Green when idling, yellow when a folder gave up, red + // when there is no fresh heartbeat at all. + Item { + width: parent.width + implicitHeight: 22 + + Rectangle { + id: watcherDot + anchors.verticalCenter: parent.verticalCenter + width: 8; height: 8; radius: 4 + color: page.watcherColor + } + + Text { + anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: page.watcherText + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + Row { + anchors.right: parent.right + spacing: 10 + + Button { + text: "Open qtmaildir" + onClicked: { Accounts.openClient(); page.close(); } + } + } +} |
