mirror of
https://github.com/prdlk/omarchy-beeper.git
synced 2026-11-02 13:12:48 +00:00
869 lines
27 KiB
QML
869 lines
27 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
// Beeper: unread conversations only. Click a row to open that chat in Beeper
|
|
// Desktop and mark it read.
|
|
//
|
|
// Data comes from `bin/omarchy-beeper`, which is the only thing that talks to
|
|
// the local Beeper API. This file never sees the token and never opens a
|
|
// socket; it runs the CLI and parses one JSON object from stdout.
|
|
//
|
|
// Every string below the header is text someone else wrote, so each Text
|
|
// carries `textFormat: Text.PlainText`.
|
|
Panel {
|
|
id: root
|
|
|
|
moduleName: "prdlk.beeper"
|
|
ipcTarget: "prdlk.beeper"
|
|
|
|
readonly property string script:
|
|
Qt.resolvedUrl("bin/omarchy-beeper").toString().replace(/^file:\/\//, "")
|
|
|
|
readonly property string iconOpenApp: "\uF08E"
|
|
readonly property string iconMarkAll: "\uF2B6"
|
|
readonly property string iconConfirm: "\uF00C"
|
|
readonly property string iconPrev: "\uF053"
|
|
readonly property string iconNext: "\uF054"
|
|
|
|
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
|
readonly property color accent: Color.accent
|
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
|
|
|
property var messages: []
|
|
property int unread: 0
|
|
property var inboxes: []
|
|
property bool reachable: true
|
|
property string errorText: ""
|
|
property string warningText: ""
|
|
property string pendingId: ""
|
|
property var readQueue: []
|
|
property var dismissedIds: ({})
|
|
property bool markAllArmed: false
|
|
property bool markAllBusy: false
|
|
property string actionWarning: ""
|
|
property int cursor: -1
|
|
|
|
property string pageToken: ""
|
|
property var pageStack: []
|
|
property string nextPage: ""
|
|
property int accountCount: 0
|
|
readonly property bool hasPrev: pageStack.length > 0
|
|
readonly property bool hasNext: nextPage !== ""
|
|
|
|
property double now: 0
|
|
|
|
readonly property int badgeCount: unread
|
|
readonly property bool hasUnread: unread > 0
|
|
|
|
readonly property int badgeWidth: badgeCount > 0
|
|
? Math.max(Style.space(12), String(badgeCount).length * Style.space(6) + Style.space(8))
|
|
: 0
|
|
readonly property int barContentWidth: Style.bar.iconFont + badgeWidth + Style.space(5)
|
|
readonly property int barSlot: barContentWidth + Style.space(10)
|
|
|
|
implicitWidth: bar && bar.vertical ? (bar ? bar.barSize : Style.bar.sizeHorizontal) : barSlot
|
|
implicitHeight: bar && bar.vertical ? barSlot : (bar ? bar.barSize : Style.bar.sizeHorizontal)
|
|
|
|
// Page tokens are plain offsets produced by the CLI.
|
|
function validToken(t) {
|
|
return /^[0-9]{1,9}$/.test(String(t))
|
|
}
|
|
|
|
// beeper:<base64url of the chat id>, exactly what `list` emits.
|
|
function validId(id) {
|
|
return /^beeper:[A-Za-z0-9_-]{1,1024}$/.test(String(id))
|
|
}
|
|
|
|
readonly property int pageSize: {
|
|
var n = parseInt(setting("max", 25), 10)
|
|
if (!(n > 0)) n = 25
|
|
return Math.max(1, Math.min(50, n))
|
|
}
|
|
readonly property int refreshMs: {
|
|
var n = parseInt(setting("refreshIntervalSec", 60), 10)
|
|
if (!(n > 0)) n = 60
|
|
return Math.max(15, Math.min(3600, n)) * 1000
|
|
}
|
|
|
|
function refresh() {
|
|
if (listProc.running || root.markAllBusy) return
|
|
var argv = [root.script, "list", "--limit", String(root.pageSize)]
|
|
if (pageToken !== "" && validToken(pageToken)) argv.push("--page", pageToken)
|
|
listProc.command = argv
|
|
listProc.running = true
|
|
}
|
|
|
|
function goNextPage() {
|
|
if (!hasNext || listProc.running || root.markAllBusy) return
|
|
var stack = pageStack.slice()
|
|
stack.push(pageToken)
|
|
pageStack = stack
|
|
pageToken = nextPage
|
|
cursor = -1
|
|
refresh()
|
|
}
|
|
|
|
function goPrevPage() {
|
|
if (!hasPrev || listProc.running || root.markAllBusy) return
|
|
var stack = pageStack.slice()
|
|
pageToken = stack.pop()
|
|
pageStack = stack
|
|
cursor = -1
|
|
refresh()
|
|
}
|
|
|
|
function firstPage() {
|
|
pageToken = ""
|
|
pageStack = []
|
|
cursor = -1
|
|
}
|
|
|
|
function titleText() {
|
|
if (root.unread === 1) return "1 unread chat"
|
|
return root.unread + " unread chats"
|
|
}
|
|
|
|
function tooltipFor() {
|
|
if (!root.reachable)
|
|
return root.errorText !== "" ? root.errorText : "Beeper unreachable"
|
|
if (!root.hasUnread) return "No unread chats"
|
|
return root.titleText()
|
|
}
|
|
|
|
// "WhatsApp 3 · Slack 1", newest networks first, so the header says where
|
|
// the pile is without opening every row.
|
|
function inboxSummary() {
|
|
var parts = []
|
|
var list = root.inboxes || []
|
|
for (var i = 0; i < list.length && parts.length < 3; i++) {
|
|
var box = list[i] || {}
|
|
var n = parseInt(box.unread, 10)
|
|
var name = String(box.account || "")
|
|
if (!(n > 0) || name === "") continue
|
|
parts.push(name + " " + n)
|
|
}
|
|
return parts.join(" · ")
|
|
}
|
|
|
|
function rememberDismissed(id) {
|
|
var next = Object.assign({}, root.dismissedIds)
|
|
next[id] = true
|
|
root.dismissedIds = next
|
|
}
|
|
|
|
function dismissLocal(id) {
|
|
rememberDismissed(id)
|
|
var next = []
|
|
for (var i = 0; i < messages.length; i++) {
|
|
if (messages[i].id !== id) next.push(messages[i])
|
|
}
|
|
messages = next
|
|
if (unread > 0) unread -= 1
|
|
if (cursor > messages.length - 1) cursor = messages.length - 1
|
|
}
|
|
|
|
function enqueueRead(id) {
|
|
var q = root.readQueue.slice()
|
|
q.push(id)
|
|
root.readQueue = q
|
|
root.pumpRead()
|
|
}
|
|
|
|
function pumpRead() {
|
|
if (readProc.running || root.readQueue.length === 0) return
|
|
var q = root.readQueue.slice()
|
|
var id = q.shift()
|
|
root.readQueue = q
|
|
root.pendingId = id
|
|
readProc.command = [root.script, "read", id]
|
|
readProc.running = true
|
|
}
|
|
|
|
// Beeper has no web URL for a chat, so opening one means asking Beeper
|
|
// Desktop to focus it through the same local API.
|
|
function openChat(message) {
|
|
if (root.markAllBusy) return
|
|
if (!message || !validId(message.id)) return
|
|
openProc.command = [root.script, "open", message.id]
|
|
openProc.running = true
|
|
dismissLocal(message.id)
|
|
enqueueRead(message.id)
|
|
close()
|
|
}
|
|
|
|
function openBeeper() {
|
|
if (root.markAllBusy) return
|
|
openProc.command = [root.script, "open"]
|
|
openProc.running = true
|
|
close()
|
|
}
|
|
|
|
function markCursorRead() {
|
|
if (root.markAllBusy) return
|
|
if (cursor < 0 || cursor >= messages.length) return
|
|
var message = messages[cursor]
|
|
if (!message || !validId(message.id)) return
|
|
cancelMarkAllConfirm()
|
|
dismissLocal(message.id)
|
|
enqueueRead(message.id)
|
|
}
|
|
|
|
function cancelMarkAllConfirm() {
|
|
markAllArmed = false
|
|
if (markAllArmTimer.running) markAllArmTimer.stop()
|
|
}
|
|
|
|
function requestMarkAll() {
|
|
if (!root.hasUnread || !root.reachable || root.markAllBusy) return
|
|
if (!root.markAllArmed) {
|
|
root.markAllArmed = true
|
|
markAllArmTimer.restart()
|
|
return
|
|
}
|
|
root.cancelMarkAllConfirm()
|
|
root.markAllBusy = true
|
|
readAllProc.command = [root.script, "read-all"]
|
|
readAllProc.running = true
|
|
}
|
|
|
|
function applyReadAllPayload(text) {
|
|
root.markAllBusy = false
|
|
root.cancelMarkAllConfirm()
|
|
root.dismissedIds = ({})
|
|
try {
|
|
var data = JSON.parse(text)
|
|
var marked = parseInt(data.marked, 10)
|
|
if (!(marked > 0)) marked = 0
|
|
if (data.ok === true) {
|
|
root.actionWarning = data.warning || ""
|
|
firstPage()
|
|
refresh()
|
|
return
|
|
}
|
|
root.actionWarning = data.error || "could not mark all as read"
|
|
if (marked > 0) {
|
|
firstPage()
|
|
refresh()
|
|
}
|
|
} catch (e) {
|
|
root.actionWarning = "unexpected output from omarchy-beeper"
|
|
}
|
|
}
|
|
|
|
function moveCursor(delta) {
|
|
if (messages.length === 0) return
|
|
var next = cursor + delta
|
|
if (next < 0) next = 0
|
|
if (next > messages.length - 1) next = messages.length - 1
|
|
cursor = next
|
|
list.positionViewAtIndex(next, ListView.Contain)
|
|
}
|
|
|
|
function activateCursor() {
|
|
if (cursor < 0 || cursor >= messages.length) return
|
|
openChat(messages[cursor])
|
|
}
|
|
|
|
function ageLabel(ts) {
|
|
if (!ts || ts <= 0) return ""
|
|
var seconds = Math.max(0, root.now - ts)
|
|
if (seconds < 60) return "now"
|
|
if (seconds < 3600) return Math.floor(seconds / 60) + "m"
|
|
if (seconds < 86400) return Math.floor(seconds / 3600) + "h"
|
|
if (seconds < 604800) return Math.floor(seconds / 86400) + "d"
|
|
if (seconds < 2592000) return Math.floor(seconds / 604800) + "w"
|
|
return Qt.formatDate(new Date(ts * 1000), "d MMM")
|
|
}
|
|
|
|
function oneLine(value) {
|
|
return String(value || "").replace(/\s+/g, " ").trim()
|
|
}
|
|
|
|
function applyPayload(text) {
|
|
try {
|
|
var data = JSON.parse(text)
|
|
reachable = data.ok === true
|
|
errorText = data.error || ""
|
|
warningText = reachable ? (data.warning || "") : ""
|
|
// Keep actionWarning across this refresh: a write can fail while list works.
|
|
if (!reachable) return
|
|
var incoming = data.messages || []
|
|
var kept = []
|
|
var dropped = 0
|
|
for (var i = 0; i < incoming.length; i++) {
|
|
var row = incoming[i]
|
|
if (row && root.dismissedIds[row.id]) dropped += 1
|
|
else kept.push(row)
|
|
}
|
|
messages = kept
|
|
unread = Math.max(0, (data.unread || 0) - dropped)
|
|
inboxes = data.inboxes || []
|
|
accountCount = data.accountCount || 0
|
|
nextPage = validToken(data.nextPage) ? data.nextPage : ""
|
|
if (cursor > messages.length - 1) cursor = messages.length - 1
|
|
} catch (e) {
|
|
reachable = false
|
|
errorText = "unexpected output from omarchy-beeper"
|
|
}
|
|
}
|
|
|
|
onOpenedChanged: {
|
|
if (opened) {
|
|
now = Date.now() / 1000
|
|
refresh()
|
|
} else {
|
|
cursor = -1
|
|
firstPage()
|
|
cancelMarkAllConfirm()
|
|
actionWarning = ""
|
|
dismissedIds = ({})
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: now = Date.now() / 1000
|
|
|
|
Process {
|
|
id: listProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.applyPayload(text)
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: readProc
|
|
onExited: function(exitCode) {
|
|
root.pendingId = ""
|
|
if (root.readQueue.length > 0) {
|
|
root.pumpRead()
|
|
return
|
|
}
|
|
root.refresh()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: openProc
|
|
}
|
|
|
|
Process {
|
|
id: readAllProc
|
|
stdout: StdioCollector {
|
|
onStreamFinished: root.applyReadAllPayload(text)
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
interval: root.refreshMs
|
|
running: true
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: {
|
|
root.now = Date.now() / 1000
|
|
root.refresh()
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: markAllArmTimer
|
|
interval: 4000
|
|
repeat: false
|
|
onTriggered: root.markAllArmed = false
|
|
}
|
|
|
|
BarIconButton {
|
|
id: button
|
|
anchors.fill: parent
|
|
bar: root.bar
|
|
opacity: root.reachable ? 1 : 0.5
|
|
slotSize: root.barSlot
|
|
opticalSize: root.barContentWidth
|
|
tooltipText: root.tooltipFor()
|
|
|
|
iconComponent: Component {
|
|
Item {
|
|
Row {
|
|
anchors.centerIn: parent
|
|
spacing: Style.space(5)
|
|
|
|
BeeperIcon {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
iconSize: Style.bar.iconCanvas
|
|
color: button.foreground
|
|
dotColor: button.foreground
|
|
hasMail: root.hasUnread && root.reachable
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: root.reachable && root.badgeCount > 0
|
|
height: Style.space(12)
|
|
width: root.badgeWidth
|
|
radius: height / 2
|
|
color: Qt.rgba(button.foreground.r, button.foreground.g,
|
|
button.foreground.b, 0.14)
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: root.badgeCount
|
|
textFormat: Text.PlainText
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
renderType: Text.NativeRendering
|
|
color: button.foreground
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
onPressed: function(b) {
|
|
if (b === Qt.RightButton) {
|
|
root.openBeeper()
|
|
} else if (b === Qt.MiddleButton) {
|
|
root.refresh()
|
|
} else {
|
|
root.toggle()
|
|
}
|
|
}
|
|
}
|
|
|
|
KeyboardPanel {
|
|
id: panel
|
|
anchorItem: button
|
|
owner: root
|
|
bar: root.bar
|
|
open: root.opened
|
|
focusTarget: keyCatcher
|
|
contentWidth: panel.fittedContentWidth(Style.space(400))
|
|
contentHeight: panel.fittedContentHeight(content.implicitHeight)
|
|
|
|
PanelKeyCatcher {
|
|
id: keyCatcher
|
|
anchors.fill: parent
|
|
onCloseRequested: {
|
|
if (root.markAllArmed) {
|
|
root.cancelMarkAllConfirm()
|
|
return
|
|
}
|
|
root.close()
|
|
}
|
|
onMoveRequested: function(dx, dy) { if (dy !== 0) root.moveCursor(dy) }
|
|
onActivateRequested: root.activateCursor()
|
|
onTabRequested: function(direction) { root.switchPanel(direction) }
|
|
onTextKey: function(t) {
|
|
var onCursor = root.cursor >= 0 && root.cursor < root.messages.length
|
|
if (t === "o" && onCursor)
|
|
root.openChat(root.messages[root.cursor])
|
|
else if (t === "i")
|
|
root.openBeeper()
|
|
else if (t === "a")
|
|
root.markCursorRead()
|
|
else if (t === "A")
|
|
root.requestMarkAll()
|
|
else if (t === "n")
|
|
root.goNextPage()
|
|
else if (t === "p")
|
|
root.goPrevPage()
|
|
}
|
|
|
|
Column {
|
|
id: content
|
|
anchors.fill: parent
|
|
spacing: Style.space(6)
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: Math.max(heading.implicitHeight, openAppButton.height)
|
|
|
|
Column {
|
|
id: heading
|
|
anchors.left: parent.left
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.right: headerActions.left
|
|
anchors.rightMargin: Style.space(8)
|
|
spacing: Style.space(1)
|
|
|
|
PanelSectionHeader {
|
|
width: parent.width
|
|
text: root.titleText()
|
|
textFormat: Text.PlainText
|
|
elide: Text.ElideRight
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: root.accountCount > 1 && text !== ""
|
|
text: root.inboxSummary()
|
|
textFormat: Text.PlainText
|
|
elide: Text.ElideRight
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: Qt.darker(root.foreground, 1.6)
|
|
}
|
|
}
|
|
|
|
Row {
|
|
id: headerActions
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(2)
|
|
|
|
PanelActionButton {
|
|
id: markAllButton
|
|
visible: root.hasUnread && root.reachable
|
|
enabled: root.hasUnread && root.reachable && !root.markAllBusy
|
|
iconText: root.markAllArmed || root.markAllBusy
|
|
? root.iconConfirm : root.iconMarkAll
|
|
tooltipText: root.markAllBusy
|
|
? "Marking unread chats as read…"
|
|
: (root.markAllArmed
|
|
? "Click again to confirm"
|
|
: "Mark all unread as read (A)")
|
|
foreground: root.foreground
|
|
hoverColor: root.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.iconSmall
|
|
onClicked: root.requestMarkAll()
|
|
}
|
|
|
|
PanelActionButton {
|
|
id: openAppButton
|
|
enabled: !root.markAllBusy
|
|
iconText: root.iconOpenApp
|
|
tooltipText: "Open Beeper Desktop (i)"
|
|
foreground: root.foreground
|
|
hoverColor: root.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.iconSmall
|
|
onClicked: root.openBeeper()
|
|
}
|
|
}
|
|
}
|
|
|
|
PanelSeparator { width: parent.width }
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: root.reachable ? 0 : staleWarning.implicitHeight + Style.space(6)
|
|
visible: !root.reachable
|
|
|
|
Text {
|
|
id: staleWarning
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width
|
|
text: root.errorText !== ""
|
|
? root.errorText
|
|
: "Could not reach Beeper. Showing the last list."
|
|
textFormat: Text.PlainText
|
|
elide: Text.ElideRight
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: bar ? bar.urgent : Color.urgent
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: (root.reachable && root.warningText !== "")
|
|
? partialWarning.implicitHeight + Style.space(6) : 0
|
|
visible: root.reachable && root.warningText !== ""
|
|
|
|
Text {
|
|
id: partialWarning
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width
|
|
text: root.warningText
|
|
textFormat: Text.PlainText
|
|
elide: Text.ElideRight
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: bar ? bar.urgent : Color.urgent
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: (root.actionWarning !== "" && !root.markAllBusy)
|
|
? actionWarningLabel.implicitHeight + Style.space(6) : 0
|
|
visible: root.actionWarning !== "" && !root.markAllBusy
|
|
|
|
Text {
|
|
id: actionWarningLabel
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width
|
|
text: root.actionWarning
|
|
textFormat: Text.PlainText
|
|
elide: Text.ElideRight
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: bar ? bar.urgent : Color.urgent
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: root.markAllBusy ? markAllBusyLabel.implicitHeight + Style.space(6) : 0
|
|
visible: root.markAllBusy
|
|
|
|
Text {
|
|
id: markAllBusyLabel
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: parent.width
|
|
text: "Marking unread chats as read…"
|
|
textFormat: Text.PlainText
|
|
elide: Text.ElideRight
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: Qt.darker(root.foreground, 1.6)
|
|
}
|
|
}
|
|
|
|
ListView {
|
|
id: list
|
|
width: parent.width
|
|
visible: root.messages.length > 0
|
|
clip: true
|
|
opacity: root.markAllBusy ? 0.4 : 1
|
|
enabled: !root.markAllBusy
|
|
model: root.messages
|
|
spacing: Style.space(1)
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
interactive: contentHeight > height && !root.markAllBusy
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
readonly property int cap: {
|
|
var chrome = Style.space(70)
|
|
if (root.hasPrev || root.hasNext) chrome += Style.space(38)
|
|
if (!root.reachable) chrome += Style.space(24)
|
|
if (root.reachable && root.warningText !== "") chrome += Style.space(24)
|
|
if (root.actionWarning !== "" && !root.markAllBusy) chrome += Style.space(24)
|
|
if (root.markAllBusy) chrome += Style.space(24)
|
|
return Math.max(Style.space(200),
|
|
panel.availableCardHeight - panel.verticalContentInset - chrome)
|
|
}
|
|
height: Math.min(contentHeight, cap)
|
|
|
|
delegate: Rectangle {
|
|
id: row
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property bool active: root.cursor === row.index || rowMouse.containsMouse
|
|
|
|
width: list.width - (list.interactive ? Style.space(10) : 0)
|
|
height: rowContent.implicitHeight + Style.space(10)
|
|
radius: Style.cornerRadius
|
|
opacity: root.pendingId === modelData.id ? 0.4 : 1
|
|
color: active
|
|
? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08)
|
|
: "transparent"
|
|
|
|
Behavior on color { ColorAnimation { duration: 80 } }
|
|
|
|
MouseArea {
|
|
id: rowMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onContainsMouseChanged: if (containsMouse) root.cursor = row.index
|
|
onClicked: if (!root.markAllBusy) root.openChat(row.modelData)
|
|
}
|
|
|
|
Column {
|
|
id: rowContent
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.leftMargin: Style.space(6)
|
|
anchors.rightMargin: Style.space(6)
|
|
spacing: Style.space(2)
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: subject.implicitHeight
|
|
|
|
Row {
|
|
id: line
|
|
anchors.left: parent.left
|
|
anchors.right: age.left
|
|
anchors.rightMargin: Style.space(6)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(5)
|
|
|
|
Row {
|
|
id: chips
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.space(3)
|
|
visible: root.accountCount > 1
|
|
&& (row.modelData.labels || []).length > 0
|
|
|
|
Repeater {
|
|
model: (row.modelData.labels || []).slice(0, 1)
|
|
|
|
Rectangle {
|
|
required property string modelData
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
height: chipText.implicitHeight + Style.space(3)
|
|
width: chipText.width + Style.space(8)
|
|
radius: Style.space(3)
|
|
color: Qt.rgba(root.foreground.r, root.foreground.g,
|
|
root.foreground.b, 0.14)
|
|
|
|
Text {
|
|
id: chipText
|
|
anchors.centerIn: parent
|
|
text: parent.modelData
|
|
textFormat: Text.PlainText
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
width: Math.min(implicitWidth, Style.space(64))
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: Qt.darker(root.foreground, 1.35)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: subject
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: Math.max(Style.space(40),
|
|
line.width - (chips.visible ? chips.width + line.spacing : 0))
|
|
text: root.oneLine(row.modelData.subject)
|
|
textFormat: Text.PlainText
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
elide: Text.ElideRight
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
font.bold: true
|
|
color: root.foreground
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: age
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: root.ageLabel(row.modelData.ts)
|
|
textFormat: Text.PlainText
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: Qt.darker(root.foreground, 1.7)
|
|
}
|
|
}
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 0
|
|
|
|
Text {
|
|
id: fromLabel
|
|
text: row.modelData.from || ""
|
|
textFormat: Text.PlainText
|
|
elide: Text.ElideRight
|
|
width: Math.min(implicitWidth, parent.width * 0.5)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
color: Qt.darker(root.foreground, 1.15)
|
|
}
|
|
|
|
Text {
|
|
text: {
|
|
var body = root.oneLine(row.modelData.snippet)
|
|
if (body === "") return ""
|
|
return (fromLabel.text !== "" ? " - " : "") + body
|
|
}
|
|
textFormat: Text.PlainText
|
|
wrapMode: Text.NoWrap
|
|
maximumLineCount: 1
|
|
elide: Text.ElideRight
|
|
width: parent.width - fromLabel.width
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: Qt.darker(root.foreground, 1.7)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: (root.hasPrev || root.hasNext) ? pagerRow.implicitHeight + Style.space(8) : 0
|
|
visible: root.hasPrev || root.hasNext
|
|
|
|
Row {
|
|
id: pagerRow
|
|
anchors.centerIn: parent
|
|
spacing: Style.space(10)
|
|
|
|
PanelActionButton {
|
|
iconText: root.iconPrev
|
|
tooltipText: "Previous page"
|
|
enabled: root.hasPrev && !root.markAllBusy
|
|
opacity: enabled ? 1 : 0.3
|
|
foreground: root.foreground
|
|
hoverColor: root.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.iconSmall
|
|
onClicked: root.goPrevPage()
|
|
}
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "page " + (root.pageStack.length + 1)
|
|
textFormat: Text.PlainText
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
color: Qt.darker(root.foreground, 1.7)
|
|
}
|
|
|
|
PanelActionButton {
|
|
iconText: root.iconNext
|
|
tooltipText: "Next page"
|
|
enabled: root.hasNext && !root.markAllBusy
|
|
opacity: enabled ? 1 : 0.3
|
|
foreground: root.foreground
|
|
hoverColor: root.accent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.iconSmall
|
|
onClicked: root.goNextPage()
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
width: parent.width
|
|
height: root.messages.length === 0 ? Style.space(60) : 0
|
|
visible: root.messages.length === 0
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
width: parent.width - Style.space(20)
|
|
horizontalAlignment: Text.AlignHCenter
|
|
wrapMode: Text.WordWrap
|
|
text: root.reachable
|
|
? "You're all caught up."
|
|
: (root.errorText !== "" ? root.errorText : "Beeper unreachable")
|
|
textFormat: Text.PlainText
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
color: root.foreground
|
|
opacity: 0.6
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|