Show explicit choices on notification cards

This commit is contained in:
David Heinemeier Hansson committed 2026-10-07 17:35:03 +02:00
1 parent 35f8eef1bc
commit 036e501e58
4 files changed
+58 -2

No files matched your search

@@ -185,6 +185,12 @@ function snapshotOf(notification, timestamp) {
var id = n.id || 0
var expireTimeout = Number(n.expireTimeout || 0)
if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0
var actions = []
for (var i = 0; n.actions && i < n.actions.length; i++) {
var action = n.actions[i]
if (action && action.identifier && action.identifier !== "default")
actions.push({ identifier: String(action.identifier), text: String(action.text || action.identifier) })
}
return {
id: id,
originalId: id,
@@ -195,6 +201,7 @@ function snapshotOf(notification, timestamp) {
image: n.image || "",
glyph: glyphFromHints(n.hints),
execArgv: execArgvFromHints(n.hints),
actionsJson: JSON.stringify(actions),
// Pinyin's first-use cloud prediction question needs an explicit answer.
// Its normal urgency otherwise dismisses it before users can read it.
urgency: n.appName === "Pinyin" && n.appIcon === "fcitx-pinyin" ? 2 : n.urgency,
@@ -205,7 +212,7 @@ function snapshotOf(notification, timestamp) {
// Everything the popup card draws, and therefore everything an in-place
// update has to write through to the row and its file.
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"]
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "actionsJson", "urgency", "expireTimeout"]
function popupRoles() {
return POPUP_ROLES
@@ -265,6 +272,8 @@ function historyEntry(value, normalUrgency) {
image: e.image || "",
glyph: e.glyph || "",
execArgv: e.execArgv || "",
// Libnotify actions belong to a live sender; never replay stale buttons.
actionsJson: "[]",
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
expireTimeout: 0,
timestamp: e.timestamp || 0
+21 -1
View File
@@ -235,7 +235,7 @@ Item {
// about after the popup exists.
readonly property var updateSignals: [
"summaryChanged", "bodyChanged", "appNameChanged", "appIconChanged",
"imageChanged", "urgencyChanged", "expireTimeoutChanged", "hintsChanged"
"imageChanged", "urgencyChanged", "expireTimeoutChanged", "hintsChanged", "actionsChanged"
]
// A client that updates a notification through replaces_id does not produce
@@ -427,10 +427,26 @@ Item {
// libnotify action — they just expect clicking the notification to
// focus their window. Fall back to focusing the sending app by class so
// that click-to-jump actually works.
if (!invoked && entry.actionsJson && entry.actionsJson !== "[]") return
if (!invoked) focusApp(entry)
dismissPopup(index)
}
function invokePopupAction(index, identifier) {
if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index)
var ref = entry && !isRestoredRow(entry) ? liveRefs[entry.originalId] : null
if (!ref || !ref.actions) return
for (var i = 0; i < ref.actions.length; i++) {
var action = ref.actions[i]
if (action && action.identifier === identifier) {
action.invoke()
dismissPopup(index)
return
}
}
}
// Try to focus an existing Hyprland window matching the notification's
// sender. The helper handles case-insensitive class matching.
function focusApp(entry) {
@@ -726,6 +742,7 @@ Item {
image: "",
glyph: "󰂚",
execArgv: "",
actionsJson: "[]",
urgency: NotificationUrgency.Low,
expireTimeout: 0,
timestamp: Date.now()
@@ -1044,6 +1061,7 @@ Item {
required property string body
required property string image
required property string glyph
required property string actionsJson
required property int urgency
required property double expireTimeout
required property double timestamp
@@ -1095,9 +1113,11 @@ Item {
cornerRadius: service.cornerRadius
fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : ""
glyph: cardSlot.glyph
actions: JSON.parse(cardSlot.actionsJson || "[]")
onCloseRequested: service.dismissPopup(cardSlot.index)
onCardClicked: service.invokePopupDefault(cardSlot.index)
onActionClicked: identifier => service.invokePopupAction(cardSlot.index, identifier)
}
}
}
@@ -26,6 +26,7 @@ BorderSurface {
property int urgency: 1
property double timestamp: 0
property int cornerRadius: 0
property var actions: []
// System monospace font injected by the container.
property string fontFamily: ""
@@ -34,6 +35,7 @@ BorderSurface {
signal closeRequested()
signal cardClicked()
signal actionClicked(string identifier)
// Prefer per-notification media/avatar data, then fall back to the app icon.
// The `check` flag avoids Qt's missing-texture placeholder for unknown names.
readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon)
@@ -193,6 +195,27 @@ BorderSurface {
}
}
}
Flow {
Layout.fillWidth: true
Layout.leftMargin: Style.space(12)
Layout.rightMargin: Style.space(12)
Layout.bottomMargin: visible ? Style.space(10) : 0
visible: root.actions.length > 0
spacing: Style.space(8)
Repeater {
model: root.actions
delegate: Button {
required property var modelData
text: modelData.text
bordered: true
foreground: Color.notifications.text
fontFamily: root.fontFamily
onClicked: root.actionClicked(modelData.identifier)
}
}
}
}
// Hover-revealed close. Stacked after mainColumn so its MouseArea sits
+4
View File
@@ -271,6 +271,10 @@ const notification = {
expireTimeout: 1.5
}
const snapshot = notifications.snapshotOf(notification, 12345)
assertDeepEqual(JSON.parse(notifications.snapshotOf({ actions: [
{ identifier: 'default', text: 'Open' }, { identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' }
]}).actionsJson), [{ identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' }], 'explicit choices appear independently of the default card action')
assertEqual(notifications.popupEntry({ actionsJson: '[{"identifier":"yes","text":"Yes"}]' }, 1).actionsJson, '[]', 'restored notifications do not replay actions from a dead sender')
assertEqual(notifications.snapshotOf({ appName: 'Pinyin', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 2, 'Pinyin prompts stay visible until dismissed')
assertEqual(notifications.snapshotOf({ appName: 'Other', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 1, 'other notification senders retain their urgency')
assertDeepEqual(