diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 581e03792..2b06a1411 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,6 +1,7 @@ This file is more of a quick reference so I know what to account for before next releases. # Next +- notifications: timed per-app popup mutes (1h / 4h) from the notification context menu - Go core migrated to dankgo (shared log/paths/errdefs/ipc/shellapp) - Quickshell UI embedded in the dms binary, -c / DMS_SHELL_DIR override - breaking for setups relying on ~/.config/quickshell/dms auto-discovery (XDG search removed) - release binaries + all -git packages build with withshell; sockets/pidfiles/env names unchanged, IPC APIVersion 28 wire-compatible diff --git a/quickshell/Common/NotificationRuleExpiry.js b/quickshell/Common/NotificationRuleExpiry.js new file mode 100644 index 000000000..b231137af --- /dev/null +++ b/quickshell/Common/NotificationRuleExpiry.js @@ -0,0 +1,28 @@ +// Pure helpers for notification-rule expiry (timed mute rules). Kept free of +// QML dependencies so the logic test suite can exercise them directly. +// A rule with expiresAt > 0 (ms since epoch) stops muting once the +// timestamp passes; expiresAt of 0 or absent means "never expires". +// Remaining-time text is deliberately NOT formatted here: callers use the +// shared Common/Format.js formatter with translated format strings. + +function isRuleExpired(rule, nowMs) { + const expiresAt = rule && rule.expiresAt ? rule.expiresAt : 0; + if (expiresAt <= 0) + return false; + const now = nowMs === undefined ? Date.now() : nowMs; + return now > expiresAt; +} + +// True when any rule carries an expiry timestamp (expired or not). Gates +// the expiry clock and sweeper so shells without timed rules run no +// periodic work at all; expired-but-unswept rules keep counting so the +// sweeper stays on until they are dropped. +function hasTimedRule(rules) { + const source = rules || []; + for (let i = 0; i < source.length; i++) { + const expiresAt = source[i] && source[i].expiresAt ? source[i].expiresAt : 0; + if (expiresAt > 0) + return true; + } + return false; +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index de9fb48ff..4f429a511 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -11,6 +11,7 @@ import qs.Common.settings import qs.Services import "GSettings.js" as GSettings import "LayoutResolver.js" as LayoutResolver +import "NotificationRuleExpiry.js" as RuleExpiry import "settings/SettingsSpec.js" as Spec import "settings/SettingsStore.js" as Store import "../DCommon/Common/settings/SpecUtil.js" as SpecUtil @@ -3374,6 +3375,23 @@ Singleton { return rule.bypassDnd === true; } + // Timed mute rules carry an expiresAt timestamp (ms since epoch); 0 or + // absent means the mute never expires. Only the mute lapses: a DND bypass + // or urgency on the same rule stays in force, and + // pruneExpiredNotificationRules() clears the lapsed mute from the + // persisted rule. nowMs is optional (defaults to the current time); + // reactive callers pass NotificationService.notificationRuleNowMs so + // their bindings refresh as rules expire instead of freezing on a stale + // Date.now(). + function isNotificationRuleExpired(rule, nowMs) { + return RuleExpiry.isRuleExpired(rule, nowMs); + } + + // True while any rule carries an expiry timestamp. Gates the expiry + // clock and sweeper in NotificationService so they only run when a + // timed rule can actually expire. + readonly property bool hasTimedNotificationRules: RuleExpiry.hasTimedRule(notificationRules) + function _appRuleIndex(rules, appName, desktopEntry, predicate) { const app = (appName || "").toString().toLowerCase(); const desktop = (desktopEntry || "").toString().toLowerCase(); @@ -3393,11 +3411,14 @@ Singleton { if (!pattern) return; var rules = JSON.parse(JSON.stringify(notificationRules || [])); - rules.push(_newNotificationRule(Object.assign({ + const rule = _newNotificationRule(Object.assign({ field: desktopEntry ? "desktopEntry" : "appName", pattern: pattern, matchType: "exact" - }, overrides))); + }, overrides)); + if (!rule.expiresAt) + delete rule.expiresAt; + rules.push(rule); notificationRules = rules; saveSettings(); } @@ -3406,6 +3427,10 @@ Singleton { return (rule.action || "default").toString().toLowerCase() === "default"; } + function _isNoopRule(rule) { + return _hasNoAction(rule) && (rule.urgency || "default").toString().toLowerCase() === "default" && !_isDndBypassRule(rule); + } + // Edits the first enabled matching rule and drops it once it no longer does anything. function _updateAppRule(appName, desktopEntry, predicate, changes) { var rules = JSON.parse(JSON.stringify(notificationRules || [])); @@ -3413,29 +3438,67 @@ Singleton { if (index === -1) return false; const rule = Object.assign(rules[index], changes); - if (_hasNoAction(rule) && (rule.urgency || "default").toString().toLowerCase() === "default" && !_isDndBypassRule(rule)) + if (!rule.expiresAt) + delete rule.expiresAt; + if (_isNoopRule(rule)) rules.splice(index, 1); notificationRules = rules; saveSettings(); return true; } - function addMuteRuleForApp(appName, desktopEntry) { - if (!_updateAppRule(appName, desktopEntry, _hasNoAction, { - action: "mute" - })) - _addAppRule(appName, desktopEntry, { - action: "mute" - }); + function addMuteRuleForApp(appName, desktopEntry, expiresAt) { + // Re-muting edits the app's existing no-action or mute rule (including an + // expired one not yet swept) so duplicates never accumulate. + const changes = { + action: "mute", + expiresAt: expiresAt || 0 + }; + if (!_updateAppRule(appName, desktopEntry, rule => _hasNoAction(rule) || _isMuteRule(rule), changes)) + _addAppRule(appName, desktopEntry, changes); } - function isAppMuted(appName, desktopEntry) { - return _appRuleIndex(notificationRules || [], appName, desktopEntry, rule => rule.enabled !== false && _isMuteRule(rule)) !== -1; + function isAppMuted(appName, desktopEntry, nowMs) { + return _appRuleIndex(notificationRules || [], appName, desktopEntry, rule => rule.enabled !== false && _isMuteRule(rule) && !isNotificationRuleExpired(rule, nowMs)) !== -1; + } + + // ExpiresAt of the active mute rule for an app (ms since epoch), + // or 0 when the app is not muted or the mute is permanent. + function muteExpiresAt(appName, desktopEntry, nowMs) { + const rules = notificationRules || []; + const index = _appRuleIndex(rules, appName, desktopEntry, rule => rule.enabled !== false && _isMuteRule(rule) && !isNotificationRuleExpired(rule, nowMs)); + return index === -1 ? 0 : (rules[index].expiresAt || 0); + } + + // Clears lapsed timed mutes from the persisted list so the settings UI + // stays truthful. Only the mute goes; the rule is dropped once nothing + // else is left on it. The idle sweep must stay cheap, so nothing is + // copied unless a rule actually expired. + function pruneExpiredNotificationRules() { + const rules = notificationRules || []; + if (!rules.some(rule => isNotificationRuleExpired(rule))) + return; + const kept = []; + for (const source of rules) { + if (!isNotificationRuleExpired(source)) { + kept.push(source); + continue; + } + const rule = Object.assign({}, source, { + action: "default" + }); + delete rule.expiresAt; + if (!_isNoopRule(rule)) + kept.push(rule); + } + notificationRules = kept; + saveSettings(); } function removeMuteRuleForApp(appName, desktopEntry) { _updateAppRule(appName, desktopEntry, _isMuteRule, { - action: "default" + action: "default", + expiresAt: 0 }); } diff --git a/quickshell/Modules/Notifications/NotificationActions.qml b/quickshell/Modules/Notifications/NotificationActions.qml index a1d64dba0..713497a10 100644 --- a/quickshell/Modules/Notifications/NotificationActions.qml +++ b/quickshell/Modules/Notifications/NotificationActions.qml @@ -11,40 +11,73 @@ QtObject { property string dismissText: I18n.tr("Dismiss") property var notification: null readonly property string copyText: [notification?.summary, notification?.body].map(text => HtmlElide.stripHtmlTags((text || "").replace(//gi, "\n")).trim()).filter(text => text).join("\n") - readonly property bool isMuted: SettingsData.isAppMuted(appName, desktopEntry) + // nowMs comes from NotificationService's ticking clock so these + // bindings refresh every minute and flip the moment a timed mute lapses + // (a raw Date.now() in a binding is not reactive and would freeze). + readonly property bool isMuted: SettingsData.isAppMuted(appName, desktopEntry, NotificationService.notificationRuleNowMs) + // double, not int: epoch milliseconds exceed the 32-bit QML int range + readonly property double muteExpiresAt: SettingsData.muteExpiresAt(appName, desktopEntry, NotificationService.notificationRuleNowMs) readonly property bool isDndBypassed: SettingsData.isAppDndBypassed(appName, desktopEntry) signal dismissRequested signal appMuted - readonly property var items: [ - { - icon: "tune", - label: I18n.tr("Set notification rules"), - action: "rules" - }, - { - icon: isMuted ? "notifications" : "notifications_off", - label: isMuted ? I18n.tr("Unmute popups for %1", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")) : I18n.tr("Mute popups for %1", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")), - action: "mute" - }, - { + readonly property string muteRemainingSuffix: { + if (!isMuted || muteExpiresAt <= 0) + return ""; + const remainingText = NotificationService.formatRuleRemaining(muteExpiresAt); + return remainingText ? I18n.tr(" (%1 left)", "notification menu, %1 = remaining time until a timed mute expires").arg(remainingText) : ""; + } + + readonly property var items: { + const list = [ + { + icon: "tune", + label: I18n.tr("Set notification rules"), + action: "rules" + } + ]; + if (isMuted) { + list.push({ + icon: "notifications", + label: I18n.tr("Unmute popups for %1", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")) + muteRemainingSuffix, + action: "mute" + }); + } else { + list.push({ + icon: "notifications_off", + label: I18n.tr("Mute popups for %1 (1 hour)", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")), + action: "mute_1h" + }); + list.push({ + icon: "notifications_off", + label: I18n.tr("Mute popups for %1 (4 hours)", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")), + action: "mute_4h" + }); + list.push({ + icon: "notifications_off", + label: I18n.tr("Mute popups for %1", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")), + action: "mute" + }); + } + list.push({ icon: isDndBypassed ? "do_not_disturb_on" : "do_not_disturb_off", label: isDndBypassed ? I18n.tr("Block %1 in Do Not Disturb", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")) : I18n.tr("Allow %1 in Do Not Disturb", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")), action: "dnd" - }, - ...(root.copyText ? [ - { - icon: "content_copy", - label: I18n.tr("Copy"), - action: "copy" - } - ] : []), - { + }); + if (root.copyText) { + list.push({ + icon: "content_copy", + label: I18n.tr("Copy"), + action: "copy" + }); + } + list.push({ icon: "close", label: root.dismissText, action: "dismiss" - } - ] + }); + return list; + } function defaultAction(notification) { const actions = notification?.actions || []; @@ -65,6 +98,14 @@ QtObject { SettingsData.addMuteRuleForApp(appName, desktopEntry); appMuted(); return; + case "mute_1h": + SettingsData.addMuteRuleForApp(appName, desktopEntry, Date.now() + 60 * 60 * 1000); + appMuted(); + return; + case "mute_4h": + SettingsData.addMuteRuleForApp(appName, desktopEntry, Date.now() + 4 * 60 * 60 * 1000); + appMuted(); + return; case "dnd": SettingsData.setAppDndBypass(appName, desktopEntry, !isDndBypassed); return; diff --git a/quickshell/Modules/Settings/NotificationRulesTab.qml b/quickshell/Modules/Settings/NotificationRulesTab.qml index b26879ddb..f2ec51c9d 100644 --- a/quickshell/Modules/Settings/NotificationRulesTab.qml +++ b/quickshell/Modules/Settings/NotificationRulesTab.qml @@ -20,7 +20,9 @@ Item { })).filter(entry => predicate(entry.rule)); } - readonly property var mutedRules: indexedRules(rule => (rule.action || "").toString().toLowerCase() === "mute") + // Expired timed mutes drop out of the list the minute they lapse + // (notificationRuleNowMs ticks), without waiting for the sweep. + readonly property var mutedRules: indexedRules(rule => (rule.action || "").toString().toLowerCase() === "mute" && !SettingsData.isNotificationRuleExpired(rule, NotificationService.notificationRuleNowMs)) readonly property var notificationRuleFieldOptions: [ { @@ -110,6 +112,14 @@ Item { return [getRuleOptionLabel(notificationRuleFieldOptions, rule.field, notificationRuleFieldOptions[0].label), getRuleOptionLabel(notificationRuleMatchTypeOptions, rule.matchType, notificationRuleMatchTypeOptions[0].label)].join(" ยท "); } + function remainingLabel(rule) { + const expiresAt = rule && rule.expiresAt ? rule.expiresAt : 0; + if (expiresAt <= NotificationService.notificationRuleNowMs) + return ""; + const remaining = NotificationService.formatRuleRemaining(expiresAt); + return remaining ? I18n.tr("expires in %1", "timed notification rule, %1 = remaining time until it expires").arg(remaining) : ""; + } + function outcomeBadges(rule) { const badges = []; if ((rule.action || "default") !== "default") @@ -118,6 +128,9 @@ Item { badges.push(getRuleOptionLabel(notificationRuleUrgencyOptions, rule.urgency, rule.urgency)); if (rule.bypassDnd === true) badges.push(I18n.tr("Allow in Do Not Disturb")); + const remaining = remainingLabel(rule); + if (remaining !== "") + badges.push(remaining); return badges; } @@ -263,6 +276,7 @@ Item { required property var modelData title: modelData.rule?.pattern || I18n.tr("Unknown") + subtitle: root.remainingLabel(modelData.rule) singleLineTitle: true DButton { diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index e23e6ebc1..cb9971d34 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -7,6 +7,7 @@ import Quickshell.Io import Quickshell.Services.Notifications import qs.Common import qs.Services +import "../Common/Format.js" as Format import "../Common/markdown2html.js" as Markdown2Html Singleton { @@ -504,7 +505,7 @@ Singleton { }; policy.bypassDnd = rules.some(rule => rule.bypassDnd === true && _matchesNotificationRule(rule, info)); - policy.disablePopup = rules.some(rule => (rule.action || "default").toString().toLowerCase() === "mute" && _matchesNotificationRule(rule, info)); + policy.disablePopup = rules.some(rule => (rule.action || "default").toString().toLowerCase() === "mute" && !SettingsData.isNotificationRuleExpired(rule) && _matchesNotificationRule(rule, info)); for (const rule of rules) { if (!_matchesNotificationRule(rule, info)) @@ -606,6 +607,42 @@ Singleton { } } + // Date.now() inside a QML binding is not reactive, so anything counting + // down to a rule's expiresAt needs a ticking time source. Minute + // precision matches the countdown granularity, and the clock only runs + // while timed rules exist, so shells without them pay nothing. + SystemClock { + id: ruleExpiryClock + precision: SystemClock.Minutes + enabled: SettingsData.hasTimedNotificationRules + } + + // Current time in ms for rule-expiry bindings. Reads 0 while the clock is + // off, which is safe: the clock is off exactly when no rule carries an + // expiry, so no binding can depend on the value moving. + readonly property double notificationRuleNowMs: ruleExpiryClock.enabled ? ruleExpiryClock.date.getTime() : 0 + + // Formatted remaining time for a rule's expiresAt, driven by the ticking + // clock above so bindings calling this refresh every minute. Single home + // for the translated duration strings; "" once the timestamp passes. + function formatRuleRemaining(expiresAt) { + return Format.formatRemaining(expiresAt - notificationRuleNowMs, "", I18n.tr("%1 min", "timed duration, %1 is a number of minutes"), I18n.tr("%1 h", "timed duration, %1 is a number of hours"), I18n.tr("%1 h %2 m", "timed duration, %1 is a number of hours, %2 is a number of minutes")); + } + + // Clears lapsed timed mutes (SettingsData.addMuteRuleForApp with + // expiresAt) from the persisted rules. Matching already ignores them; + // this keeps the stored list and the settings UI truthful. Runs only + // while timed rules exist, and fires on start so mutes that lapsed while + // the shell was offline are cleared at launch. + Timer { + id: expiredRuleSweeper + interval: 60000 + repeat: true + running: SettingsData.hasTimedNotificationRules + triggeredOnStart: true + onTriggered: SettingsData.pruneExpiredNotificationRules() + } + Timer { id: dismissPump interval: _dismissTickMs diff --git a/quickshell/tests/format.test.mjs b/quickshell/tests/format.test.mjs new file mode 100644 index 000000000..7e488a62a --- /dev/null +++ b/quickshell/tests/format.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { loadScript } from "./qml-script.mjs"; + +const format = loadScript(new URL("../Common/Format.js", import.meta.url)); + +// QML's JS engine provides QString-style String.prototype.arg(); the plain vm +// realm does not. Install a minimal equivalent (replace the lowest-numbered +// %n per call) so format helpers behave as they do in the shell. +vm.runInContext("String.prototype.arg = function() { let out = this.toString(); for (let i = 0; i < arguments.length; i++) out = out.replace(/%(\\d+)/, String(arguments[i])); return out; }", format); + +const HOUR_MS = 60 * 60 * 1000; + +test("formatRemaining returns zeroText at or below zero", () => { + assert.equal(format.formatRemaining(0, "Off", "%1 min", "%1 h", "%1 h %2 m"), "Off"); + assert.equal(format.formatRemaining(-5, "Off", "%1 min", "%1 h", "%1 h %2 m"), "Off"); + assert.equal(format.formatRemaining(0, "", "%1 min", "%1 h", "%1 h %2 m"), ""); +}); + +test("formatRemaining formats minutes below an hour, rounding up", () => { + assert.equal(format.formatRemaining(30 * 1000, "", "%1 min", "%1 h", "%1 h %2 m"), "1 min"); + assert.equal(format.formatRemaining(43 * 60 * 1000, "", "%1 min", "%1 h", "%1 h %2 m"), "43 min"); +}); + +test("formatRemaining formats whole hours without minutes", () => { + assert.equal(format.formatRemaining(HOUR_MS, "", "%1 min", "%1 h", "%1 h %2 m"), "1 h"); + assert.equal(format.formatRemaining(2 * HOUR_MS, "", "%1 min", "%1 h", "%1 h %2 m"), "2 h"); +}); + +test("formatRemaining rounds 59m59s up to one hour, not 60 min", () => { + assert.equal(format.formatRemaining(59 * 60 * 1000 + 59000, "", "%1 min", "%1 h", "%1 h %2 m"), "1 h"); +}); + +test("formatRemaining formats hours and minutes together", () => { + assert.equal(format.formatRemaining(2 * HOUR_MS + 5 * 60 * 1000, "", "%1 min", "%1 h", "%1 h %2 m"), "2 h 5 m"); + assert.equal(format.formatRemaining(4 * HOUR_MS + 59 * 60 * 1000 + 59000, "", "%1 min", "%1 h", "%1 h %2 m"), "5 h"); +}); diff --git a/quickshell/tests/notification-menu-rules.test.mjs b/quickshell/tests/notification-menu-rules.test.mjs index 26ae9f782..52cc87ef2 100644 --- a/quickshell/tests/notification-menu-rules.test.mjs +++ b/quickshell/tests/notification-menu-rules.test.mjs @@ -2,9 +2,11 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import vm from "node:vm"; +import { loadScript } from "./qml-script.mjs"; const source = readFileSync(new URL("../Common/SettingsData.qml", import.meta.url), "utf8"); const serviceSource = readFileSync(new URL("../Services/NotificationService.qml", import.meta.url), "utf8"); +const ruleExpiry = loadScript(new URL("../Common/NotificationRuleExpiry.js", import.meta.url)); function load(text, globals, only) { const context = vm.createContext(globals); @@ -16,14 +18,14 @@ function load(text, globals, only) { } function settings(rules) { - const context = load(source, { notificationRules: rules }); + const context = load(source, { notificationRules: rules, RuleExpiry: ruleExpiry }); context.saveSettings = () => {}; return context; } function policy(rules, notif) { const functions = ["_resolveAppNameForRule", "_ruleFieldValue", "_coerceRuleUrgency", "_matchesNotificationRule", "_evaluateNotificationPolicy"]; - const context = load(serviceSource, { SettingsData: { notificationRules: rules }, NotificationUrgency: { Low: 0, Normal: 1, Critical: 2 } }, functions); + const context = load(serviceSource, { SettingsData: { notificationRules: rules, isNotificationRuleExpired: ruleExpiry.isRuleExpired }, NotificationUrgency: { Low: 0, Normal: 1, Critical: 2 } }, functions); return plain(context._evaluateNotificationPolicy(notif)); } @@ -128,3 +130,14 @@ test("rules that are not exact app or desktop-entry rules are never read or edit assert.equal(u.isAppMuted("org.mozilla.firefox", "firefox"), false, "a desktopEntry rule is not matched against the app name"); assert.equal(u.isAppMuted("Firefox", "org.mozilla.firefox"), true); }); + +test("an expired timed mute releases the popup but keeps the rule's other settings", () => { + const notif = { appName: "Firefox", summary: "", body: "" }; + const p = policy([rule({ action: "mute", bypassDnd: true, expiresAt: 1 })], notif); + assert.equal(p.disablePopup, false); + assert.equal(p.bypassDnd, true); + + const s = settings([rule({ action: "mute", bypassDnd: true, expiresAt: 1 }), rule({ action: "mute", expiresAt: 1 })]); + s.pruneExpiredNotificationRules(); + assert.deepEqual(plain(s.notificationRules), [rule({ bypassDnd: true })]); +}); diff --git a/quickshell/tests/notification-rule-expiry.test.mjs b/quickshell/tests/notification-rule-expiry.test.mjs new file mode 100644 index 000000000..82fc61c8f --- /dev/null +++ b/quickshell/tests/notification-rule-expiry.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { loadScript } from "./qml-script.mjs"; + +const expiry = loadScript(new URL("../Common/NotificationRuleExpiry.js", import.meta.url)); + +// The script runs inside a vm context, so returned objects live in another +// realm; clone them into plain host objects before deep comparisons +// (bar-content.test.mjs pattern). +const plain = value => JSON.parse(JSON.stringify(value)); + +test("rules without expiresAt never expire", () => { + assert.equal(expiry.isRuleExpired({}, 1000), false); + assert.equal(expiry.isRuleExpired(null, 1000), false); + assert.equal(expiry.isRuleExpired({ expiresAt: 0 }, 1000), false); + assert.equal(expiry.isRuleExpired({ action: "mute" }, Number.MAX_SAFE_INTEGER), false); +}); + +test("timed rules expire strictly after their timestamp", () => { + const rule = { action: "mute", expiresAt: 5000 }; + assert.equal(expiry.isRuleExpired(rule, 4999), false); + assert.equal(expiry.isRuleExpired(rule, 5000), false); + assert.equal(expiry.isRuleExpired(rule, 5001), true); +}); + +test("hasTimedRule reports whether any rule carries an expiry", () => { + assert.equal(expiry.hasTimedRule(null), false); + assert.equal(expiry.hasTimedRule([]), false); + assert.equal(expiry.hasTimedRule([{}, { action: "mute" }, { expiresAt: 0 }]), false); + assert.equal(expiry.hasTimedRule([null, { expiresAt: 0 }, { expiresAt: 5000 }]), true); + // Expired-but-unswept rules still count: they keep the sweeper alive + // until they are dropped from the persisted list. + assert.equal(expiry.hasTimedRule([{ expiresAt: 1 }]), true); +});