mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
Merge pull request #1856 from tibssy/feat/notification-markdown
Feat/notification markdown
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -387,6 +387,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": true,
|
||||
"enableMarkdown": false,
|
||||
"density": "default",
|
||||
"monitors": [],
|
||||
"location": "top_right",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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/")) {
|
||||
|
||||
Reference in New Issue
Block a user