// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import St from 'gi://St'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js'; import * as Calendar from 'resource:///org/gnome/shell/ui/calendar.js'; import * as NotificationDaemon from 'resource:///org/gnome/shell/ui/notificationDaemon.js'; import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; import {getIcon} from './utils.js'; const APP_ID = 'org.gnome.Shell.Extensions.GSConnect'; const APP_PATH = '/org/gnome/Shell/Extensions/GSConnect'; // deviceId Pattern (|) const DEVICE_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)$/); // requestReplyId Pattern (|)|) const REPLY_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)\|([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/, 'i'); /** * Extracted from notificationDaemon.js, as it's no longer exported * https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/notificationDaemon.js#L556 * @returns {{ 'desktop-startup-id': string }} Object with ID containing current time */ function getPlatformData() { const startupId = GLib.Variant.new('s', `_TIME${global.get_current_time()}`); return {'desktop-startup-id': startupId}; } // This is no longer directly exported, so we do this instead for now const GtkNotificationDaemon = Main.notificationDaemon._gtkNotificationDaemon.constructor; /** * A slightly modified Notification Banner with an entry field */ const NotificationBanner = GObject.registerClass({ GTypeName: 'GSConnectNotificationBanner', }, class NotificationBanner extends Calendar.NotificationMessage { constructor(notification) { super(notification); if (notification.requestReplyId !== undefined) this._addReplyAction(); } _addReplyAction() { if (!this._buttonBox) { this._buttonBox = new St.BoxLayout({ style_class: 'notification-buttons-bin', x_expand: true, }); this.setActionArea(this._buttonBox); global.focus_manager.add_group(this._buttonBox); } // Reply Button const button = new St.Button({ style_class: 'notification-button', label: _('Reply'), x_expand: true, can_focus: true, }); button.connect( 'clicked', this._onEntryRequested.bind(this) ); this._buttonBox.add_child(button); // Reply Entry this._replyEntry = new St.Entry({ can_focus: true, hint_text: _('Type a message'), style_class: 'chat-response', x_expand: true, visible: false, }); this._buttonBox.add_child(this._replyEntry); } _onEntryRequested(button) { this.focused = true; for (const child of this._buttonBox.get_children()) child.visible = (child === this._replyEntry); // Release the notification focus with the entry focus this._replyEntry.connect( 'key-focus-out', this._onEntryDismissed.bind(this) ); this._replyEntry.clutter_text.connect( 'activate', this._onEntryActivated.bind(this) ); this._replyEntry.grab_key_focus(); } _onEntryDismissed(entry) { this.focused = false; this.emit('unfocused'); } _onEntryActivated(clutter_text) { // Refuse to send empty replies if (clutter_text.text === '') return; // Copy the text, then clear the entry const text = clutter_text.text; clutter_text.text = ''; const {deviceId, requestReplyId} = this.notification; const target = new GLib.Variant('(ssbv)', [ deviceId, 'replyNotification', true, new GLib.Variant('(ssa{ss})', [requestReplyId, text, {}]), ]); const platformData = getPlatformData(); Gio.DBus.session.call( APP_ID, APP_PATH, 'org.freedesktop.Application', 'ActivateAction', GLib.Variant.new('(sava{sv})', ['device', [target], platformData]), null, Gio.DBusCallFlags.NO_AUTO_START, -1, null, (connection, res) => { try { connection.call_finish(res); } catch (e) { // Silence errors } } ); this.close(); } }); /** * A custom notification source for spawning notifications and closing device * notifications. This source isn't actually used, but it's methods are patched * into existing sources. */ const Source = GObject.registerClass({ GTypeName: 'GSConnectNotificationSource', }, class Source extends NotificationDaemon.GtkNotificationDaemonAppSource { _closeGSConnectNotification(notification, reason) { if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED) return; // Avoid sending the request multiple times if (notification._remoteClosed || notification.remoteId === undefined) return; notification._remoteClosed = true; const target = new GLib.Variant('(ssbv)', [ notification.deviceId, 'closeNotification', true, new GLib.Variant('s', notification.remoteId), ]); const platformData = getPlatformData(); Gio.DBus.session.call( APP_ID, APP_PATH, 'org.freedesktop.Application', 'ActivateAction', GLib.Variant.new('(sava{sv})', ['device', [target], platformData]), null, Gio.DBusCallFlags.NO_AUTO_START, -1, null, (connection, res) => { try { connection.call_finish(res); } catch (e) { // If we fail, reset in case we can try again notification._remoteClosed = false; } } ); } /* * Parse the id to determine if it's a repliable notification, device * notification or a regular local notification */ _parseNotificationId(notificationId) { let idMatch, deviceId, requestReplyId, remoteId, localId; if ((idMatch = REPLY_REGEX.exec(notificationId))) { [, deviceId, remoteId, requestReplyId] = idMatch; localId = `${deviceId}|${remoteId}`; } else if ((idMatch = DEVICE_REGEX.exec(notificationId))) { [, deviceId, remoteId] = idMatch; localId = `${deviceId}|${remoteId}`; } else { localId = notificationId; } return [idMatch, deviceId, requestReplyId, remoteId, localId]; } /* * Add notification to source or update existing notification with extra * GsConnect information */ _createNotification(notification) { const [idMatch, deviceId, requestReplyId, remoteId, localId] = this._parseNotificationId(notification.id); const cachedNotification = this._notifications[localId]; // Check if this is a repeat if (cachedNotification) { cachedNotification.requestReplyId = requestReplyId; // Bail early If @notificationParams represents an exact repeat const title = notification.title; const body = notification.body ? notification.body : null; if (cachedNotification.title === title && cachedNotification.body === body) return cachedNotification; cachedNotification.title = title; cachedNotification.body = body; return cachedNotification; } // Device Notification if (idMatch) { notification.deviceId = deviceId; notification.remoteId = remoteId; notification.requestReplyId = requestReplyId; notification.connect('destroy', (notification, reason) => { this._closeGSConnectNotification(notification, reason); delete this._notifications[localId]; }); // Service Notification } else { notification.connect('destroy', (notification, reason) => { delete this._notifications[localId]; }); } this._notifications[localId] = notification; return notification; } /* * Override to control notification spawning */ addNotification(notification) { this._notificationPending = true; // Fix themed icons if (notification.icon) { let gicon = notification.icon; if (gicon instanceof Gio.ThemedIcon) { gicon = getIcon(gicon.names[0]); notification.icon = gicon.serialize(); } } const createdNotification = this._createNotification(notification); this._addNotificationToMessageTray(createdNotification); this._notificationPending = false; } /* * Reimplementation of MessageTray.addNotification to raise the usual * notification limit (3) */ _addNotificationToMessageTray(notification) { if (this.notifications.includes(notification)) { notification.acknowledged = false; return; } while (this.notifications.length >= 10) { const [oldest] = this.notifications; oldest.destroy(MessageTray.NotificationDestroyedReason.EXPIRED); } notification.connect('destroy', this._onNotificationDestroy.bind(this)); notification.connect('notify::acknowledged', () => { this.countUpdated(); // If acknowledged was set to false try to show the notification again if (!notification.acknowledged) this.emit('notification-request-banner', notification); }); this.notifications.push(notification); this.emit('notification-added', notification); this.emit('notification-request-banner', notification); } createBanner(notification) { return new NotificationBanner(notification); } }); /** * If there is an active GtkNotificationDaemonAppSource for GSConnect when the * extension is loaded, it has to be patched in place. */ export function patchGSConnectNotificationSource() { const source = Main.notificationDaemon._gtkNotificationDaemon._sources[APP_ID]; if (source !== undefined) { // Patch in the subclassed methods source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification; source._parseNotificationId = Source.prototype._parseNotificationId; source._createNotification = Source.prototype._createNotification; source.addNotification = Source.prototype.addNotification; source._addNotificationToMessageTray = Source.prototype._addNotificationToMessageTray; source.createBanner = Source.prototype.createBanner; // Connect to existing notifications for (const notification of Object.values(source._notifications)) { const _id = notification.connect('destroy', (notification, reason) => { source._closeGSConnectNotification(notification, reason); notification.disconnect(_id); }); } } } /** * Wrap GtkNotificationDaemon._ensureAppSource() to patch GSConnect's app source * https://gitlab.gnome.org/GNOME/gnome-shell/blob/master/js/ui/notificationDaemon.js#L742-755 */ const __ensureAppSource = GtkNotificationDaemon.prototype._ensureAppSource; // eslint-disable-next-line func-style const _ensureAppSource = function (appId) { const source = __ensureAppSource.call(this, appId); if (source._appId === APP_ID) { source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification; source._parseNotificationId = Source.prototype._parseNotificationId; source._createNotification = Source.prototype._createNotification; source.addNotification = Source.prototype.addNotification; source._addNotificationToMessageTray = Source.prototype._addNotificationToMessageTray; source.createBanner = Source.prototype.createBanner; } return source; }; export function patchGtkNotificationDaemon() { GtkNotificationDaemon.prototype._ensureAppSource = _ensureAppSource; } export function unpatchGtkNotificationDaemon() { GtkNotificationDaemon.prototype._ensureAppSource = __ensureAppSource; } /** * We patch other Gtk notification sources so we can notify remote devices when * notifications have been closed locally. */ const _addNotification = NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification; export function patchGtkNotificationSources() { // eslint-disable-next-line func-style const _withdrawGSConnectNotification = function (id, notification, reason) { if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED) return; // Avoid sending the request multiple times if (notification._remoteWithdrawn) return; notification._remoteWithdrawn = true; // Recreate the notification id as it would've been sent const target = new GLib.Variant('(ssbv)', [ '*', 'withdrawNotification', true, new GLib.Variant('s', `gtk|${this._appId}|${id}`), ]); const platformData = getPlatformData(); Gio.DBus.session.call( APP_ID, APP_PATH, 'org.freedesktop.Application', 'ActivateAction', GLib.Variant.new('(sava{sv})', ['device', [target], platformData]), null, Gio.DBusCallFlags.NO_AUTO_START, -1, null, (connection, res) => { try { connection.call_finish(res); } catch (e) { // If we fail, reset in case we can try again notification._remoteWithdrawn = false; } } ); }; NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification = _withdrawGSConnectNotification; } export function unpatchGtkNotificationSources() { NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification = _addNotification; delete NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification; }