// The notifications tab: what interrupts, how long it stays, and which // applications are allowed to do it. // // The application list used to be every application that had ever notified, // rendered in full, forever. On a machine that has been running for a year that // is a wall nobody reads. It is now bounded: recent senders and applications // whose rule has been changed sit up top, and everything else waits behind a // searchable expander. Rows unfold in place into the rule itself. // // Focus modes moved to their own tab. What stays here is the one line saying a // mode is currently quieting things, because that is the question this page is // opened to answer. import QtQuick import qs.config import qs.services import qs.modules.clipboard SettingsPage { id: root // One application is unfolded at a time; the point is a card you can read, // not twenty open at once. property string expandedApp: "" property bool allAppsOpen: false readonly property int weekMs: 7 * 24 * 60 * 60 * 1000 // Every rule write leaves through here, so a change made in a row goes down // exactly the same path as one made anywhere else. function editRule(appId: string, patch: var): void { Notifs.setAppRule(appId, patch); } function forgetRule(appId: string): void { if (root.expandedApp === appId) root.expandedApp = ""; Notifs.forgetApp(appId); } // A rule that differs from the defaults in any field. This is what earns an // application a place above the fold even when it has not notified lately. function customized(rule: var): bool { return rule.enabled === false || rule.sound === false || String(rule.display ?? "banners") !== "banners" || String(rule.urgency ?? "auto") !== "auto"; } // Relative rather than a timestamp: "three days ago" is the question being // asked, and a clock time from last March answers a different one. function lastSeenText(lastSeenMs: var): string { const at = Number(lastSeenMs ?? 0); if (!at) return ""; const days = Math.floor((Date.now() - at) / 86400000); if (days <= 0) return "Today"; if (days === 1) return "Yesterday"; if (days < 7) return days + " days ago"; return Qt.formatDateTime(new Date(at), "d MMM"); } // Spelled the same way the Focus tab spells it, so the two pages describe // one schedule in one voice. function daysText(days: var): string { const list = Array.isArray(days) ? days.map(Number).slice().sort((a, b) => a - b) : []; if (list.length === 0) return "no days"; if (list.length === 7) return "every day"; if (list.join(",") === "1,2,3,4,5") return "weekdays"; if (list.join(",") === "0,6") return "weekends"; const names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; return list.map(day => names[day]).join(", "); } // Quiet hours are the Sleep mode's schedule. Rather than inventing a second // place to set them, the row states what Sleep is actually doing and hands // over to the tab that owns it. function quietHoursDetail(): string { const sleep = FocusModes.modes.find(mode => String(mode.id) === "sleep"); if (!sleep) return "There is no Sleep focus mode, so nothing is scheduled"; const schedule = (sleep.triggers ?? []).find(trigger => trigger.kind === "schedule"); if (!schedule) return "The Sleep focus mode has no schedule, so quiet hours are not scheduled"; const when = String(schedule.start ?? "") + " to " + String(schedule.end ?? "") + ", " + root.daysText(schedule.days); return sleep.enabled === true ? "Scheduled through the Sleep focus mode — " + when : "The Sleep focus mode is off; it would run " + when; } readonly property var recentApps: { const now = Date.now(); return Notifs.applications .filter(app => Number(app.lastSeenMs ?? 0) > 0 && now - Number(app.lastSeenMs) < root.weekMs) .sort((a, b) => Number(b.lastSeenMs) - Number(a.lastSeenMs)); } // Sections are partitioned by id rather than by object identity: these are // plain objects rebuilt by a binding, and an application appearing in two // sections at once is the failure that would cause. readonly property var recentIds: root.recentApps.map(app => String(app.id)) readonly property var customizedApps: Notifs.applications.filter(app => root.recentIds.indexOf(String(app.id)) < 0 && root.customized(app.rule)) readonly property var customizedIds: root.customizedApps.map(app => String(app.id)) // Everything not already on screen. Notifs.applications arrives sorted by // name, so this stays alphabetical for free. readonly property var otherApps: Notifs.applications.filter(app => root.recentIds.indexOf(String(app.id)) < 0 && root.customizedIds.indexOf(String(app.id)) < 0) // With nothing typed the expander holds what is not shown above. A typed // query searches the whole universe instead -- a search box that cannot // find an application you can see is worse than a duplicated row. readonly property var expanderApps: { const needle = appSearch.text.trim().toLowerCase(); if (needle === "") return root.otherApps; return Notifs.applications.filter(app => (String(app.name) + " " + String(app.id)).toLowerCase().indexOf(needle) >= 0); } readonly property string allowSummary: { const allow = FocusModes.allowedApps; if (allow.length === 0) return "nothing may interrupt"; const names = allow.map(id => { const app = Notifs.applications.find(entry => String(entry.id) === id); return app ? String(app.name) : id; }); if (names.length <= 2) return names.join(" and ") + " may interrupt"; return names.slice(0, 2).join(", ") + " and " + (names.length - 2) + " more may interrupt"; } title: "Notifications" lede: "Control interruptions without losing useful history." // Why the machine is quiet, at the top, where the question is asked. Rectangle { id: activeBanner width: parent.width visible: FocusModes.activeMode !== null height: visible ? Math.max(58, bannerCopy.implicitHeight + 24) : 0 radius: Theme.cardRadius + 1 color: Theme.alpha(Theme.ok, 0.08) border.width: 1 border.color: Theme.alpha(Theme.ok, 0.3) Column { id: bannerCopy anchors.left: parent.left anchors.leftMargin: 15 anchors.right: bannerButton.left anchors.rightMargin: 14 anchors.verticalCenter: parent.verticalCenter spacing: 3 Text { width: parent.width text: FocusModes.activeMode?.silence === true ? FocusModes.activeName + " is quieting notifications" : FocusModes.activeName + " is on" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.DemiBold elide: Text.ElideRight } Text { width: parent.width text: { const why = "Because " + FocusModes.activeReason; return FocusModes.activeMode?.silence === true ? why + " · " + root.allowSummary : why + " · notifications are unaffected"; } color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall wrapMode: Text.WordWrap } } SettingsButton { id: bannerButton anchors.right: parent.right anchors.rightMargin: 14 anchors.verticalCenter: parent.verticalCenter text: "Open Focus" onClicked: ShellState.openSettings("focus") } } SettingsCard { title: "Quiet" SettingRow { label: "Do Not Disturb" detail: "Banners stop; everything still lands in the notification center" controlWidth: 48 SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter checked: Notifs.doNotDisturb onToggled: value => Notifs.doNotDisturb = value } } ToggleRow { setting: "criticalBreaksThrough" } ActionRow { label: "Quiet hours" detail: root.quietHoursDetail() action: "Open Focus" divider: false onTriggered: ShellState.openSettings("focus") } } SettingsCard { title: "Banners & history" SliderRow { setting: "notificationTimeoutMs" } SliderRow { setting: "notificationTimeoutCriticalMs" zeroLabel: "Never" } SliderRow { setting: "maxVisibleToasts" } SliderRow { setting: "notificationHistoryLimit" } ActionRow { label: "Notification history" detail: Notifs.history.length === 1 ? "1 kept now · the oldest are dropped past the limit" : `${Notifs.history.length} kept now · the oldest are dropped past the limit` action: "Clear" enabled: Notifs.history.length > 0 divider: false onTriggered: Notifs.dismissAll() } } SettingsCard { title: "Applications" subtitle: "Apps appear after their first notification. Recent senders and apps you have customized stay up top; the rest wait in All apps." TextRow { visible: Notifs.applications.length === 0 label: "No applications remembered yet" detail: "Application controls will appear after the first notification arrives." divider: false } Item { width: parent.width height: root.recentApps.length > 0 ? 28 : 0 visible: height > 0 Text { anchors.left: parent.left anchors.bottom: parent.bottom anchors.bottomMargin: 4 text: "Recent" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) font.weight: Font.Bold font.capitalization: Font.AllUppercase font.letterSpacing: 0.8 } } Repeater { model: root.recentApps delegate: NotificationAppRow { id: recentRow required property var modelData width: parent.width appId: String(recentRow.modelData.id) appName: String(recentRow.modelData.name) iconName: String(recentRow.modelData.icon ?? "") rule: Notifs.appRule(recentRow.modelData.id) lastSeen: root.lastSeenText(recentRow.modelData.lastSeenMs) expanded: root.expandedApp === String(recentRow.modelData.id) onActivated: root.expandedApp = recentRow.expanded ? "" : String(recentRow.modelData.id) onEdited: patch => root.editRule(String(recentRow.modelData.id), patch) onForgotten: root.forgetRule(String(recentRow.modelData.id)) } } Item { width: parent.width height: root.customizedApps.length > 0 ? 28 : 0 visible: height > 0 Text { anchors.left: parent.left anchors.bottom: parent.bottom anchors.bottomMargin: 4 text: "Customized" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) font.weight: Font.Bold font.capitalization: Font.AllUppercase font.letterSpacing: 0.8 } } Repeater { model: root.customizedApps delegate: NotificationAppRow { id: customRow required property var modelData width: parent.width appId: String(customRow.modelData.id) appName: String(customRow.modelData.name) iconName: String(customRow.modelData.icon ?? "") rule: Notifs.appRule(customRow.modelData.id) lastSeen: root.lastSeenText(customRow.modelData.lastSeenMs) expanded: root.expandedApp === String(customRow.modelData.id) onActivated: root.expandedApp = customRow.expanded ? "" : String(customRow.modelData.id) onEdited: patch => root.editRule(String(customRow.modelData.id), patch) onForgotten: root.forgetRule(String(customRow.modelData.id)) } } SettingRow { visible: root.otherApps.length > 0 label: `All apps (${root.otherApps.length})` detail: "The rest of what has notified, alphabetically — search matches a name or an id" activatable: true divider: !root.allAppsOpen controlWidth: 40 onActivated: root.allAppsOpen = !root.allAppsOpen Text { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: root.allAppsOpen ? "▴" : "▾" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } Item { width: parent.width height: root.allAppsOpen ? 42 : 0 visible: root.allAppsOpen SearchField { id: appSearch anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter placeholder: "Search apps" } } Repeater { model: root.allAppsOpen ? root.expanderApps : [] delegate: NotificationAppRow { id: expanderRow required property var modelData required property int index width: parent.width appId: String(expanderRow.modelData.id) appName: String(expanderRow.modelData.name) iconName: String(expanderRow.modelData.icon ?? "") rule: Notifs.appRule(expanderRow.modelData.id) lastSeen: root.lastSeenText(expanderRow.modelData.lastSeenMs) expanded: root.expandedApp === String(expanderRow.modelData.id) divider: expanderRow.index < root.expanderApps.length - 1 onActivated: root.expandedApp = expanderRow.expanded ? "" : String(expanderRow.modelData.id) onEdited: patch => root.editRule(String(expanderRow.modelData.id), patch) onForgotten: root.forgetRule(String(expanderRow.modelData.id)) } } TextRow { visible: root.allAppsOpen && root.expanderApps.length === 0 label: appSearch.text.trim() === "" ? "Nothing else remembered" : "No matching applications" detail: appSearch.text.trim() === "" ? "Every application that has notified is already listed above." : "Search matches an application's name or its id." divider: false } } }