Merge pull request #1856 from tibssy/feat/notification-markdown

Feat/notification markdown
This commit is contained in:
Lysec
2026-02-16 14:12:29 +01:00
committed by GitHub
6 changed files with 203 additions and 60 deletions
+3
View File
@@ -599,6 +599,7 @@
"notifications": {
"panel": {
"click-to-expand": "Click to expand",
"click-to-collapse": "Click to collapse",
"description": "Your notifications will show up here as they arrive.",
"no-notifications": "No notifications"
},
@@ -1292,6 +1293,8 @@
"settings-do-not-disturb-description": "Disable all notification popups when enabled.",
"settings-enabled-description": "Enable or disable the notification daemon, requires a restart of Noctalia shell.",
"settings-enabled-label": "Enable notifications",
"settings-markdown-description": "Render notification content using Markdown formatting.",
"settings-markdown-label": "Enable Markdown",
"settings-location-description": "Where notifications appear on screen.",
"sounds-desc": "Configure notification sound effects and volume.",
"sounds-enabled-description": "Enable sound effects for incoming notifications.",
+1
View File
@@ -387,6 +387,7 @@
},
"notifications": {
"enabled": true,
"enableMarkdown": false,
"density": "default",
"monitors": [],
"location": "top_right",
+1
View File
@@ -595,6 +595,7 @@ Singleton {
// notifications
property JsonObject notifications: JsonObject {
property bool enabled: true
property bool enableMarkdown: false
property string density: "default" // "default", "compact"
property list<string> monitors: [] // holds notifications visibility per monitor
property string location: "top_right"
@@ -13,8 +13,8 @@ import qs.Widgets
SmartPanel {
id: root
preferredWidth: Math.round(440 * Style.uiScaleRatio)
preferredHeight: Math.round(540 * Style.uiScaleRatio)
preferredWidth: Math.round((Settings.data.notifications.enableMarkdown ? 540 : 440) * Style.uiScaleRatio)
preferredHeight: Math.round((Settings.data.notifications.enableMarkdown ? 640 : 540) * Style.uiScaleRatio)
onOpened: {
NotificationService.updateLastSeenTs();
@@ -566,11 +566,56 @@ SmartPanel {
property real pressGlobalX: 0
property real pressGlobalY: 0
property bool isSwiping: false
property bool suppressClick: false
property bool isRemoving: false
property string pendingLink: ""
readonly property real swipeStartThreshold: Math.round(16 * Style.uiScaleRatio)
readonly property real swipeDismissThreshold: Math.max(110, width * 0.3)
readonly property int removeAnimationDuration: Style.animationNormal
readonly property int notificationTextFormat: (Settings.data.notifications.enableMarkdown && notificationDelegate.isExpanded) ? Text.MarkdownText : Text.StyledText
readonly property real actionButtonSize: Style.baseWidgetSize * 0.7
readonly property real buttonClusterWidth: notificationDelegate.actionButtonSize * 2 + Style.marginXS
readonly property real iconSize: Math.round(40 * Style.uiScaleRatio)
function isSafeLink(link) {
if (!link)
return false;
const lower = link.toLowerCase();
const schemes = ["http://", "https://", "mailto:"];
return schemes.some(scheme => lower.startsWith(scheme));
}
function linkAtPoint(x, y) {
if (!Settings.data.notifications.enableMarkdown || !notificationDelegate.isExpanded)
return "";
if (summaryText) {
const summaryPoint = summaryText.mapFromItem(historyInteractionArea, x, y);
if (summaryPoint.x >= 0 && summaryPoint.y >= 0 && summaryPoint.x <= summaryText.width && summaryPoint.y <= summaryText.height) {
const summaryLink = summaryText.linkAt ? summaryText.linkAt(summaryPoint.x, summaryPoint.y) : "";
if (isSafeLink(summaryLink))
return summaryLink;
}
}
if (bodyText) {
const bodyPoint = bodyText.mapFromItem(historyInteractionArea, x, y);
if (bodyPoint.x >= 0 && bodyPoint.y >= 0 && bodyPoint.x <= bodyText.width && bodyPoint.y <= bodyText.height) {
const bodyLink = bodyText.linkAt ? bodyText.linkAt(bodyPoint.x, bodyPoint.y) : "";
if (isSafeLink(bodyLink))
return bodyLink;
}
}
return "";
}
function updateCursorAt(x, y) {
if (notificationDelegate.isExpanded && notificationDelegate.linkAtPoint(x, y)) {
historyInteractionArea.cursorShape = Qt.PointingHandCursor;
} else {
historyInteractionArea.cursorShape = Qt.ArrowCursor;
}
}
transform: Translate {
x: notificationDelegate.swipeOffset
@@ -581,7 +626,6 @@ SmartPanel {
return;
isRemoving = true;
isSwiping = false;
suppressClick = true;
if (Settings.data.general.animationDisabled) {
NotificationService.removeFromHistory(notificationId);
@@ -668,20 +712,29 @@ SmartPanel {
MouseArea {
id: historyInteractionArea
anchors.fill: parent
anchors.rightMargin: Style.baseWidgetSize
anchors.rightMargin: notificationDelegate.buttonClusterWidth + Style.marginM
enabled: !notificationDelegate.isRemoving
cursorShape: notificationDelegate.canExpand ? Qt.PointingHandCursor : Qt.ArrowCursor
hoverEnabled: true
cursorShape: Qt.ArrowCursor
onPressed: mouse => {
panelContent.focusIndex = index;
panelContent.actionIndex = -1;
if (notificationDelegate.isExpanded) {
const link = notificationDelegate.linkAtPoint(mouse.x, mouse.y);
if (link) {
notificationDelegate.pendingLink = link;
} else {
notificationDelegate.pendingLink = "";
}
}
if (mouse.button !== Qt.LeftButton)
return;
const globalPoint = historyInteractionArea.mapToGlobal(mouse.x, mouse.y);
notificationDelegate.pressGlobalX = globalPoint.x;
notificationDelegate.pressGlobalY = globalPoint.y;
notificationDelegate.isSwiping = false;
notificationDelegate.suppressClick = false;
}
onPositionChanged: mouse => {
if (!(mouse.buttons & Qt.LeftButton) || notificationDelegate.isRemoving)
@@ -697,12 +750,15 @@ SmartPanel {
// Only start a swipe-dismiss when horizontal movement is dominant.
if (Math.abs(deltaX) <= Math.abs(deltaY) * 1.15) {
notificationDelegate.suppressClick = true;
return;
}
notificationDelegate.isSwiping = true;
}
if (notificationDelegate.pendingLink && Math.abs(deltaX) >= notificationDelegate.swipeStartThreshold) {
notificationDelegate.pendingLink = "";
}
notificationDelegate.swipeOffset = deltaX;
}
onReleased: mouse => {
@@ -715,31 +771,38 @@ SmartPanel {
} else {
notificationDelegate.swipeOffset = 0;
}
notificationDelegate.suppressClick = true;
notificationDelegate.isSwiping = false;
notificationDelegate.pendingLink = "";
return;
}
if (!notificationDelegate.canExpand || notificationDelegate.suppressClick)
return;
if (scrollView.expandedId === notificationId) {
scrollView.expandedId = "";
} else {
scrollView.expandedId = notificationId;
if (notificationDelegate.pendingLink) {
Qt.openUrlExternally(notificationDelegate.pendingLink);
notificationDelegate.pendingLink = "";
return;
}
}
onCanceled: {
notificationDelegate.isSwiping = false;
notificationDelegate.swipeOffset = 0;
notificationDelegate.suppressClick = true;
notificationDelegate.pendingLink = "";
historyInteractionArea.cursorShape = Qt.ArrowCursor;
}
}
HoverHandler {
target: historyInteractionArea
onPointChanged: notificationDelegate.updateCursorAt(point.position.x, point.position.y)
onActiveChanged: {
if (!active) {
historyInteractionArea.cursorShape = Qt.ArrowCursor;
}
}
}
onVisibleChanged: {
if (!visible) {
notificationDelegate.isSwiping = false;
notificationDelegate.suppressClick = false;
notificationDelegate.swipeOffset = 0;
notificationDelegate.opacity = 1;
notificationDelegate.isRemoving = false;
@@ -763,9 +826,9 @@ SmartPanel {
// Icon
NImageRounded {
anchors.verticalCenter: parent.verticalCenter
width: Math.round(40 * Style.uiScaleRatio)
height: Math.round(40 * Style.uiScaleRatio)
anchors.verticalCenter: notificationDelegate.isExpanded ? undefined : parent.verticalCenter
width: notificationDelegate.iconSize
height: notificationDelegate.iconSize
radius: Math.min(Style.radiusL, width / 2)
imagePath: model.cachedImage || model.originalImage || ""
borderColor: "transparent"
@@ -776,7 +839,7 @@ SmartPanel {
// Content
Column {
width: parent.width - Math.round(40 * Style.uiScaleRatio) - Style.marginM - Style.baseWidgetSize
width: parent.width - notificationDelegate.iconSize - notificationDelegate.buttonClusterWidth - (Style.marginM * 2)
spacing: Style.marginXS
// Header row with app name and timestamp
@@ -821,10 +884,10 @@ SmartPanel {
NText {
id: summaryText
width: parent.width
text: model.summary || I18n.tr("common.no-summary")
text: (Settings.data.notifications.enableMarkdown && notificationDelegate.isExpanded) ? (model.summaryMarkdown || I18n.tr("common.no-summary")) : (model.summary || I18n.tr("common.no-summary"))
pointSize: Style.fontSizeM
color: Color.mOnSurface
textFormat: Text.StyledText
textFormat: notificationDelegate.notificationTextFormat
wrapMode: Text.Wrap
maximumLineCount: notificationDelegate.isExpanded ? 999 : 2
elide: Text.ElideRight
@@ -834,42 +897,16 @@ SmartPanel {
NText {
id: bodyText
width: parent.width
text: model.body || ""
text: (Settings.data.notifications.enableMarkdown && notificationDelegate.isExpanded) ? (model.bodyMarkdown || "") : (model.body || "")
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
textFormat: Text.StyledText
textFormat: notificationDelegate.notificationTextFormat
wrapMode: Text.Wrap
maximumLineCount: notificationDelegate.isExpanded ? 999 : 3
elide: Text.ElideRight
visible: text.length > 0
}
// Expand indicator
Row {
width: parent.width
visible: !notificationDelegate.isExpanded && notificationDelegate.canExpand
spacing: Style.marginXS
Item {
width: parent.width - expandText.width - expandIcon.width - Style.marginXS
height: 1
}
NText {
id: expandText
text: I18n.tr("notifications.panel.click-to-expand") || "Click to expand"
pointSize: Style.fontSizeXS
color: Color.mPrimary
}
NIcon {
id: expandIcon
icon: "chevron-down"
pointSize: Style.fontSizeS
color: Color.mPrimary
}
}
// Actions Flow
Flow {
width: parent.width
@@ -908,15 +945,43 @@ SmartPanel {
}
}
// Delete button
NIconButton {
icon: "trash"
tooltipText: I18n.tr("tooltips.delete-notification")
baseSize: Style.baseWidgetSize * 0.7
anchors.top: parent.top
Item {
width: notificationDelegate.buttonClusterWidth
height: notificationDelegate.actionButtonSize
onClicked: {
NotificationService.removeFromHistory(notificationId);
Row {
anchors.right: parent.right
spacing: Style.marginXS
NIconButton {
id: expandButton
icon: notificationDelegate.isExpanded ? "chevron-up" : "chevron-down"
tooltipText: notificationDelegate.isExpanded ? I18n.tr("notifications.panel.click-to-collapse") || "Click to collapse" : I18n.tr("notifications.panel.click-to-expand") || "Click to expand"
baseSize: notificationDelegate.actionButtonSize
opacity: (notificationDelegate.canExpand || notificationDelegate.isExpanded) ? 1.0 : 0.0
enabled: notificationDelegate.canExpand || notificationDelegate.isExpanded
onClicked: {
notificationDelegate.pendingLink = "";
historyInteractionArea.cursorShape = Qt.ArrowCursor;
if (scrollView.expandedId === notificationId) {
scrollView.expandedId = "";
} else {
scrollView.expandedId = notificationId;
}
}
}
// Delete button
NIconButton {
icon: "trash"
tooltipText: I18n.tr("tooltips.delete-notification")
baseSize: notificationDelegate.actionButtonSize
onClicked: {
NotificationService.removeFromHistory(notificationId);
}
}
}
}
}
@@ -9,6 +9,14 @@ ColumnLayout {
spacing: Style.marginL
Layout.fillWidth: true
NToggle {
label: I18n.tr("panels.notifications.settings-markdown-label")
description: I18n.tr("panels.notifications.settings-markdown-description")
checked: Settings.data.notifications.enableMarkdown
onToggled: checked => Settings.data.notifications.enableMarkdown = checked
defaultValue: Settings.getDefaultValue("notifications.enableMarkdown")
}
NToggle {
label: I18n.tr("panels.notifications.history-low-urgency-label")
description: I18n.tr("panels.notifications.history-low-urgency-description")
+65
View File
@@ -286,7 +286,9 @@ Singleton {
// Update properties (keeping original timestamp and progress)
activeList.setProperty(index, "summary", data.summary);
activeList.setProperty(index, "summaryMarkdown", data.summaryMarkdown);
activeList.setProperty(index, "body", data.body);
activeList.setProperty(index, "bodyMarkdown", data.bodyMarkdown);
activeList.setProperty(index, "appName", data.appName);
activeList.setProperty(index, "urgency", data.urgency);
activeList.setProperty(index, "expireTimeout", data.expireTimeout);
@@ -427,7 +429,9 @@ Singleton {
return {
"id": id,
"summary": processNotificationText(n.summary || ""),
"summaryMarkdown": processNotificationMarkdown(n.summary || ""),
"body": processNotificationText(n.body || ""),
"bodyMarkdown": processNotificationMarkdown(n.body || ""),
"appName": getAppName(n.appName || n.desktopEntry || ""),
"urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency,
"expireTimeout": n.expireTimeout,
@@ -466,7 +470,9 @@ Singleton {
// Update properties (keeping timestamp and progress)
activeList.setProperty(index, "summary", data.summary);
activeList.setProperty(index, "summaryMarkdown", data.summaryMarkdown);
activeList.setProperty(index, "body", data.body);
activeList.setProperty(index, "bodyMarkdown", data.bodyMarkdown);
activeList.setProperty(index, "appName", data.appName);
activeList.setProperty(index, "urgency", data.urgency);
activeList.setProperty(index, "expireTimeout", data.expireTimeout);
@@ -639,7 +645,9 @@ Singleton {
historyList.append({
"id": item.id || "",
"summary": item.summary || "",
"summaryMarkdown": processNotificationMarkdown(item.summary || ""),
"body": item.body || "",
"bodyMarkdown": processNotificationMarkdown(item.body || ""),
"appName": item.appName || "",
"urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency,
"timestamp": time,
@@ -743,6 +751,59 @@ Singleton {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function sanitizeMarkdownUrl(url) {
if (!url)
return "";
const trimmed = url.trim();
if (trimmed === "")
return "";
const lower = trimmed.toLowerCase();
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("mailto:")) {
return encodeURI(trimmed);
}
return "";
}
function sanitizeMarkdown(text) {
if (!text)
return "";
let input = String(text);
// Strip images entirely
input = input.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (match, alt) {
return alt ? alt : "";
});
// Extract links into placeholders
const links = [];
input = input.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (match, label, urlAndTitle) {
const urlPart = (urlAndTitle || "").trim().split(/\s+/)[0] || "";
const safeUrl = sanitizeMarkdownUrl(urlPart);
const safeLabel = escapeHtml(label);
if (!safeUrl)
return safeLabel;
const token = "__MDLINK_" + links.length + "__";
links.push({
"label": safeLabel,
"url": safeUrl
});
return token;
});
// Escape any remaining HTML
input = escapeHtml(input);
// Restore sanitized links
for (let i = 0; i < links.length; i++) {
const token = "__MDLINK_" + i + "__";
const link = links[i];
input = input.split(token).join("[" + link.label + "](" + link.url + ")");
}
return input;
}
function processNotificationText(text) {
if (!text)
return "";
@@ -773,6 +834,10 @@ Singleton {
return result;
}
function processNotificationMarkdown(text) {
return sanitizeMarkdown(text);
}
function generateImageId(notification, image) {
if (image && image.startsWith("image://")) {
if (image.startsWith("image://qsimage/")) {