aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/mail/MailPage.qml
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/mail/MailPage.qml')
-rw-r--r--desktop/modules/mail/MailPage.qml220
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(); }
+ }
+ }
+}