Files
Panama/config/dot/quickshell/modules/settings/NotificationsPage.qml
T

424 lines
16 KiB
QML

// 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
}
}
}