Files
omarchycn/shell/plugins/notifications/components/NotificationCard.qml
T
David Heinemeier Hansson 7026ede90b Strip image tags after the newline rewrite, not before it
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.
2026-08-27 16:53:55 +02:00

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()
}
}
}