History was a pair of in-memory lists mirrored into notifications.json, split into "pending" and "past" by a seen/unseen distinction no surface exposed, capped at 100, deduped by an id that repeats across server generations, and pruned by a 15-minute TTL. Replaying it showed five rows drawn from whichever list happened to hold them. Every toast already writes a file under ~/.local/state/omarchy/notifications so it can survive a shell restart. That file is now the history record: when the popup leaves the screen it moves into notifications/history instead of being deleted, the newest ten are kept, and showHistory replays exactly what is in there, including the toasts still on screen when it is asked for. A notification DND silenced is written straight into the same directory, since a toast that never showed is the one worth looking back at. That leaves the models, notifications.json history payload, past pruning, and the /tmp image cache that existed to keep century-old history thumbnails alive with nothing to do, so they go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
function isChromiumDerived(app, appIcon) {
|
|
var source = (String(app || "") + "\n" + String(appIcon || "")).toLowerCase()
|
|
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
|
|
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
|
|
source.indexOf("opera") >= 0
|
|
}
|
|
|
|
function sanitizeBody(body, app, appIcon) {
|
|
var text = String(body || "").replace(/<img[^>]*>/gi, "")
|
|
if (!isChromiumDerived(app, appIcon)) return text
|
|
|
|
return text
|
|
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
|
|
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
|
|
}
|
|
|
|
function summaryStartsWithGlyph(summary) {
|
|
var text = String(summary || "").replace(/^\s+/, "")
|
|
if (!text) return false
|
|
|
|
var offset = 1
|
|
var first = text.charCodeAt(0)
|
|
if (first >= 0xd800 && first <= 0xdbff && text.length > 1) offset = 2
|
|
|
|
var spaces = 0
|
|
while (offset < text.length && text.charAt(offset) === " ") {
|
|
spaces++
|
|
offset++
|
|
}
|
|
|
|
return spaces >= 2
|
|
}
|
|
|
|
function shouldBypassDnd(notification, criticalUrgency) {
|
|
var appName = String((notification && notification.appName) || "")
|
|
if (appName === "omarchy-action") return true
|
|
return appName === "notify-send" && notification && notification.urgency === criticalUrgency
|
|
}
|
|
|
|
function isEphemeralApp(appName) {
|
|
var name = String(appName || "")
|
|
return name === "notify-send" || name === "omarchy-action"
|
|
}
|
|
|
|
function stringHint(hints, name) {
|
|
try {
|
|
if (hints) {
|
|
var value = hints[name]
|
|
if (value !== undefined && value !== null) return String(value)
|
|
}
|
|
} catch (e) {
|
|
}
|
|
return ""
|
|
}
|
|
|
|
function glyphFromHints(hints) {
|
|
return stringHint(hints, "omarchy-glyph")
|
|
}
|
|
|
|
// Shell command to run when the card is clicked, sent by
|
|
// omarchy-notification-send --exec. Carrying the action as data means it
|
|
// travels with the popup through the persistence files, so a toast restored
|
|
// after a shell restart clicks through exactly like a live one. A libnotify
|
|
// action can't: its sender is still waiting on an id from a server generation
|
|
// that no longer exists.
|
|
function execFromHints(hints) {
|
|
return stringHint(hints, "omarchy-exec")
|
|
}
|
|
|
|
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
|
|
return String(glyph || "").length > 0 && String(iconSource || "").length === 0 && !!singleLineToast
|
|
}
|
|
|
|
function snapshotOf(notification, timestamp) {
|
|
var n = notification || {}
|
|
var id = n.id || 0
|
|
var expireTimeout = Number(n.expireTimeout || 0)
|
|
if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0
|
|
return {
|
|
id: id,
|
|
originalId: id,
|
|
app: n.appName || "",
|
|
appIcon: n.appIcon || "",
|
|
summary: String(n.summary || ""),
|
|
body: n.body || "",
|
|
image: n.image || "",
|
|
glyph: glyphFromHints(n.hints),
|
|
exec: execFromHints(n.hints),
|
|
urgency: n.urgency,
|
|
expireTimeout: expireTimeout,
|
|
timestamp: timestamp === undefined ? Date.now() : timestamp
|
|
}
|
|
}
|
|
|
|
function historyEntry(value, normalUrgency) {
|
|
var e = value || {}
|
|
return {
|
|
id: e.id || 0,
|
|
originalId: e.originalId || e.id || 0,
|
|
app: e.app || "",
|
|
appIcon: e.appIcon || "",
|
|
summary: e.summary || "",
|
|
body: e.body || "",
|
|
image: e.image || "",
|
|
glyph: e.glyph || "",
|
|
exec: e.exec || "",
|
|
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
|
|
expireTimeout: 0,
|
|
timestamp: e.timestamp || 0
|
|
}
|
|
}
|
|
|
|
// notifications.json holds nothing but the last-set DND preference now that
|
|
// history is a directory of files. Older versions kept `pending`/`past`
|
|
// (and, older still, `entries`) arrays in there; their presence is reported
|
|
// so the service can rewrite the file without the dead payload.
|
|
function parseSettings(raw) {
|
|
var text = String(raw || "").trim()
|
|
if (!text) return { error: false, dnd: null, legacy: false }
|
|
|
|
try {
|
|
var parsed = JSON.parse(text)
|
|
return {
|
|
error: false,
|
|
dnd: parsed && typeof parsed.dnd === "boolean" ? parsed.dnd : null,
|
|
legacy: !!(parsed && (parsed.pending || parsed.past || parsed.entries))
|
|
}
|
|
} catch (e) {
|
|
return { error: true, errorMessage: String(e), dnd: null, legacy: false }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------- popup persistence
|
|
//
|
|
// Each on-screen popup is mirrored to its own file under
|
|
// ~/.local/state/omarchy/notifications/ so toasts survive shell restarts
|
|
// (e.g. the restart `omarchy-update` performs). The file exists exactly as
|
|
// long as the popup is on screen: it is written when the toast appears and
|
|
// moved into the history/ subdirectory when the toast expires, is dismissed,
|
|
// or its action is invoked. History is those moved files, newest last-10.
|
|
|
|
function popupEntry(value, normalUrgency) {
|
|
var entry = historyEntry(value, normalUrgency)
|
|
var expire = Number((value || {}).expireTimeout || 0)
|
|
if (!isFinite(expire) || expire < 0) expire = 0
|
|
entry.expireTimeout = expire
|
|
// Absolute expiry deadline, set only when a restore resets a surviving
|
|
// popup's display lifetime. Kept out of the entry entirely when unset so
|
|
// restored rows match the roles of freshly received ones.
|
|
var deadline = Number((value || {}).deadline || 0)
|
|
if (isFinite(deadline) && deadline > 0) entry.deadline = deadline
|
|
return entry
|
|
}
|
|
|
|
function popupFileName(entry) {
|
|
var e = entry || {}
|
|
return String(e.timestamp || 0) + "-" + String(e.originalId || 0) + ".json"
|
|
}
|
|
|
|
function serializePopup(entry, normalUrgency) {
|
|
// Compact (single-line) on purpose: restore cats every file together and
|
|
// parses line by line, which only works when each file is one line.
|
|
return JSON.stringify(popupEntry(entry, normalUrgency))
|
|
}
|
|
|
|
// Parse the concatenation of every persisted popup file into entries,
|
|
// newest-first. Deliberately NO dedupe by originalId: ids restart from 1
|
|
// with every server process, so two files sharing an id are usually
|
|
// different generations — dropping the older one would silently discard a
|
|
// restored critical alert the moment a fresh notification reuses its id.
|
|
// The one case that leaves a genuine duplicate (a crash between a
|
|
// replacement's write and the replaced file's delete) merely re-shows a
|
|
// superseded toast, which expires or is dismissed and cleans itself up.
|
|
function parsePopupFiles(raw, normalUrgency) {
|
|
var lines = String(raw || "").split("\n")
|
|
var entries = []
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i].trim()
|
|
if (!line) continue
|
|
try {
|
|
var value = JSON.parse(line)
|
|
if (value && typeof value === "object") entries.push(popupEntry(value, normalUrgency))
|
|
} catch (e) {
|
|
// A torn write from a crash mid-save — skip the line, keep the rest.
|
|
}
|
|
}
|
|
entries.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
|
|
return entries
|
|
}
|
|
|
|
// A persisted popup whose lifetime already ran out would have expired on
|
|
// screen had the shell kept running, so it is not restored. duration 0 means
|
|
// the popup never expires (critical urgency) and always survives restarts.
|
|
// A restore-reset deadline outranks the original timestamp: without it, a
|
|
// second restart would judge a re-shown toast by a clock that no longer
|
|
// governs its display and drop it while it is still on screen.
|
|
function popupExpired(entry, duration, now) {
|
|
var deadline = Number((entry || {}).deadline || 0)
|
|
if (isFinite(deadline) && deadline > 0) return Number(now) >= deadline
|
|
var lifetime = Number(duration || 0)
|
|
if (!isFinite(lifetime) || lifetime <= 0) return false
|
|
return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime
|
|
}
|
|
|
|
function popupPlacement(barPosition, barClearance, gapsOut) {
|
|
var position = String(barPosition || "top")
|
|
var clearance = Number(barClearance)
|
|
var gap = Number(gapsOut)
|
|
if (!isFinite(clearance)) clearance = 0
|
|
if (!isFinite(gap)) gap = 0
|
|
|
|
return {
|
|
anchors: { top: true, bottom: false, left: false, right: true },
|
|
margins: {
|
|
top: position === "top" ? clearance : gap,
|
|
bottom: gap,
|
|
left: gap,
|
|
right: position === "right" ? clearance : gap
|
|
}
|
|
}
|
|
}
|
|
|
|
// The archived files are the history. They are read back exactly like the
|
|
// live popup files, then normalized into history rows: replaying a toast
|
|
// must not inherit the original's expire timeout or restore deadline, so it
|
|
// gets the standard on-screen lifetime for its urgency instead.
|
|
//
|
|
// liveRows are the toasts still on screen when the replay was asked for.
|
|
// They belong in it — they're the newest notifications there are — but the
|
|
// directory read races their archival, so they're carried across by hand and
|
|
// keyed by file name (timestamp + id) to drop the copy the read already saw.
|
|
function historyRows(raw, liveRows, normalUrgency, limit) {
|
|
var max = limit === undefined || limit === null ? 10 : Number(limit)
|
|
if (isNaN(max)) max = 10
|
|
max = Math.max(0, max)
|
|
|
|
var out = []
|
|
var seen = {}
|
|
function collect(rows) {
|
|
for (var i = 0; i < rows.length; i++) {
|
|
var entry = rows[i]
|
|
if (!entry) continue
|
|
var key = popupFileName(entry)
|
|
if (seen[key]) continue
|
|
seen[key] = true
|
|
out.push(historyEntry(entry, normalUrgency))
|
|
}
|
|
}
|
|
|
|
collect(Array.isArray(liveRows) ? liveRows : [])
|
|
collect(parsePopupFiles(raw, normalUrgency))
|
|
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
|
|
return out.slice(0, max)
|
|
}
|
|
|
|
if (typeof module !== "undefined") {
|
|
module.exports = {
|
|
isChromiumDerived: isChromiumDerived,
|
|
sanitizeBody: sanitizeBody,
|
|
summaryStartsWithGlyph: summaryStartsWithGlyph,
|
|
shouldBypassDnd: shouldBypassDnd,
|
|
isEphemeralApp: isEphemeralApp,
|
|
stringHint: stringHint,
|
|
glyphFromHints: glyphFromHints,
|
|
execFromHints: execFromHints,
|
|
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
|
|
snapshotOf: snapshotOf,
|
|
historyEntry: historyEntry,
|
|
parseSettings: parseSettings,
|
|
historyRows: historyRows,
|
|
popupEntry: popupEntry,
|
|
popupFileName: popupFileName,
|
|
serializePopup: serializePopup,
|
|
parsePopupFiles: parsePopupFiles,
|
|
popupExpired: popupExpired,
|
|
popupPlacement: popupPlacement
|
|
}
|
|
}
|