import Adw from "gi://Adw"; import Gio from "gi://Gio"; import GioUnix from "gi://GioUnix"; import Gtk from "gi://Gtk"; import { ExtensionPreferences } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js"; const LOG_LEVELS = [ "debug", "info", "warn", "error", ]; export default class JunkNotificationCleanerPreferences extends ExtensionPreferences { model; getPreferencesWidget() { this.model = new PreferencesModel(this.getSettings()); const page = new Adw.PreferencesPage(); page.set_title("Settings"); page.set_icon_name("preferences-system-symbolic"); page.add(this.buildGeneralGroup()); page.add(this.buildLoggingGroup()); page.add(this.buildExcludedAppsGroup()); return page; } buildGeneralGroup() { const group = new Adw.PreferencesGroup(); group.set_title("General Settings"); group.add(this.buildSwitchRow({ key: "delete-on-focus", title: "Delete on Focus", subtitle: "Delete notifications when an application window is focused.", })); group.add(this.buildSwitchRow({ key: "delete-on-close", title: "Delete on Close", subtitle: "Delete notifications when an application window is closed.", })); return group; } buildSwitchRow(opts) { const row = new Adw.ActionRow({ title: opts.title, subtitle: opts.subtitle, }); const toggle = new Gtk.Switch({ valign: Gtk.Align.CENTER }); this.model.bindDeleteTrigger(opts.key, toggle, "active"); row.add_suffix(toggle); return row; } buildLoggingGroup() { const group = new Adw.PreferencesGroup(); group.set_title("Logging"); const row = new Adw.ActionRow({ title: "Log Level", subtitle: "Set the logging level for troubleshooting notification matching.", }); const dropdown = new Gtk.DropDown({ model: Gtk.StringList.new([...LOG_LEVELS]), valign: Gtk.Align.CENTER, }); this.bindLogLevelDropdown(dropdown); row.add_suffix(dropdown); group.add(row); return group; } bindLogLevelDropdown(dropdown) { const sync = () => { dropdown.set_selected(this.model.getLogLevelIndex()); }; sync(); const changedId = this.model.onLogLevelChanged(sync); dropdown.connect("notify::selected", (dd) => { this.model.setLogLevelIndex(dd.get_selected()); }); dropdown.connect("destroy", () => { this.model.disconnect(changedId); }); } buildExcludedAppsGroup() { const group = new Adw.PreferencesGroup(); group.set_title("Excluded Applications"); group.set_description("Applications whose notifications will not be automatically deleted."); const listBox = this.buildExcludedAppsList(); const addButton = new Gtk.Button({ icon_name: "list-add-symbolic", tooltip_text: "Add application", css_classes: ["flat"], valign: Gtk.Align.CENTER, }); addButton.connect("clicked", (btn) => { const parent = btn.get_root(); this.openAppSelector(parent, (appId) => { this.model.addExcludedApp(appId); }); }); group.set_header_suffix(addButton); group.add(listBox); return group; } buildExcludedAppsList() { const listBox = new Gtk.ListBox({ selection_mode: Gtk.SelectionMode.NONE, css_classes: ["boxed-list"], }); listBox.set_placeholder(new Gtk.Label({ label: "No excluded applications.", css_classes: ["dim-label"], margin_top: 12, margin_bottom: 12, })); this.rebuildExcludedAppsList(listBox); const handlerId = this.model.onExcludedAppsChanged(() => { this.rebuildExcludedAppsList(listBox); }); listBox.connect("destroy", () => { this.model.disconnect(handlerId); }); return listBox; } rebuildExcludedAppsList(listBox) { listBox.remove_all(); for (const appId of this.model.getExcludedApps()) { listBox.append(this.buildExcludedAppRow(appId)); } } buildExcludedAppRow(appId) { const app = tryLoadDesktopApp(appId); const row = buildAppRow(app, appId); if (!app) row.set_subtitle("Uninstalled"); const removeButton = new Gtk.Button({ icon_name: "user-trash-symbolic", tooltip_text: "Remove", css_classes: ["flat"], valign: Gtk.Align.CENTER, }); removeButton.connect("clicked", () => { this.model.removeExcludedApp(appId); }); row.add_suffix(removeButton); return row; } openAppSelector(parent, onSelected) { const window = new Adw.Window({ title: "Add Excluded Application", modal: true, default_width: 420, default_height: 520, }); if (parent) window.set_transient_for(parent); const search = new Gtk.SearchEntry({ placeholder_text: "Search applications", hexpand: true, }); const headerBar = new Adw.HeaderBar(); headerBar.set_title_widget(search); const appList = buildSelectableAppList(this.getSelectableApps(), (appId) => { onSelected(appId); window.close(); }); wireSearchFilter(appList, search); addCloseOnEscape(window); const toolbarView = new Adw.ToolbarView(); toolbarView.add_top_bar(headerBar); toolbarView.set_content(new Gtk.ScrolledWindow({ child: appList })); window.set_content(toolbarView); window.present(); search.grab_focus(); } getSelectableApps() { const excluded = new Set(this.model.getExcludedApps()); return Gio.AppInfo.get_all() .filter((a) => a.should_show() && a.get_id() && !excluded.has(getAppId(a))) .sort((a, b) => a.get_name().localeCompare(b.get_name())); } } function buildSelectableAppList(apps, onActivated) { const appList = new Gtk.ListBox({ selection_mode: Gtk.SelectionMode.NONE, css_classes: ["boxed-list"], margin_top: 8, margin_bottom: 8, margin_start: 8, margin_end: 8, }); for (const app of apps) { const appId = getAppId(app); const row = buildAppRow(app, appId, { activatable: true }); appList.append(row); row.connect("activated", () => { onActivated(appId); }); } return appList; } // DesktopAppInfo.new is typed as non-null but returns null when the // .desktop file is missing (e.g. the user uninstalled the app). function tryLoadDesktopApp(appId) { return GioUnix.DesktopAppInfo.new(`${appId}.desktop`); } function buildAppRow(app, appId, extra = {}) { const title = app?.get_name() ?? appId; const row = new Adw.ActionRow({ ...extra, title, subtitle: appId }); const icon = app?.get_icon(); if (icon) { const image = Gtk.Image.new_from_gicon(icon); image.set_pixel_size(32); row.add_prefix(image); } return row; } function wireSearchFilter(appList, search) { appList.set_filter_func((row) => { const query = search.get_text().toLowerCase().trim(); if (query === "") return true; const actionRow = row; const title = actionRow.get_title().toLowerCase(); const subtitle = actionRow.get_subtitle()?.toLowerCase() ?? ""; return title.includes(query) || subtitle.includes(query); }); search.connect("search-changed", () => { appList.invalidate_filter(); }); } function addCloseOnEscape(window) { const controller = new Gtk.ShortcutController(); controller.add_shortcut(new Gtk.Shortcut({ trigger: Gtk.ShortcutTrigger.parse_string("Escape"), action: Gtk.ShortcutAction.parse_string("action(window.close)"), })); window.add_controller(controller); } function getAppId(app) { return (app.get_id() ?? "").replace(/\.desktop$/, ""); } class PreferencesModel { settings; constructor(settings) { this.settings = settings; } bindDeleteTrigger(key, target, property) { this.settings.bind(key, target, property, Gio.SettingsBindFlags.DEFAULT); } getExcludedApps() { return this.settings.get_strv("excluded-apps"); } addExcludedApp(appId) { const current = this.getExcludedApps(); if (current.includes(appId)) return; this.settings.set_strv("excluded-apps", [...current, appId]); } removeExcludedApp(appId) { this.settings.set_strv("excluded-apps", this.getExcludedApps().filter((id) => id !== appId)); } onExcludedAppsChanged(handler) { return this.settings.connect("changed::excluded-apps", handler); } getLogLevelIndex() { return this.settings.get_enum("log-level"); } setLogLevelIndex(idx) { this.settings.set_enum("log-level", idx); } onLogLevelChanged(handler) { return this.settings.connect("changed::log-level", handler); } disconnect(handlerId) { this.settings.disconnect(handlerId); } }