diff --git a/Modules/ControlCenter/KeystonePage.qml b/Modules/ControlCenter/KeystonePage.qml index 28238127..cf34f935 100644 --- a/Modules/ControlCenter/KeystonePage.qml +++ b/Modules/ControlCenter/KeystonePage.qml @@ -10,66 +10,6 @@ Item { property string currentSection: "overview" - component SearchSelectSettingRow: Item { - id: selectRow - - property string title: "" - property string description: "" - property var options: [] - property string value: "" - property string placeholder: "" - property int fieldWidth: 240 - - signal accepted(string value) - - Layout.fillWidth: true - Layout.preferredHeight: Math.max(58, selectLabelColumn.implicitHeight + 16) - - RowLayout { - anchors.fill: parent - spacing: 16 - - Column { - id: selectLabelColumn - - Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - spacing: 3 - - Text { - width: parent.width - text: selectRow.title - color: Appearance.colors.colOnSurface - font.family: Fonts.ui - font.pixelSize: 15 - font.weight: Font.Medium - elide: Text.ElideRight - } - - Text { - width: parent.width - text: selectRow.description - color: Appearance.colors.colSubtext - font.family: Fonts.ui - font.pixelSize: 12 - wrapMode: Text.WordWrap - } - } - - SearchSelectMenuField { - Layout.preferredWidth: selectRow.fieldWidth - Layout.preferredHeight: 40 - Layout.alignment: Qt.AlignVCenter - options: selectRow.options - value: selectRow.value - placeholder: selectRow.placeholder - textRole: "label" - valueRole: "value" - onAccepted: value => selectRow.accepted(value) - } - } - } - function openSection(section) { root.currentSection = String(section || "overview"); } @@ -120,7 +60,9 @@ Item { options: PersonalizationConfig.keystoneStyles value: PersonalizationConfig.keystoneStyle placeholder: qsTr("选择钥石样式") - onAccepted: value => PersonalizationConfig.setKeystoneStyle(value) + onAccepted: (value) => { + return PersonalizationConfig.setKeystoneStyle(value); + } } SettingsRow { @@ -129,10 +71,13 @@ Item { trailing: EdgePositionSelector { position: PersonalizationConfig.keystonePosition - onPositionSelected: position => - PersonalizationConfig.setKeystonePosition(position) + onPositionSelected: (position) => { + return PersonalizationConfig.setKeystonePosition(position); + } } + } + } KeystoneSection { @@ -141,8 +86,7 @@ Item { Item { Layout.fillWidth: true - Layout.preferredHeight: Math.max( - 66, (width - 8) * 42 / 220 + 12) + Layout.preferredHeight: Math.max(66, (width - 8) * 42 / 220 + 12) HorizontalClockPreview { anchors.left: parent.left @@ -154,6 +98,7 @@ Item { anchors.topMargin: 6 anchors.bottomMargin: 6 } + } SettingsRow { @@ -165,6 +110,7 @@ Item { Accessible.name: qsTr("隐藏日期") onToggled: PersonalizationConfig.setKeystoneHideDate(checked) } + } SettingsActionRow { @@ -175,13 +121,42 @@ Item { trailingIconName: "chevron_right" onClicked: root.openSection("horizontal-clock") } + + } + + KeystoneSection { + title: qsTr("通知隐私") + iconName: "visibility_off" + + SearchSelectSettingRow { + title: qsTr("锁屏通知") + description: qsTr("锁屏时通知内容的显示方式") + options: [{ + "value": "show", + "label": qsTr("显示内容") + }, { + "value": "hide-content", + "label": qsTr("隐藏内容") + }, { + "value": "hide-all", + "label": qsTr("全部隐藏") + }] + value: UiPreferences.lockScreenNotificationPrivacy + placeholder: qsTr("选择显示方式") + onAccepted: (value) => { + return UiPreferences.setLockScreenNotificationPrivacy(value); + } + } + } Item { Layout.fillWidth: true Layout.preferredHeight: 24 } + } + } Loader { @@ -192,7 +167,72 @@ Item { anchors.top: subpageHeader.bottom anchors.bottom: parent.bottom visible: root.currentSection !== "overview" - source: root.currentSection === "horizontal-clock" - ? Qt.resolvedUrl("HorizontalClockPage.qml") : "" + source: root.currentSection === "horizontal-clock" ? Qt.resolvedUrl("HorizontalClockPage.qml") : "" } + + component SearchSelectSettingRow: Item { + id: selectRow + + property string title: "" + property string description: "" + property var options: [] + property string value: "" + property string placeholder: "" + property int fieldWidth: 240 + + signal accepted(string value) + + Layout.fillWidth: true + Layout.preferredHeight: Math.max(58, selectLabelColumn.implicitHeight + 16) + + RowLayout { + anchors.fill: parent + spacing: 16 + + Column { + id: selectLabelColumn + + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 3 + + Text { + width: parent.width + text: selectRow.title + color: Appearance.colors.colOnSurface + font.family: Fonts.ui + font.pixelSize: 15 + font.weight: Font.Medium + elide: Text.ElideRight + } + + Text { + width: parent.width + text: selectRow.description + color: Appearance.colors.colSubtext + font.family: Fonts.ui + font.pixelSize: 12 + wrapMode: Text.WordWrap + } + + } + + SearchSelectMenuField { + Layout.preferredWidth: selectRow.fieldWidth + Layout.preferredHeight: 40 + Layout.alignment: Qt.AlignVCenter + options: selectRow.options + value: selectRow.value + placeholder: selectRow.placeholder + textRole: "label" + valueRole: "value" + onAccepted: (value) => { + return selectRow.accepted(value); + } + } + + } + + } + } diff --git a/Modules/Keystone/NotificationContent/NotificationContent.qml b/Modules/Keystone/NotificationContent/NotificationContent.qml index 39f98a50..e819583a 100644 --- a/Modules/Keystone/NotificationContent/NotificationContent.qml +++ b/Modules/Keystone/NotificationContent/NotificationContent.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Layouts import Quickshell +import Quickshell.Services.Notifications import qs.Common import qs.Services import qs.Widgets.common @@ -9,10 +10,18 @@ Item { id: root required property var manager + // Активно ли поле ответа (раскрыто по hover) — KeystoneSurface отдаёт + // клавиатурный фокус слою, чтобы можно было печатать ответ. + property bool replyActive: false + // Фактическая высота содержимого (сумма делегатов) — KeystoneSurface + // использует её для динамической высоты попапа при hover-раскрытии. + readonly property real contentHeight: notifList.contentHeight visible: !UiPreferences.dndEnabled && manager.hasNotifs StyledListView { + id: notifList + anchors.fill: parent model: root.manager.popupList spacing: 10 @@ -25,33 +34,80 @@ Item { id: delegateRoot required property var modelData - - width: ListView.view.width - height: 60 - color: "transparent" - readonly property string imageSource: modelData && modelData.image ? modelData.image : "" readonly property string appIconSource: modelData && modelData.appIcon ? Quickshell.iconPath(modelData.appIcon, "image-missing") : "" readonly property string iconSource: imageSource !== "" ? imageSource : appIconSource readonly property bool hasImage: imageSource !== "" readonly property bool hasIcon: iconSource !== "" + readonly property bool isCritical: modelData && modelData.urgency === NotificationUrgency.Critical.toString() + readonly property bool isLow: modelData && modelData.urgency === NotificationUrgency.Low.toString() + // Расширение по наведению: показываем действия, приостанавливаем таймер. + // HoverHandler вместо MouseArea: не конфликтует с вложенными + // MouseArea (RippleButton) и не создаёт цикл свернуть/развернуть. + readonly property bool hovered: hoverHandler.hovered + readonly property bool expanded: hovered + readonly property int baseHeight: 60 + readonly property int expandedHeight: expanded ? Math.max(baseHeight, actionsColumn.implicitHeight + 46) : baseHeight + readonly property bool hasActions: modelData && modelData.actions && modelData.actions.length > 0 + readonly property bool canReply: modelData && modelData.hasInlineReply - Timer { - interval: 5000 - running: true - repeat: false - onTriggered: root.manager.removeByNotifId(delegateRoot.modelData.notificationId) + width: ListView.view.width + height: delegateRoot.expandedHeight + color: delegateRoot.expanded ? Appearance.colors.colSurfaceContainerLow : "transparent" + radius: Appearance.rounding.normal + // При раскрытии с полем ответа — передать фокус вводу, + // чтобы можно было печатать без клика по полю. + // При сворачивании — снять фокус с поля, чтобы не удерживать + // клавиатуру за Keystone. + onExpandedChanged: { + if (expanded && canReply) { + root.replyActive = true; + Qt.callLater(() => { + return replyInput.forceActiveFocus(); + }); + } else { + root.replyActive = false; + replyInput.focus = false; + } + } + // Критично: если делегат уничтожен (ответ отправлен, попап закрыт, + // уведомление истекло), не оставлять клавиатурный фокус захваченным — + // иначе Keystone навсегда удержит ввод и другие приложения + // не смогут печатать. + Component.onDestruction: root.replyActive = false + + HoverHandler { + id: hoverHandler + + onHoveredChanged: { + if (!modelData) + return ; + + if (hovered) + root.manager.cancelTimeout(modelData.notificationId); + else + root.manager.resumeTimeout(modelData.notificationId); + } } MouseArea { + id: hoverArea + anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.manager.removeByNotifId(delegateRoot.modelData.notificationId) + // Клик по телу = активация приложения (стандарт freedesktop: default action). + // Кнопки и поле ответа выше по z-order — получают клики раньше. + onClicked: { + if (modelData) + root.manager.invokeDefaultAction(modelData.notificationId); + + } } RowLayout { - anchors.fill: parent - anchors.bottomMargin: 4 + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 10 spacing: 12 Rectangle { @@ -63,6 +119,7 @@ Item { Image { id: iconImage + anchors.fill: parent anchors.margins: delegateRoot.hasImage ? 0 : 6 source: delegateRoot.iconSource @@ -79,6 +136,7 @@ Item { font.pixelSize: 22 color: Appearance.colors.colOnSurfaceVariant } + } ColumnLayout { @@ -104,16 +162,191 @@ Item { Layout.fillWidth: true elide: Text.ElideRight maximumLineCount: 2 + visible: !delegateRoot.expanded || !delegateRoot.hasActions && !delegateRoot.canReply } + } - Text { - text: "close" - color: Appearance.colors.colOnSurfaceVariant - font.family: Fonts.materialSymbolsRounded - font.pixelSize: 18 - Layout.alignment: Qt.AlignRight | Qt.AlignTop + // Закрыть — отдельная зона, чтобы не активировать приложение. + MouseArea { + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + Layout.alignment: Qt.AlignTop + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + if (modelData) + root.manager.discardNotification(modelData.notificationId); + + } + + Text { + anchors.centerIn: parent + text: "close" + color: parent.containsMouse ? Appearance.colors.colOnSurface : Appearance.colors.colOnSurfaceVariant + font.family: Fonts.materialSymbolsRounded + font.pixelSize: 18 + } + } + + } + + // Действия, раскрывающиеся по наведению. + ColumnLayout { + id: actionsColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: 62 + anchors.rightMargin: 10 + anchors.bottomMargin: 8 + spacing: 8 + visible: delegateRoot.expanded && (delegateRoot.hasActions || delegateRoot.canReply) + opacity: visible ? 1 : 0 + + // Inline reply (напр. Telegram): поле быстрого ответа. + RowLayout { + visible: delegateRoot.canReply + Layout.fillWidth: true + spacing: 6 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 32 + radius: Appearance.rounding.small + color: Appearance.colors.colLayer1 + + TextInput { + id: replyInput + + function sendReply() { + const text = replyInput.text.trim(); + if (text === "" || !delegateRoot.modelData) + return ; + + root.manager.sendInlineReply(delegateRoot.modelData.notificationId, text); + } + + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + verticalAlignment: Text.AlignVCenter + color: Appearance.colors.colOnSurface + font.family: Fonts.ui + font.pixelSize: 12 + clip: true + selectByMouse: true + Keys.onReturnPressed: sendReply() + Keys.onEnterPressed: sendReply() + + Text { + anchors.fill: parent + visible: replyInput.text === "" + verticalAlignment: Text.AlignVCenter + text: delegateRoot.modelData && delegateRoot.modelData.inlineReplyPlaceholder ? delegateRoot.modelData.inlineReplyPlaceholder : qsTr("回复") + color: Appearance.colors.colSubtext + font.family: Fonts.ui + font.pixelSize: 12 + elide: Text.ElideRight + } + + } + + } + + RippleButton { + Layout.preferredHeight: 32 + Layout.preferredWidth: 40 + buttonRadius: Appearance.rounding.small + containerColor: Appearance.colors.colPrimaryContainer + stateLayerColor: Appearance.colors.colPrimaryContainerHover + pressedStateLayerColor: Appearance.colors.colPrimaryContainerActive + rippleColor: Appearance.colors.colOnPrimaryContainer + onClicked: replyInput.sendReply() + + contentItem: Item { + implicitWidth: 24 + implicitHeight: 24 + + Text { + anchors.centerIn: parent + text: "send" + font.family: Fonts.materialSymbolsRounded + font.pixelSize: 18 + color: Appearance.colors.colOnPrimaryContainer + } + + } + + } + + } + + // Действия приложения (Reply / Mark as read / Open и т.п.). + RowLayout { + visible: delegateRoot.hasActions + Layout.fillWidth: true + spacing: 6 + layoutDirection: Qt.RightToLeft + + Repeater { + model: delegateRoot.hasActions ? delegateRoot.modelData.actions : [] + + RippleButton { + required property var modelData + + Layout.fillWidth: true + Layout.preferredHeight: 32 + buttonRadius: Appearance.rounding.small + containerColor: delegateRoot.isCritical ? Appearance.colors.colSecondaryContainer : Appearance.colors.colLayer4 + stateLayerColor: delegateRoot.isCritical ? Appearance.colors.colSecondaryContainerHover : Appearance.colors.colLayer4Hover + pressedStateLayerColor: delegateRoot.isCritical ? Appearance.colors.colSecondaryContainerActive : Appearance.colors.colLayer4Active + rippleColor: delegateRoot.isCritical ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer4 + onClicked: { + if (delegateRoot.modelData) + root.manager.attemptInvokeAction(delegateRoot.modelData.notificationId, modelData.identifier); + + } + + contentItem: RowLayout { + anchors.centerIn: parent + spacing: 5 + + Text { + text: "arrow_forward" + font.family: Fonts.materialSymbolsRounded + font.pixelSize: 16 + color: delegateRoot.isCritical ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer4 + } + + Text { + text: modelData.text + font.family: Fonts.ui + font.pixelSize: 12 + font.weight: Font.Medium + color: delegateRoot.isCritical ? Appearance.colors.colOnSecondaryContainer : Appearance.colors.colOnLayer4 + elide: Text.ElideRight + } + + } + + } + + } + + } + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.expressiveDefaultEffects.duration + easing.type: Appearance.animation.expressiveDefaultEffects.type + easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve + } + + } + } Rectangle { @@ -121,14 +354,37 @@ Item { anchors.horizontalCenter: parent.horizontalCenter height: 2 radius: 1 - color: Appearance.colors.colPrimary + // Urgency-индикатор: Critical → error, Low → subtext, иначе primary. + // Зеркалит цветовую схему NotificationItem. + color: delegateRoot.isCritical ? Appearance.colors.colError : (delegateRoot.isLow ? Appearance.colors.colSubtext : Appearance.colors.colPrimary) NumberAnimation on width { from: delegateRoot.width - 20 to: 0 duration: 5000 + running: !delegateRoot.isCritical && !delegateRoot.hovered + } + + } + + Behavior on height { + NumberAnimation { + duration: Appearance.animation.expressiveDefaultEffects.duration + easing.type: Appearance.animation.expressiveDefaultEffects.type + easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve + } + + } + + Behavior on color { + ColorAnimation { + duration: Appearance.animation.expressiveDefaultEffects.duration } + } + } + } + } diff --git a/Modules/Keystone/Styles/Shared/KeystoneSurface.qml b/Modules/Keystone/Styles/Shared/KeystoneSurface.qml index 1c848797..9dd1860a 100644 --- a/Modules/Keystone/Styles/Shared/KeystoneSurface.qml +++ b/Modules/Keystone/Styles/Shared/KeystoneSurface.qml @@ -125,7 +125,7 @@ Variants { WlrLayershell.namespace: "clavis-shell-keystone" WlrLayershell.layer: WlrLayer.Top WlrLayershell.exclusionMode: ExclusionMode.Ignore - WlrLayershell.keyboardFocus: root.hasClosablePopup ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + WlrLayershell.keyboardFocus: (root.hasClosablePopup || notifContent.replyActive) ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None anchors { top: true @@ -456,7 +456,9 @@ Variants { readonly property int pillFusionDuration: 820 property int pillActiveFusionDuration: pillFusionDuration property int notifW: 380 - property int notifH: (NotificationManager.popupList.length * 70) + 20 + // Динамическая высота: учитывает hover-раскрытие делегата + // (actionsColumn добавляет ~40px к одному уведомлению). + property int notifH: Math.min(320, Math.max(80, notifContent.contentHeight + 20)) property color color: BlurService.backgroundColor(Appearance.colors.colLayer0) readonly property QtObject activeLayout: keystoneWindow.horizontalEdge ? horizontalLayout : verticalLayout readonly property real recordingVisualWidth: styleSurface.detached && pillRecordingPresenter.item ? pillRecordingPresenter.item.implicitWidth : activeLayout.attachedRecordingWidth @@ -476,9 +478,6 @@ Variants { property string sliderMode: "volume" readonly property var currentPlayer: MediaManager.active - onDashboardTabActiveChanged: SystemIdentityService.setUptimeConsumer(root.dashboardUptimeOwner, root.dashboardTabActive) - Component.onDestruction: SystemIdentityService.setUptimeConsumer(root.dashboardUptimeOwner, false) - function isHoverWidthMotion(nextW) { return isCollapsedMode && Math.abs(nextW - width) <= KeystoneMotion.hoverWidthDelta; } @@ -512,6 +511,8 @@ Variants { root.triggerSliderOSD("volume"); } + onDashboardTabActiveChanged: SystemIdentityService.setUptimeConsumer(root.dashboardUptimeOwner, root.dashboardTabActive) + Component.onDestruction: SystemIdentityService.setUptimeConsumer(root.dashboardUptimeOwner, false) state: keystoneWindow.edge // HubContent changes its own currentIndex when a tab is // clicked. Keep the two pieces of state synchronized @@ -1186,6 +1187,8 @@ Variants { } NotificationContent { + id: notifContent + anchors.top: parent.top anchors.horizontalCenter: parent.horizontalCenter anchors.topMargin: 10 diff --git a/Modules/Lock/Cards/NotificationCard.qml b/Modules/Lock/Cards/NotificationCard.qml index bf71d69b..9d7a3d79 100644 --- a/Modules/Lock/Cards/NotificationCard.qml +++ b/Modules/Lock/Cards/NotificationCard.qml @@ -14,6 +14,9 @@ Rectangle { }) readonly property int notificationCount: notifications.length readonly property int cardMargin: Sizes.lockOuterPadding + // Приватность: "show" / "hide-content" / "hide-all" (см. UiPreferences). + readonly property bool privacyHideContent: UiPreferences.lockScreenNotificationPrivacy === "hide-content" + readonly property bool privacyHideAll: UiPreferences.lockScreenNotificationPrivacy === "hide-all" function normalizeSource(source) { if (!source || source === "") @@ -53,6 +56,8 @@ Rectangle { return Qt.formatDate(date, "MM/dd") + " " + UiPreferences.shortTime(date); } + // visible: при "hide-all" карточка скрыта полностью, но не сжимается layout. + visible: !privacyHideAll Layout.fillWidth: true Layout.fillHeight: true color: Appearance.colors.colLayer2 @@ -258,6 +263,7 @@ Rectangle { } Text { + visible: !delegateRoot.modelData || !root.privacyHideContent text: delegateRoot.modelData ? delegateRoot.modelData.summary : "" color: Appearance.colors.colOnSurface font.family: Fonts.ui @@ -267,7 +273,39 @@ Rectangle { Layout.fillWidth: true } + // При "hide-content" содержимое не показываем: + // вместо текста — иконка приватности. + Item { + visible: root.privacyHideContent + Layout.fillWidth: true + Layout.preferredHeight: 20 + + RowLayout { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + Text { + text: "visibility_off" + color: Appearance.colors.colOnSurfaceVariant + font.family: Fonts.materialSymbolsRounded + font.pixelSize: 15 + } + + Text { + text: qsTr("通知内容已隐藏") + color: Appearance.colors.colOnSurfaceVariant + font.family: Fonts.ui + font.pixelSize: 15 + elide: Text.ElideRight + } + + } + + } + Text { + visible: !root.privacyHideContent text: delegateRoot.modelData ? delegateRoot.modelData.body : "" color: Appearance.colors.colOnSurfaceVariant font.family: Fonts.ui diff --git a/Modules/Sidebars/Left/notifications/NotificationGroup.qml b/Modules/Sidebars/Left/notifications/NotificationGroup.qml index c1dbedd3..68e5036e 100644 --- a/Modules/Sidebars/Left/notifications/NotificationGroup.qml +++ b/Modules/Sidebars/Left/notifications/NotificationGroup.qml @@ -23,18 +23,8 @@ MouseArea { property int parentDragIndex: dragHost ? dragHost.dragIndex : -1 property real parentDragDistance: dragHost ? dragHost.dragDistance : 0 property int dragIndexDiff: Math.abs(parentDragIndex - delegateIndex) - property real xOffset: dragIndexDiff === 0 ? parentDragDistance - : Math.abs(parentDragDistance) > dragConfirmThreshold ? 0 - : dragIndexDiff === 1 ? parentDragDistance * 0.3 - : dragIndexDiff === 2 ? parentDragDistance * 0.1 - : 0 - readonly property bool latestNotificationHasImage: notificationCount > 0 - && notifications[notificationCount - 1].image !== "" - - implicitHeight: background.implicitHeight - hoverEnabled: true - - NotificationUtils { id: notifUtils } + property real xOffset: dragIndexDiff === 0 ? parentDragDistance : Math.abs(parentDragDistance) > dragConfirmThreshold ? 0 : dragIndexDiff === 1 ? parentDragDistance * 0.3 : dragIndexDiff === 2 ? parentDragDistance * 0.1 : 0 + readonly property bool latestNotificationHasImage: notificationCount > 0 && notifications[notificationCount - 1].image !== "" function isCriticalUrgency(urgency) { return urgency === NotificationUrgency.Critical || urgency === NotificationUrgency.Critical.toString(); @@ -43,6 +33,7 @@ MouseArea { function destroyWithAnimation(left = false) { if (root.dragHost) root.dragHost.resetDrag(); + dragManager.resetDrag(); background.anchors.leftMargin = background.anchors.leftMargin; destroyAnimation.left = left; @@ -52,23 +43,46 @@ MouseArea { function toggleExpanded() { implicitHeightAnim.enabled = !root.multipleNotifications || root.expanded; root.expanded = !root.expanded; + if (root.expanded) + root.notifications.forEach((notif) => { + return NotificationManager.markAsRead(notif.notificationId); + }); + } + implicitHeight: background.implicitHeight + hoverEnabled: true onContainsMouseChanged: { if (!root.popup) - return; + return ; - if (root.containsMouse) { - root.notifications.forEach((notif) => NotificationManager.cancelTimeout(notif.notificationId)); - } else { - root.notifications.forEach((notif) => NotificationManager.timeoutNotification(notif.notificationId)); - } + if (root.containsMouse) + root.notifications.forEach((notif) => { + return NotificationManager.cancelTimeout(notif.notificationId); + }); + else + root.notifications.forEach((notif) => { + return NotificationManager.timeoutNotification(notif.notificationId); + }); + } + + NotificationUtils { + id: notifUtils } SequentialAnimation { id: destroyAnimation + property bool left: true + running: false + onFinished: { + root.notifications.forEach((notif) => { + Qt.callLater(() => { + return NotificationManager.discardNotification(notif.notificationId); + }); + }); + } NumberAnimation { target: background.anchors @@ -79,40 +93,45 @@ MouseArea { easing.bezierCurve: Appearance.animation.expressiveDefaultSpatial.bezierCurve } - onFinished: { - root.notifications.forEach((notif) => { - Qt.callLater(() => NotificationManager.discardNotification(notif.notificationId)); - }); - } } DragManager { id: dragManager + anchors.fill: parent interactive: !root.expanded automaticallyReset: false acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton - onPressed: (mouse) => { if (mouse.button === Qt.RightButton) root.toggleExpanded(); - } + } onClicked: (mouse) => { - if (mouse.button === Qt.MiddleButton) + if (mouse.button === Qt.MiddleButton) { root.destroyWithAnimation(); + } else if (mouse.button === Qt.LeftButton) { + // Свёрнутая группа из нескольких уведомлений — раскрыть. + // Одиночное уведомление (или клик по раскрытой группе) — + // переход в приложение (default action). + if (root.multipleNotifications && !root.expanded) { + root.toggleExpanded(); + } else if (root.notificationCount > 0) { + const notif = root.notifications[root.notificationCount - 1]; + NotificationManager.invokeDefaultAction(notif.notificationId); + } + } } - onDraggingChanged: { if (dragging && root.dragHost) root.dragHost.dragIndex = root.delegateIndex; - } + } onDragDiffXChanged: { if (root.dragHost) root.dragHost.dragDistance = dragDiffX; - } + } onDragReleased: (diffX) => { if (Math.abs(diffX) > root.dragConfirmThreshold) { root.destroyWithAnimation(diffX < 0); @@ -120,6 +139,7 @@ MouseArea { dragManager.resetDrag(); if (root.dragHost) root.dragHost.resetDrag(); + } } } @@ -130,36 +150,14 @@ MouseArea { anchors.left: parent.left anchors.leftMargin: root.xOffset width: parent.width - color: root.popup - ? Appearance.colors.colBackgroundSurfaceContainer - : BlurService.opaqueBackgroundColor( - Appearance.m3colors.m3surfaceContainer) + color: root.popup ? Appearance.colors.colBackgroundSurfaceContainer : BlurService.opaqueBackgroundColor(Appearance.m3colors.m3surfaceContainer) radius: Appearance.rounding.normal clip: true - implicitHeight: root.expanded - ? row.implicitHeight + root.padding * 2 - : Math.min(80, row.implicitHeight + root.padding * 2) - - Behavior on anchors.leftMargin { - enabled: !dragManager.dragging && !destroyAnimation.running - NumberAnimation { - duration: Appearance.animation.expressiveDefaultSpatial.duration - easing.type: Appearance.animation.expressiveFastSpatial.type - easing.bezierCurve: Appearance.animation.expressiveFastSpatial.bezierCurve - } - } - - Behavior on implicitHeight { - id: implicitHeightAnim - NumberAnimation { - duration: Appearance.animation.expressiveDefaultEffects.duration - easing.type: Appearance.animation.expressiveDefaultEffects.type - easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve - } - } + implicitHeight: root.expanded ? row.implicitHeight + root.padding * 2 : Math.min(80, row.implicitHeight + root.padding * 2) RowLayout { id: row + anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right @@ -171,34 +169,27 @@ MouseArea { image: root.multipleNotifications || root.notificationCount === 0 ? "" : root.notifications[0].image appIcon: root.notificationGroup ? root.notificationGroup.appIcon : "" summary: root.notificationCount > 0 ? root.notifications[root.notificationCount - 1].summary : "" - urgency: root.notifications.some((notif) => root.isCriticalUrgency(notif.urgency)) - ? NotificationUrgency.Critical.toString() - : NotificationUrgency.Normal.toString() + urgency: root.notifications.some((notif) => { + return root.isCriticalUrgency(notif.urgency); + }) ? NotificationUrgency.Critical.toString() : NotificationUrgency.Normal.toString() } ColumnLayout { Layout.fillWidth: true - spacing: root.expanded - ? (root.multipleNotifications ? (root.latestNotificationHasImage ? 35 : 5) : 0) - : 0 - - Behavior on spacing { - NumberAnimation { - duration: Appearance.animation.expressiveDefaultEffects.duration - easing.type: Appearance.animation.expressiveDefaultEffects.type - easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve - } - } + spacing: root.expanded ? (root.multipleNotifications ? (root.latestNotificationHasImage ? 35 : 5) : 0) : 0 Item { id: topRow - Layout.fillWidth: true - implicitHeight: Math.max(topTextRow.implicitHeight, expandButton.implicitHeight) + property real fontSize: 12 property bool showAppName: root.multipleNotifications + Layout.fillWidth: true + implicitHeight: Math.max(topTextRow.implicitHeight, expandButton.implicitHeight) + RowLayout { id: topTextRow + anchors.left: parent.left anchors.right: expandButton.left anchors.verticalCenter: parent.verticalCenter @@ -206,15 +197,11 @@ MouseArea { Text { Layout.fillWidth: true - text: (topRow.showAppName - ? (root.notificationGroup ? root.notificationGroup.appName : "") - : (root.notificationCount > 0 ? root.notifications[0].summary : "")) || "" + text: (topRow.showAppName ? (root.notificationGroup ? root.notificationGroup.appName : "") : (root.notificationCount > 0 ? root.notifications[0].summary : "")) || "" font.family: Fonts.ui font.pixelSize: topRow.showAppName ? topRow.fontSize : 13 font.bold: !topRow.showAppName - color: topRow.showAppName - ? Appearance.colors.colSubtext - : Appearance.colors.colOnLayer2 + color: topRow.showAppName ? Appearance.colors.colSubtext : Appearance.colors.colOnLayer2 elide: Text.ElideRight } @@ -226,18 +213,23 @@ MouseArea { font.pixelSize: topRow.fontSize color: Appearance.colors.colSubtext } + } NotificationGroupExpandButton { id: expandButton + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter count: root.notificationCount expanded: root.expanded fontSize: topRow.fontSize onClicked: root.toggleExpanded() - altAction: () => root.toggleExpanded() + altAction: () => { + return root.toggleExpanded(); + } } + } StyledListView { @@ -246,6 +238,11 @@ MouseArea { property int dragIndex: -1 property real dragDistance: 0 + function resetDrag() { + dragIndex = -1; + dragDistance = 0; + } + Layout.fillWidth: true implicitHeight: contentHeight spacing: root.expanded ? 5 : 3 @@ -255,23 +252,17 @@ MouseArea { showVerticalScrollBar: false clip: false - function resetDrag() { - dragIndex = -1; - dragDistance = 0; - } - Behavior on spacing { NumberAnimation { duration: Appearance.animation.expressiveDefaultEffects.duration easing.type: Appearance.animation.expressiveDefaultEffects.type easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve } + } model: ScriptModel { - values: root.expanded - ? root.notifications.slice().reverse() - : root.notifications.slice().reverse().slice(0, 2) + values: root.expanded ? root.notifications.slice().reverse() : root.notifications.slice().reverse().slice(0, 2) objectProp: "notificationId" } @@ -289,8 +280,44 @@ MouseArea { opacity: (!root.expanded && index === 1 && root.notificationCount > 2) ? 0.5 : 1 visible: root.expanded || index < 2 } + + } + + Behavior on spacing { + NumberAnimation { + duration: Appearance.animation.expressiveDefaultEffects.duration + easing.type: Appearance.animation.expressiveDefaultEffects.type + easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve + } + } + + } + + } + + Behavior on anchors.leftMargin { + enabled: !dragManager.dragging && !destroyAnimation.running + + NumberAnimation { + duration: Appearance.animation.expressiveDefaultSpatial.duration + easing.type: Appearance.animation.expressiveFastSpatial.type + easing.bezierCurve: Appearance.animation.expressiveFastSpatial.bezierCurve } + } + + Behavior on implicitHeight { + id: implicitHeightAnim + + NumberAnimation { + duration: Appearance.animation.expressiveDefaultEffects.duration + easing.type: Appearance.animation.expressiveDefaultEffects.type + easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve + } + + } + } + } diff --git a/Modules/Sidebars/Left/notifications/NotificationItem.qml b/Modules/Sidebars/Left/notifications/NotificationItem.qml index 22391029..906c24b4 100644 --- a/Modules/Sidebars/Left/notifications/NotificationItem.qml +++ b/Modules/Sidebars/Left/notifications/NotificationItem.qml @@ -23,24 +23,10 @@ Item { property int parentDragIndex: dragHost ? dragHost.dragIndex : -1 property real parentDragDistance: dragHost ? dragHost.dragDistance : 0 property int dragIndexDiff: Math.abs(parentDragIndex - delegateIndex) - property real xOffset: dragIndexDiff === 0 ? parentDragDistance - : Math.abs(parentDragDistance) > dragConfirmThreshold ? 0 - : dragIndexDiff === 1 ? parentDragDistance * 0.3 - : dragIndexDiff === 2 ? parentDragDistance * 0.1 - : 0 + property real xOffset: dragIndexDiff === 0 ? parentDragDistance : Math.abs(parentDragDistance) > dragConfirmThreshold ? 0 : dragIndexDiff === 1 ? parentDragDistance * 0.3 : dragIndexDiff === 2 ? parentDragDistance * 0.1 : 0 readonly property var notificationActions: notificationObject && notificationObject.actions ? notificationObject.actions : [] readonly property string notificationUrgency: notificationObject ? notificationObject.urgency : NotificationUrgency.Normal.toString() - implicitHeight: background.implicitHeight - - NotificationUtils { id: notifUtils } - - TextMetrics { - id: summaryTextMetrics - font.pixelSize: root.fontSize - text: root.notificationObject ? root.notificationObject.summary : "" - } - function isCriticalUrgency(urgency) { return urgency === NotificationUrgency.Critical || urgency === NotificationUrgency.Critical.toString(); } @@ -54,16 +40,37 @@ Item { function destroyWithAnimation(left = false) { if (root.dragHost) root.dragHost.resetDrag(); + dragManager.resetDrag(); background.anchors.leftMargin = background.anchors.leftMargin; destroyAnimation.left = left; destroyAnimation.running = true; } + implicitHeight: background.implicitHeight + + NotificationUtils { + id: notifUtils + } + + TextMetrics { + id: summaryTextMetrics + + font.pixelSize: root.fontSize + text: root.notificationObject ? root.notificationObject.summary : "" + } + SequentialAnimation { id: destroyAnimation + property bool left: true + running: false + onFinished: { + if (root.notificationObject) + NotificationManager.discardNotification(root.notificationObject.notificationId); + + } NumberAnimation { target: background.anchors @@ -74,35 +81,37 @@ Item { easing.bezierCurve: Appearance.animation.expressiveDefaultSpatial.bezierCurve } - onFinished: { - if (root.notificationObject) - NotificationManager.discardNotification(root.notificationObject.notificationId); - } } DragManager { id: dragManager + anchors.fill: root anchors.leftMargin: root.expanded ? -notificationIcon.implicitWidth : 0 interactive: root.expanded automaticallyReset: false acceptedButtons: Qt.LeftButton | Qt.MiddleButton - onClicked: (mouse) => { - if (mouse.button === Qt.MiddleButton) + if (mouse.button === Qt.MiddleButton) { root.destroyWithAnimation(); - } + } else if (mouse.button === Qt.LeftButton) { + // Клик по телу уведомления = переход в приложение + // (стандарт freedesktop: default action). + if (root.notificationObject) + NotificationManager.invokeDefaultAction(root.notificationObject.notificationId); + } + } onDraggingChanged: { if (dragging && root.dragHost) root.dragHost.dragIndex = root.delegateIndex; - } + } onDragDiffXChanged: { if (root.dragHost) root.dragHost.dragDistance = dragDiffX; - } + } onDragReleased: (diffX) => { if (Math.abs(diffX) > root.dragConfirmThreshold) { root.destroyWithAnimation(diffX < 0); @@ -110,12 +119,14 @@ Item { dragManager.resetDrag(); if (root.dragHost) root.dragHost.resetDrag(); + } } } NotificationAppIcon { id: notificationIcon + opacity: (!root.onlyNotification && root.notificationObject && root.notificationObject.image !== "" && root.expanded) ? 1 : 0 visible: opacity > 0 image: root.notificationObject ? root.notificationObject.image : "" @@ -132,7 +143,9 @@ Item { easing.type: Appearance.animation.expressiveDefaultEffects.type easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve } + } + } Rectangle { @@ -142,48 +155,51 @@ Item { anchors.left: parent.left anchors.leftMargin: root.xOffset radius: Appearance.rounding.small - color: (root.expanded && !root.onlyNotification) - ? (root.isCriticalUrgency(root.notificationUrgency) - ? Appearance.mix(Appearance.colors.colSecondaryContainer, Appearance.colors.colLayer2, 0.35) - : Appearance.colors.colLayer3) - : Appearance.transparentize(Appearance.colors.colLayer3, 1) - implicitHeight: root.expanded - ? contentColumn.implicitHeight + root.padding * 2 - : root.onlyNotification - ? notificationBodyText.implicitHeight - : summaryRow.implicitHeight - - Behavior on anchors.leftMargin { - enabled: !dragManager.dragging && !destroyAnimation.running - NumberAnimation { - duration: Appearance.animation.expressiveDefaultSpatial.duration - easing.type: Appearance.animation.expressiveFastSpatial.type - easing.bezierCurve: Appearance.animation.expressiveFastSpatial.bezierCurve + color: (root.expanded && !root.onlyNotification) ? (root.isCriticalUrgency(root.notificationUrgency) ? Appearance.mix(Appearance.colors.colSecondaryContainer, Appearance.colors.colLayer2, 0.35) : Appearance.colors.colLayer3) : Appearance.transparentize(Appearance.colors.colLayer3, 1) + implicitHeight: root.expanded ? contentColumn.implicitHeight + root.padding * 2 : root.onlyNotification ? notificationBodyText.implicitHeight : summaryRow.implicitHeight + + // Urgency indicator bar: Critical → error color, Low → subtext, + // Normal → primary. Mirrors the accent used by NotificationAppIcon. + Rectangle { + id: urgencyBar + + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 4 + radius: 2 + visible: root.expanded || root.onlyNotification + color: { + if (root.isCriticalUrgency(root.notificationUrgency)) + return Appearance.colors.colError; + + if (root.notificationUrgency === NotificationUrgency.Low.toString()) + return Appearance.colors.colSubtext; + + return Appearance.colors.colPrimary; } } ColumnLayout { id: contentColumn + anchors.fill: parent - anchors.margins: root.expanded ? root.padding : 0 + anchors.leftMargin: (root.expanded || root.onlyNotification) ? Math.max(root.padding, urgencyBar.width + 4) : 0 + anchors.topMargin: root.expanded ? root.padding : 0 + anchors.rightMargin: root.expanded ? root.padding : 0 + anchors.bottomMargin: root.expanded ? root.padding : 0 spacing: 3 - Behavior on anchors.margins { - NumberAnimation { - duration: Appearance.animation.expressiveDefaultEffects.duration - easing.type: Appearance.animation.expressiveDefaultEffects.type - easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve - } - } - RowLayout { id: summaryRow + visible: !root.onlyNotification Layout.fillWidth: true implicitHeight: summaryText.implicitHeight Text { id: summaryText + Layout.fillWidth: summaryTextMetrics.width >= summaryRow.implicitWidth * root.summaryElideRatio visible: !root.onlyNotification text: root.notificationObject ? root.notificationObject.summary : "" @@ -213,24 +229,20 @@ Item { easing.type: Appearance.animation.expressiveDefaultEffects.type easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve } + } + } + } ColumnLayout { id: expandedContentColumn + Layout.fillWidth: true opacity: root.onlyNotification || root.expanded ? 1 : 0 visible: root.onlyNotification || opacity > 0 - Behavior on opacity { - NumberAnimation { - duration: Appearance.animation.expressiveDefaultEffects.duration - easing.type: Appearance.animation.expressiveDefaultEffects.type - easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve - } - } - Text { id: notificationBodyText @@ -238,13 +250,14 @@ Item { text: `${root.processedBody()}` textFormat: Text.RichText wrapMode: Text.Wrap - maximumLineCount: root.onlyNotification && !root.expanded ? 1 : 2147483647 + maximumLineCount: root.onlyNotification && !root.expanded ? 1 : 2.14748e+09 elide: root.onlyNotification && !root.expanded ? Text.ElideRight : Text.ElideNone font.family: Fonts.ui font.pixelSize: root.fontSize color: Appearance.colors.colSubtext - - onLinkActivated: (link) => Qt.openUrlExternally(link) + onLinkActivated: (link) => { + return Qt.openUrlExternally(link); + } } Item { @@ -253,26 +266,11 @@ Item { implicitHeight: actionsFlickable.implicitHeight opacity: root.expanded ? 1 : 0 visible: root.expanded || opacity > 0 - - Behavior on opacity { - NumberAnimation { - duration: Appearance.animation.expressiveDefaultEffects.duration - easing.type: Appearance.animation.expressiveDefaultEffects.type - easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve - } - } - layer.enabled: true - layer.effect: OpacityMask { - maskSource: Rectangle { - width: actionsFlickable.width - height: actionsFlickable.height - radius: Appearance.rounding.small - } - } StyledFlickable { id: actionsFlickable + anchors.fill: parent implicitHeight: actionRowLayout.implicitHeight contentWidth: actionRowLayout.implicitWidth @@ -282,6 +280,7 @@ Item { RowLayout { id: actionRowLayout + Layout.alignment: Qt.AlignBottom spacing: 6 @@ -305,12 +304,15 @@ Item { onClicked: { if (root.notificationObject) NotificationManager.attemptInvokeAction(root.notificationObject.notificationId, modelData.identifier); + } } + } NotificationActionButton { id: copyButton + Layout.fillWidth: true iconName: "content_copy" urgency: root.notificationUrgency @@ -322,15 +324,72 @@ Item { Timer { id: copyIconTimer + interval: 1500 repeat: false onTriggered: copyButton.iconName = "content_copy" } + } + } + } + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.expressiveDefaultEffects.duration + easing.type: Appearance.animation.expressiveDefaultEffects.type + easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve + } + + } + + layer.effect: OpacityMask { + + maskSource: Rectangle { + width: actionsFlickable.width + height: actionsFlickable.height + radius: Appearance.rounding.small + } + + } + + } + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.expressiveDefaultEffects.duration + easing.type: Appearance.animation.expressiveDefaultEffects.type + easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve + } + + } + + } + + Behavior on anchors.margins { + NumberAnimation { + duration: Appearance.animation.expressiveDefaultEffects.duration + easing.type: Appearance.animation.expressiveDefaultEffects.type + easing.bezierCurve: Appearance.animation.expressiveDefaultEffects.bezierCurve } + } + } + + Behavior on anchors.leftMargin { + enabled: !dragManager.dragging && !destroyAnimation.running + + NumberAnimation { + duration: Appearance.animation.expressiveDefaultSpatial.duration + easing.type: Appearance.animation.expressiveFastSpatial.type + easing.bezierCurve: Appearance.animation.expressiveFastSpatial.bezierCurve + } + + } + } + } diff --git a/Services/NotificationManager.qml b/Services/NotificationManager.qml index 46e26985..c98a294c 100644 --- a/Services/NotificationManager.qml +++ b/Services/NotificationManager.qml @@ -19,12 +19,16 @@ Singleton { property list actions: [] property bool popup: false property bool isTransient: false + property bool read: false property string appIcon: "" property string appName: "" + property string desktopEntry: "" property string body: "" property string image: "" property string replaceKey: "" property string summary: "" + property bool hasInlineReply: false + property string inlineReplyPlaceholder: "" property double time: Date.now() property string urgency: NotificationUrgency.Normal.toString() property Timer timer @@ -61,6 +65,7 @@ Singleton { readonly property bool popupInhibited: silent || (WidgetState.leftSidebarOpen && WidgetState.leftSidebarView === "info") readonly property bool hasNotifs: popupList.length > 0 + readonly property int historyRetentionMs: 7 * 24 * 60 * 60 * 1000 property int unread: 0 property int idOffset: 0 property list list: [] @@ -101,11 +106,13 @@ Singleton { NotificationServer { id: notifServer actionsSupported: true + actionIconsSupported: true bodyHyperlinksSupported: true bodyImagesSupported: true bodyMarkupSupported: true bodySupported: true imageSupported: true + inlineReplySupported: true keepOnReload: false persistenceSupported: true @@ -116,6 +123,21 @@ Singleton { const replaceKey = root.replaceKeyForNotification(notification); root.removeNotificationsByReplaceKey(replaceKey); root.idOffset++; + + const urgencyValue = notification.urgency !== undefined && notification.urgency !== null + ? notification.urgency + : NotificationUrgency.Normal; + const urgency = urgencyValue.toString(); + const isCritical = urgencyValue === NotificationUrgency.Critical; + const isLow = urgencyValue === NotificationUrgency.Low; + + // Inline reply: Quickshell распознаёт action "inline-reply" (нативно). + // KDE-стиль hint "x-kde-reply-placeholder-text" (Telegram) — fallback: + // показываем поле, но отправка возможна только через нативный путь. + const kdeReplyHint = notification.hints && notification.hints["x-kde-reply-placeholder-text"]; + const hasInlineReply = notification.hasInlineReply || !!kdeReplyHint; + const inlineReplyPlaceholder = notification.inlineReplyPlaceholder || kdeReplyHint || qsTr("回复"); + const newNotifObject = notifComponent.createObject(root, { "notificationId": root.idOffset, "serverNotificationId": notification.id, @@ -123,18 +145,22 @@ Singleton { "actions": root.actionsForNotification(notification), "appIcon": notification.appIcon || notification.desktopEntry || "", "appName": notification.appName || notification.desktopEntry || qsTr("系统"), + "desktopEntry": notification.desktopEntry || "", "body": notification.body || "", "image": notification.image || "", - "isTransient": notification.hints ? notification.hints.transient : false, + "isTransient": notification.hints && notification.hints.transient ? true : false, "replaceKey": replaceKey, "summary": notification.summary || notification.appName || qsTr("通知"), "time": now, - "urgency": notification.urgency ? notification.urgency.toString() : NotificationUrgency.Normal.toString(), + "urgency": urgency, + "hasInlineReply": hasInlineReply, + "inlineReplyPlaceholder": inlineReplyPlaceholder, }); - if (!root.popupInhibited) { + // 按优先级决定弹出行为 + if (!root.popupInhibited && !isLow) { newNotifObject.popup = true; - if (notification.expireTimeout !== 0) { + if (notification.expireTimeout !== 0 && !isCritical) { newNotifObject.timer = notifTimerComponent.createObject(root, { "notificationId": newNotifObject.notificationId, "interval": notification.expireTimeout < 0 ? 7000 : notification.expireTimeout, @@ -143,7 +169,18 @@ Singleton { root.unread++; } - root.list = [...root.list, newNotifObject]; + root.list = [...root.list, newNotifObject].sort((a, b) => { + const urgencies = { + [NotificationUrgency.Critical.toString()]: 0, + [NotificationUrgency.Normal.toString()]: 1, + [NotificationUrgency.Low.toString()]: 2 + }; + const aU = urgencies[a.urgency] ?? 1; + const bU = urgencies[b.urgency] ?? 1; + if (aU !== bU) return aU - bU; + return b.time - a.time; + }); + root.trimPopupList(3); root.saveNotifications(); root.notify(newNotifObject); @@ -157,30 +194,47 @@ Singleton { onLoaded: { try { const fileContents = notifFileView.text(); - const loaded = JSON.parse(fileContents && fileContents.trim() !== "" ? fileContents : "[]"); - if (!Array.isArray(loaded)) { + if (!fileContents || fileContents.trim() === "") { root.list = []; root.initDone(); return; } + const parsed = JSON.parse(fileContents); + const loadedData = parsed.version === 1 ? parsed.data : (Array.isArray(parsed) ? parsed : []); + + if (!Array.isArray(loadedData)) { + root.list = []; + root.initDone(); + return; + } + + const now = Date.now(); + const retentionLimit = now - root.historyRetentionMs; + let maxId = 0; - root.list = loaded.map((notif) => { - const notificationId = Number(notif.notificationId || notif.id || 0); - maxId = Math.max(maxId, notificationId); - return notifComponent.createObject(root, { - "notificationId": notificationId, - "actions": [], - "appIcon": notif.appIcon || "", - "appName": notif.appName || qsTr("系统"), - "body": notif.body || "", - "image": notif.image || "", - "replaceKey": notif.replaceKey || root.replaceKeyForValues(notif.appName || "System", notif.summary || notif.appName || "Notification"), - "summary": notif.summary || notif.appName || qsTr("通知"), - "time": Number(notif.time) || Date.now(), - "urgency": notif.urgency || NotificationUrgency.Normal.toString(), + root.list = loadedData + .filter(notif => (Number(notif.time) || 0) > retentionLimit) + .map((notif) => { + const notificationId = Number(notif.notificationId || notif.id || 0); + maxId = Math.max(maxId, notificationId); + return notifComponent.createObject(root, { + "notificationId": notificationId, + "actions": [], + "appIcon": notif.appIcon || "", + "appName": notif.appName || qsTr("系统"), + "desktopEntry": notif.desktopEntry || "", + "body": notif.body || "", + "image": notif.image || "", + "replaceKey": notif.replaceKey || root.replaceKeyForValues(notif.appName || "System", notif.summary || notif.appName || "Notification"), + "summary": notif.summary || notif.appName || qsTr("通知"), + "time": Number(notif.time) || Date.now(), + "urgency": notif.urgency || NotificationUrgency.Normal.toString(), + "read": !!notif.read, + "hasInlineReply": !!notif.hasInlineReply, + "inlineReplyPlaceholder": notif.inlineReplyPlaceholder || "", + }); }); - }); root.idOffset = maxId; root.initDone(); } catch (error) { @@ -209,17 +263,24 @@ Singleton { "actions": notif.actions, "appIcon": notif.appIcon, "appName": notif.appName, + "desktopEntry": notif.desktopEntry, "body": notif.body, "image": notif.image, "replaceKey": notif.replaceKey, "summary": notif.summary, "time": notif.time, "urgency": notif.urgency, + "read": notif.read, + "hasInlineReply": notif.hasInlineReply, + "inlineReplyPlaceholder": notif.inlineReplyPlaceholder, }; } function stringifyList(notifications) { - return JSON.stringify(notifications.map((notif) => root.notifToJSON(notif)), null, 2); + return JSON.stringify({ + "version": 1, + "data": notifications.map((notif) => root.notifToJSON(notif)) + }, null, 2); } function actionsForNotification(notification) { @@ -278,10 +339,14 @@ Singleton { } function trimPopupList(maxCount) { - const popups = root.list.filter((notif) => notif.popup).sort((a, b) => b.time - a.time); - for (let i = maxCount; i < popups.length; i++) - popups[i].popup = false; - if (popups.length > maxCount) + // 关键通知不受数量上限约束,仅裁剪低优先级弹出 + const critical = root.list.filter((notif) => notif.popup && notif.urgency === NotificationUrgency.Critical.toString()); + const nonCritical = root.list.filter((notif) => notif.popup && notif.urgency !== NotificationUrgency.Critical.toString()) + .sort((a, b) => b.time - a.time); + const keepCount = Math.max(0, maxCount - critical.length); + for (let i = keepCount; i < nonCritical.length; i++) + nonCritical[i].popup = false; + if (nonCritical.length > keepCount) root.triggerListChange(); } @@ -291,6 +356,26 @@ Singleton { function markAllRead() { root.unread = 0; + let changed = false; + root.list.forEach((notif) => { + if (!notif.read) { + notif.read = true; + changed = true; + } + }); + if (changed) { + root.triggerListChange(); + root.saveNotifications(); + } + } + + function markAsRead(id) { + const index = root.list.findIndex((notif) => notif.notificationId === id); + if (index === -1 || root.list[index].read) + return; + root.list[index].read = true; + root.triggerListChange(); + root.saveNotifications(); } function removeByNotifId(targetId) { @@ -365,6 +450,18 @@ Singleton { root.list[index].timer.stop(); } + // Перезапуск автоскрытия после ухода курсора с попапа. + function resumeTimeout(id) { + const index = root.list.findIndex((notif) => notif.notificationId === id); + const notif = index !== -1 ? root.list[index] : null; + if (!notif || !notif.timer) + return; + if (notif.urgency === NotificationUrgency.Critical.toString()) + return; + notif.timer.interval = notif.timer.interval; + notif.timer.start(); + } + function timeoutNotification(id) { const index = root.list.findIndex((notif) => notif.notificationId === id); if (index !== -1) { @@ -377,7 +474,10 @@ Singleton { } function timeoutAll() { + // 关键通知不会被 timeoutAll 清除,需用户显式确认 root.popupList.forEach((notif) => { + if (notif.urgency === NotificationUrgency.Critical.toString()) + return; root.timeout(notif.notificationId); if (notif.timer) notif.timer.stop(); @@ -401,4 +501,74 @@ Singleton { } root.discardNotification(id); } -} + + // Клик по телу уведомления: стандартное поведение всех DE — активировать + // приложение. Спецификация freedesktop: первое действие с идентификатором + // "default" (или просто первое действие) возвращает фокус приложению. + // Без действий — закрываем уведомление. + function invokeDefaultAction(id) { + const index = root.list.findIndex((notif) => notif.notificationId === id); + const notifObject = index !== -1 ? root.list[index] : null; + const serverNotificationId = notifObject ? notifObject.serverNotificationId : -1; + const notifServerIndex = serverNotificationId !== -1 + ? notifServer.trackedNotifications.values.findIndex((notif) => notif.id === serverNotificationId) + : -1; + if (notifServerIndex !== -1) { + const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex]; + const actions = notifServerNotif.actions; + const defaultAction = actions.find((candidate) => candidate.identifier === "default") + || (actions.length > 0 ? actions[0] : null); + if (defaultAction) { + defaultAction.invoke(); + root.discardNotification(id); + return; + } + } + // Нет действий (или уведомление из истории без живого серверного объекта) — + // активируем приложение по desktopEntry (freedesktop: клик по телу возвращает + // фокус приложению). gtk-launch запускает .desktop-файл по имени без расширения. + // Quickshell заполняет desktopEntry только из явного DBus-hint "desktop-entry", + // который большинство приложений не передаёт — поэтому fallback на appName + // (он почти всегда совпадает с desktop-id, напр. "org.telegram.desktop") и на + // heuristicLookup по нему. + const rawEntry = String((notifObject && notifObject.desktopEntry) || ""); + const rawAppName = String((notifObject && notifObject.appName) || ""); + let entryName = ""; + if (rawEntry.length > 0) { + entryName = rawEntry.endsWith(".desktop") + ? rawEntry.slice(0, -".desktop".length) + : rawEntry; + } else if (rawAppName.length > 0) { + const lookup = DesktopEntries.heuristicLookup(rawAppName); + if (lookup && lookup.id) + entryName = String(lookup.id).replace(/\.desktop$/, ""); + else + entryName = rawAppName.replace(/\.desktop$/, ""); + } + if (entryName.length > 0) { + Quickshell.execDetached(["gtk-launch", entryName]); + root.discardNotification(id); + return; + } + root.discardNotification(id); + } + + // Inline reply (быстрый ответ из попапа, напр. Telegram). + // Возвращает false, если у уведомления нет inline reply. + function sendInlineReply(id, replyText) { + const index = root.list.findIndex((notif) => notif.notificationId === id); + const notifObject = index !== -1 ? root.list[index] : null; + const serverNotificationId = notifObject ? notifObject.serverNotificationId : -1; + const notifServerIndex = serverNotificationId !== -1 + ? notifServer.trackedNotifications.values.findIndex((notif) => notif.id === serverNotificationId) + : -1; + if (notifServerIndex === -1) + return false; + const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex]; + if (!notifServerNotif.hasInlineReply) + return false; + notifServerNotif.sendInlineReply(replyText); + root.discardNotification(id); + return true; + } +} \ No newline at end of file diff --git a/Services/UiPreferences.qml b/Services/UiPreferences.qml index f84d0d75..68754da3 100644 --- a/Services/UiPreferences.qml +++ b/Services/UiPreferences.qml @@ -11,6 +11,11 @@ Singleton { readonly property string filePath: configDir + "/ui-preferences.json" property bool dndEnabled: false property bool darkMode: false + // Приватность уведомлений на экране блокировки (как Android): + // "show" — показывать содержимое (по умолчанию) + // "hide-content" — видно приложение, текст скрыт + // "hide-all" — уведомления на lock screen скрыты полностью + property string lockScreenNotificationPrivacy: "show" property string language: normalizedLanguage(Qt.locale().name) property string weatherTemperatureUnit: "celsius" property string systemTemperatureUnit: "celsius" @@ -58,6 +63,19 @@ Singleton { root.setDndEnabled(!root.dndEnabled); } + function normalizedNotificationPrivacy(value) { + return root.allowedValue(value, ["show", "hide-content", "hide-all"], "show"); + } + + function setLockScreenNotificationPrivacy(value) { + const normalized = root.normalizedNotificationPrivacy(value); + if (root.lockScreenNotificationPrivacy === normalized) + return ; + + root.lockScreenNotificationPrivacy = normalized; + root.save(); + } + function setLanguage(value) { const normalized = root.normalizedLanguage(value); if (root.language === normalized) @@ -291,6 +309,7 @@ Singleton { prefsFile.setText(JSON.stringify({ "dndEnabled": root.dndEnabled, "language": root.language, + "lockScreenNotificationPrivacy": root.lockScreenNotificationPrivacy, "weatherTemperatureUnit": root.weatherTemperatureUnit, "systemTemperatureUnit": root.systemTemperatureUnit, "systemMonitorGpuId": root.systemMonitorGpuId, @@ -341,6 +360,7 @@ Singleton { root.dndEnabled = parsed.dndEnabled; root.language = root.normalizedLanguage(parsed.language || Qt.locale().name); + root.lockScreenNotificationPrivacy = root.normalizedNotificationPrivacy(parsed.lockScreenNotificationPrivacy); root.weatherTemperatureUnit = root.normalizedTemperatureUnit(parsed.weatherTemperatureUnit); root.systemTemperatureUnit = root.normalizedTemperatureUnit(parsed.systemTemperatureUnit); root.systemMonitorGpuId = root.normalizedSystemMonitorGpuId(parsed.systemMonitorGpuId); diff --git a/i18n/clavis_en_US.ts b/i18n/clavis_en_US.ts index cb66f2ba..9cd4b885 100644 --- a/i18n/clavis_en_US.ts +++ b/i18n/clavis_en_US.ts @@ -2900,6 +2900,41 @@ Scroll to adjust 字体、数字位置和颜色 Font, digit positions, and colors + + + 通知隐私 + Notification privacy + + + + 锁屏通知 + Lock screen notifications + + + + 锁屏时通知内容的显示方式 + How notification content is shown on the lock screen + + + + 显示内容 + Show content + + + + 隐藏内容 + Hide content + + + + 全部隐藏 + Hide all + + + + 选择显示方式 + Choose display mode + LanguageAndRegionPage @@ -3752,6 +3787,11 @@ Click to open network settings 没有通知 No notifications + + + 通知内容已隐藏 + Notification content hidden + NotificationItem diff --git a/i18n/clavis_zh_CN.ts b/i18n/clavis_zh_CN.ts index 90067308..9cef5598 100644 --- a/i18n/clavis_zh_CN.ts +++ b/i18n/clavis_zh_CN.ts @@ -2900,6 +2900,41 @@ 字体、数字位置和颜色 字体、数字位置和颜色 + + + 通知隐私 + 通知隐私 + + + + 锁屏通知 + 锁屏通知 + + + + 锁屏时通知内容的显示方式 + 锁屏时通知内容的显示方式 + + + + 显示内容 + 显示内容 + + + + 隐藏内容 + 隐藏内容 + + + + 全部隐藏 + 全部隐藏 + + + + 选择显示方式 + 选择显示方式 + LanguageAndRegionPage @@ -3752,6 +3787,11 @@ 没有通知 没有通知 + + + 通知内容已隐藏 + 通知内容已隐藏 + NotificationItem diff --git a/i18n/clavis_zh_TW.ts b/i18n/clavis_zh_TW.ts index cb308457..b2a31746 100644 --- a/i18n/clavis_zh_TW.ts +++ b/i18n/clavis_zh_TW.ts @@ -2900,6 +2900,41 @@ 字体、数字位置和颜色 字型、數字位置和顏色 + + + 通知隐私 + 通知隱私 + + + + 锁屏通知 + 鎖定螢幕通知 + + + + 锁屏时通知内容的显示方式 + 鎖定螢幕上通知內容的顯示方式 + + + + 显示内容 + 顯示內容 + + + + 隐藏内容 + 隱藏內容 + + + + 全部隐藏 + 全部隱藏 + + + + 选择显示方式 + 選擇顯示方式 + LanguageAndRegionPage @@ -3752,6 +3787,11 @@ 没有通知 沒有通知 + + + 通知内容已隐藏 + 通知內容已隱藏 + NotificationItem