feat: omarchy-beeper bar widget for unread Beeper chats

This commit is contained in:
prdlk committed 2026-09-01 11:15:22 -04:00
commit 7fc78f1075
17 files changed
+3483

No files matched your search

+868
View File
@@ -0,0 +1,868 @@
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
}
}
}
}
}
}