414 lines
16 KiB
QML
414 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" }
|
|
|
|
// A SettingRow with its own ConfirmAction rather than an ActionRow:
|
|
// clearing history is not undoable and it takes the inline replies and
|
|
// actions with it, so it takes two presses and the armed detail names
|
|
// both the count and what stops being answerable.
|
|
SettingRow {
|
|
label: "Notification history"
|
|
detail: clearConfirm.armed
|
|
? (Notifs.history.length === 1
|
|
? "Drops the 1 kept notification — its inline reply and actions go with it"
|
|
: `Drops all ${Notifs.history.length} kept notifications — their inline replies and actions go with them`)
|
|
: (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`)
|
|
controlWidth: clearConfirm.armed ? 200 : 110
|
|
divider: false
|
|
|
|
ConfirmAction {
|
|
id: clearConfirm
|
|
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
actionId: "notification-history-clear"
|
|
armText: "Clear…"
|
|
confirmText: "Clear them"
|
|
enabled: Notifs.history.length > 0
|
|
onConfirmed: Notifs.dismissAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Applications"
|
|
subtitle: "Applications appear after their first notification. Recent senders and applications you have customized stay up top; the rest wait in All applications."
|
|
|
|
TextRow {
|
|
visible: Notifs.applications.length === 0
|
|
label: "No applications remembered yet"
|
|
detail: "Application controls will appear after the first notification arrives"
|
|
divider: false
|
|
}
|
|
|
|
SectionLabel {
|
|
visible: root.recentApps.length > 0
|
|
text: "Recent"
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
SectionLabel {
|
|
visible: root.customizedApps.length > 0
|
|
text: "Customized"
|
|
}
|
|
|
|
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 applications"
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|