The card binds the body Text to styledBody, which rewrites newlines to <br/> *after* sanitizeBody has run. That rewrite inserts tag syntax into text the stripper deliberately kept: a kept tag may hold a `<` of its own, and `<x`, newline, `<img src="http://host/x.png">` is one tag named `x` to both the stripper and Qt, so it survives whole — until the rewrite splits it into `<x<br/>` and a live image tag the input never contained. Measured against Qt 6.11.2 with an offscreen StyledText and a local HTTP server: that body issues the GET after this branch's sanitizer and issues nothing before it, because the one-pass /<img[^>]*>/gi it replaces deleted the inner substring outright. The whole-tag bound is still the right trade — it is what stops the stripper manufacturing tags — but it only holds if nothing edits the string afterwards. So move the rewrite into NotificationLogic, next to the reasoning it depends on, and strip again after it. What Qt parses is then what was checked last. The tests assert on styledBody for the same reason, since sanitizeBody's output is no longer the string that reaches the renderer, and a regex assertion pins the card's binding because no JavaScript assertion can see a QML property.
229 lines
8.2 KiB
QML
229 lines
8.2 KiB
QML
// Notification card. Pure presentational — no service, Notification, or
|
|
// ListModel references. The popup container drives lifetime; the history
|
|
// panel drives static rendering. Both use the same component.
|
|
|
|
import QtQuick
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import qs.Commons
|
|
import qs.Ui
|
|
import "../NotificationLogic.js" as NotificationLogic
|
|
|
|
BorderSurface {
|
|
id: root
|
|
|
|
property string app: ""
|
|
property string appIcon: ""
|
|
property string summary: ""
|
|
property string body: ""
|
|
property string image: ""
|
|
// Nerd Font glyph rendered in the icon slot when no real icon is set.
|
|
// Used by omarchy-notification-send so user-action toasts (`Silenced
|
|
// notifications` etc.) show their bell/lock/etc. glyph without leaking
|
|
// into the summary text.
|
|
property string glyph: ""
|
|
// NotificationUrgency: Low=0, Normal=1, Critical=2 (upstream).
|
|
property int urgency: 1
|
|
property double timestamp: 0
|
|
property int cornerRadius: 0
|
|
|
|
// System monospace font injected by the container.
|
|
property string fontFamily: ""
|
|
|
|
readonly property bool hovered: hoverTracker.hovered
|
|
|
|
signal closeRequested()
|
|
signal cardClicked()
|
|
// 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)
|
|
readonly property bool hasGlyph: glyph.length > 0
|
|
readonly property bool compactGlyph: NotificationLogic.shouldRenderCompactGlyph(glyph, smallIconSource, singleLineToast)
|
|
readonly property bool hasSmallIcon: smallIconSource.length > 0
|
|
readonly property bool summaryStartsWithGlyph: NotificationLogic.summaryStartsWithGlyph(summary)
|
|
readonly property bool singleLineToast: sanitizedBody.length === 0
|
|
readonly property bool collapseRedundantIcon: singleLineToast && !hasGlyph && summaryStartsWithGlyph
|
|
readonly property string sanitizedBody: sanitizeBody(body)
|
|
readonly property string styledBody: NotificationLogic.styledBody(body, app, appIcon)
|
|
|
|
readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4)
|
|
readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15)
|
|
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
|
|
readonly property var cardBorderSpec: Border.surfaceSpec("notifications", "border", Color.notifications.border, Math.max(1, Style.space(2)))
|
|
|
|
function sanitizeBody(s) {
|
|
return NotificationLogic.sanitizeBody(s, app, appIcon)
|
|
}
|
|
|
|
function iconSource(icon) {
|
|
var value = String(icon || "")
|
|
if (value.length === 0) return ""
|
|
if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value
|
|
if (value.charAt(0) === "/") return Util.fileUrl(value)
|
|
return Quickshell.iconPath(value, true)
|
|
}
|
|
|
|
implicitWidth: Style.space(380)
|
|
// Add vertical border insets so mainColumn (inset by border on top/left/right)
|
|
// doesn't push content under the bottom edge.
|
|
implicitHeight: mainColumn.implicitHeight + borderTop + borderBottom
|
|
radius: cornerRadius
|
|
color: Color.notifications.background
|
|
borderSpec: cardBorderSpec
|
|
clip: true
|
|
|
|
HoverHandler { id: hoverTracker }
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
onClicked: function(mouse) {
|
|
if (mouse.button === Qt.RightButton) {
|
|
root.closeRequested()
|
|
} else {
|
|
root.cardClicked()
|
|
}
|
|
}
|
|
}
|
|
|
|
ColumnLayout {
|
|
id: mainColumn
|
|
// Inset by the card border so the content doesn't paint over the card's
|
|
// outer border.
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.topMargin: root.borderTop
|
|
anchors.leftMargin: root.borderLeft
|
|
anchors.rightMargin: root.borderRight
|
|
spacing: 0
|
|
|
|
// Text content.
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.leftMargin: Style.space(12)
|
|
Layout.rightMargin: Style.space(12)
|
|
Layout.topMargin: root.singleLineToast ? Style.space(7) : Style.space(10)
|
|
Layout.bottomMargin: root.singleLineToast ? Style.space(7) : Style.space(10)
|
|
spacing: root.collapseRedundantIcon ? 0 : (root.compactGlyph ? Style.space(8) : Style.space(12))
|
|
|
|
Item {
|
|
id: smallIconSlot
|
|
Layout.preferredWidth: visible ? Style.space(40) : 0
|
|
Layout.preferredHeight: visible ? Style.space(40) : 0
|
|
Layout.alignment: Qt.AlignVCenter
|
|
// Hide the slot when the icon failed to resolve (themed-icon name
|
|
// not in the user's icon theme) AND we don't have a glyph fallback
|
|
// — prevents rendering Qt's pink broken-image placeholder.
|
|
visible: !root.collapseRedundantIcon && !root.compactGlyph && (root.hasSmallIcon || root.hasGlyph) && (root.hasGlyph || smallIconImage.status !== Image.Error)
|
|
|
|
Image {
|
|
id: smallIconImage
|
|
anchors.fill: parent
|
|
source: root.smallIconSource
|
|
sourceSize.width: smallIconSlot.width * Screen.devicePixelRatio
|
|
sourceSize.height: smallIconSlot.height * Screen.devicePixelRatio
|
|
fillMode: Image.PreserveAspectFit
|
|
asynchronous: true
|
|
smooth: true
|
|
visible: !root.hasGlyph || smallIconImage.status === Image.Ready
|
|
}
|
|
|
|
// Glyph fallback (Nerd Font character) when no image icon is
|
|
// available. Used by omarchy-notification-send's `-g` flag.
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
anchors.centerIn: parent
|
|
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
|
|
text: root.glyph
|
|
color: Color.notifications.text
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.displayLarge
|
|
}
|
|
}
|
|
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
Layout.alignment: Qt.AlignVCenter
|
|
visible: root.compactGlyph
|
|
text: root.glyph
|
|
color: Color.notifications.text
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.icon
|
|
}
|
|
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
Layout.alignment: Qt.AlignVCenter
|
|
// Keep the first line clear of the hover-revealed close button.
|
|
Layout.rightMargin: Style.space(10)
|
|
spacing: Style.space(2)
|
|
|
|
Text {
|
|
// The spec defines the summary as a single line of plain text, so
|
|
// AutoText could only ever promote a hostile string to rich text.
|
|
// The body below is StyledText on purpose — see Service.qml's
|
|
// bodyMarkupSupported — and is stripped in NotificationLogic.
|
|
textFormat: Text.PlainText
|
|
Layout.fillWidth: true
|
|
visible: root.summary.length > 0
|
|
text: root.summary
|
|
font.family: "Liberation Sans"
|
|
color: Color.notifications.text
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
wrapMode: Text.WordWrap
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 2
|
|
}
|
|
|
|
Text {
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: Style.space(2)
|
|
visible: root.sanitizedBody.length > 0
|
|
text: root.styledBody
|
|
textFormat: Text.StyledText
|
|
font.family: "Liberation Sans"
|
|
color: root.bodyColor
|
|
font.pixelSize: Style.font.title
|
|
wrapMode: Text.WordWrap
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 3
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hover-revealed close. Stacked after mainColumn so its MouseArea sits
|
|
// above the full-card one and the click never reaches cardClicked.
|
|
Item {
|
|
anchors.top: parent.top
|
|
anchors.right: parent.right
|
|
anchors.topMargin: root.borderTop + Style.space(3)
|
|
anchors.rightMargin: root.borderRight + Style.space(3)
|
|
width: Style.space(18)
|
|
height: Style.space(18)
|
|
visible: opacity > 0
|
|
opacity: root.hovered ? 1 : 0
|
|
|
|
Behavior on opacity { NumberAnimation { duration: 100 } }
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "✕"
|
|
color: closeArea.containsMouse ? Color.notifications.text : root.dimColor
|
|
font.pixelSize: Math.round(Style.font.caption * 1.44)
|
|
}
|
|
|
|
MouseArea {
|
|
id: closeArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: root.closeRequested()
|
|
}
|
|
}
|
|
|
|
}
|