notification.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Gio from 'gi://Gio';
  5. import GLib from 'gi://GLib';
  6. import GObject from 'gi://GObject';
  7. import St from 'gi://St';
  8. import * as Main from 'resource:///org/gnome/shell/ui/main.js';
  9. import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
  10. import * as Calendar from 'resource:///org/gnome/shell/ui/calendar.js';
  11. import * as NotificationDaemon from 'resource:///org/gnome/shell/ui/notificationDaemon.js';
  12. import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';
  13. import {getIcon} from './utils.js';
  14. const APP_ID = 'org.gnome.Shell.Extensions.GSConnect';
  15. const APP_PATH = '/org/gnome/Shell/Extensions/GSConnect';
  16. // deviceId Pattern (<device-id>|<remote-id>)
  17. const DEVICE_REGEX = new RegExp(/^([^|]+)\|([\s\S]+)$/);
  18. // requestReplyId Pattern (<device-id>|<remote-id>)|<reply-id>)
  19. 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');
  20. /**
  21. * Extracted from notificationDaemon.js, as it's no longer exported
  22. * https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/main/js/ui/notificationDaemon.js#L556
  23. * @returns {{ 'desktop-startup-id': string }} Object with ID containing current time
  24. */
  25. function getPlatformData() {
  26. const startupId = GLib.Variant.new('s', `_TIME${global.get_current_time()}`);
  27. return {'desktop-startup-id': startupId};
  28. }
  29. // This is no longer directly exported, so we do this instead for now
  30. const GtkNotificationDaemon = Main.notificationDaemon._gtkNotificationDaemon.constructor;
  31. /**
  32. * A slightly modified Notification Banner with an entry field
  33. */
  34. const NotificationBanner = GObject.registerClass({
  35. GTypeName: 'GSConnectNotificationBanner',
  36. }, class NotificationBanner extends Calendar.NotificationMessage {
  37. constructor(notification) {
  38. super(notification);
  39. if (notification.requestReplyId !== undefined)
  40. this._addReplyAction();
  41. }
  42. _addReplyAction() {
  43. if (!this._buttonBox) {
  44. this._buttonBox = new St.BoxLayout({
  45. style_class: 'notification-buttons-bin',
  46. x_expand: true,
  47. });
  48. this.setActionArea(this._buttonBox);
  49. global.focus_manager.add_group(this._buttonBox);
  50. }
  51. // Reply Button
  52. const button = new St.Button({
  53. style_class: 'notification-button',
  54. label: _('Reply'),
  55. x_expand: true,
  56. can_focus: true,
  57. });
  58. button.connect(
  59. 'clicked',
  60. this._onEntryRequested.bind(this)
  61. );
  62. this._buttonBox.add_child(button);
  63. // Reply Entry
  64. this._replyEntry = new St.Entry({
  65. can_focus: true,
  66. hint_text: _('Type a message'),
  67. style_class: 'chat-response',
  68. x_expand: true,
  69. visible: false,
  70. });
  71. this._buttonBox.add_child(this._replyEntry);
  72. }
  73. _onEntryRequested(button) {
  74. this.focused = true;
  75. for (const child of this._buttonBox.get_children())
  76. child.visible = (child === this._replyEntry);
  77. // Release the notification focus with the entry focus
  78. this._replyEntry.connect(
  79. 'key-focus-out',
  80. this._onEntryDismissed.bind(this)
  81. );
  82. this._replyEntry.clutter_text.connect(
  83. 'activate',
  84. this._onEntryActivated.bind(this)
  85. );
  86. this._replyEntry.grab_key_focus();
  87. }
  88. _onEntryDismissed(entry) {
  89. this.focused = false;
  90. this.emit('unfocused');
  91. }
  92. _onEntryActivated(clutter_text) {
  93. // Refuse to send empty replies
  94. if (clutter_text.text === '')
  95. return;
  96. // Copy the text, then clear the entry
  97. const text = clutter_text.text;
  98. clutter_text.text = '';
  99. const {deviceId, requestReplyId} = this.notification;
  100. const target = new GLib.Variant('(ssbv)', [
  101. deviceId,
  102. 'replyNotification',
  103. true,
  104. new GLib.Variant('(ssa{ss})', [requestReplyId, text, {}]),
  105. ]);
  106. const platformData = getPlatformData();
  107. Gio.DBus.session.call(
  108. APP_ID,
  109. APP_PATH,
  110. 'org.freedesktop.Application',
  111. 'ActivateAction',
  112. GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
  113. null,
  114. Gio.DBusCallFlags.NO_AUTO_START,
  115. -1,
  116. null,
  117. (connection, res) => {
  118. try {
  119. connection.call_finish(res);
  120. } catch (e) {
  121. // Silence errors
  122. }
  123. }
  124. );
  125. this.close();
  126. }
  127. });
  128. /**
  129. * A custom notification source for spawning notifications and closing device
  130. * notifications. This source isn't actually used, but it's methods are patched
  131. * into existing sources.
  132. */
  133. const Source = GObject.registerClass({
  134. GTypeName: 'GSConnectNotificationSource',
  135. }, class Source extends NotificationDaemon.GtkNotificationDaemonAppSource {
  136. _closeGSConnectNotification(notification, reason) {
  137. if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED)
  138. return;
  139. // Avoid sending the request multiple times
  140. if (notification._remoteClosed || notification.remoteId === undefined)
  141. return;
  142. notification._remoteClosed = true;
  143. const target = new GLib.Variant('(ssbv)', [
  144. notification.deviceId,
  145. 'closeNotification',
  146. true,
  147. new GLib.Variant('s', notification.remoteId),
  148. ]);
  149. const platformData = getPlatformData();
  150. Gio.DBus.session.call(
  151. APP_ID,
  152. APP_PATH,
  153. 'org.freedesktop.Application',
  154. 'ActivateAction',
  155. GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
  156. null,
  157. Gio.DBusCallFlags.NO_AUTO_START,
  158. -1,
  159. null,
  160. (connection, res) => {
  161. try {
  162. connection.call_finish(res);
  163. } catch (e) {
  164. // If we fail, reset in case we can try again
  165. notification._remoteClosed = false;
  166. }
  167. }
  168. );
  169. }
  170. /*
  171. * Parse the id to determine if it's a repliable notification, device
  172. * notification or a regular local notification
  173. */
  174. _parseNotificationId(notificationId) {
  175. let idMatch, deviceId, requestReplyId, remoteId, localId;
  176. if ((idMatch = REPLY_REGEX.exec(notificationId))) {
  177. [, deviceId, remoteId, requestReplyId] = idMatch;
  178. localId = `${deviceId}|${remoteId}`;
  179. } else if ((idMatch = DEVICE_REGEX.exec(notificationId))) {
  180. [, deviceId, remoteId] = idMatch;
  181. localId = `${deviceId}|${remoteId}`;
  182. } else {
  183. localId = notificationId;
  184. }
  185. return [idMatch, deviceId, requestReplyId, remoteId, localId];
  186. }
  187. /*
  188. * Add notification to source or update existing notification with extra
  189. * GsConnect information
  190. */
  191. _createNotification(notification) {
  192. const [idMatch, deviceId, requestReplyId, remoteId, localId] = this._parseNotificationId(notification.id);
  193. const cachedNotification = this._notifications[localId];
  194. // Check if this is a repeat
  195. if (cachedNotification) {
  196. cachedNotification.requestReplyId = requestReplyId;
  197. // Bail early If @notificationParams represents an exact repeat
  198. const title = notification.title;
  199. const body = notification.body
  200. ? notification.body
  201. : null;
  202. if (cachedNotification.title === title &&
  203. cachedNotification.body === body)
  204. return cachedNotification;
  205. cachedNotification.title = title;
  206. cachedNotification.body = body;
  207. return cachedNotification;
  208. }
  209. // Device Notification
  210. if (idMatch) {
  211. notification.deviceId = deviceId;
  212. notification.remoteId = remoteId;
  213. notification.requestReplyId = requestReplyId;
  214. notification.connect('destroy', (notification, reason) => {
  215. this._closeGSConnectNotification(notification, reason);
  216. delete this._notifications[localId];
  217. });
  218. // Service Notification
  219. } else {
  220. notification.connect('destroy', (notification, reason) => {
  221. delete this._notifications[localId];
  222. });
  223. }
  224. this._notifications[localId] = notification;
  225. return notification;
  226. }
  227. /*
  228. * Override to control notification spawning
  229. */
  230. addNotification(notification) {
  231. this._notificationPending = true;
  232. // Fix themed icons
  233. if (notification.icon) {
  234. let gicon = notification.icon;
  235. if (gicon instanceof Gio.ThemedIcon) {
  236. gicon = getIcon(gicon.names[0]);
  237. notification.icon = gicon.serialize();
  238. }
  239. }
  240. const createdNotification = this._createNotification(notification);
  241. this._addNotificationToMessageTray(createdNotification);
  242. this._notificationPending = false;
  243. }
  244. /*
  245. * Reimplementation of MessageTray.addNotification to raise the usual
  246. * notification limit (3)
  247. */
  248. _addNotificationToMessageTray(notification) {
  249. if (this.notifications.includes(notification)) {
  250. notification.acknowledged = false;
  251. return;
  252. }
  253. while (this.notifications.length >= 10) {
  254. const [oldest] = this.notifications;
  255. oldest.destroy(MessageTray.NotificationDestroyedReason.EXPIRED);
  256. }
  257. notification.connect('destroy', this._onNotificationDestroy.bind(this));
  258. notification.connect('notify::acknowledged', () => {
  259. this.countUpdated();
  260. // If acknowledged was set to false try to show the notification again
  261. if (!notification.acknowledged)
  262. this.emit('notification-request-banner', notification);
  263. });
  264. this.notifications.push(notification);
  265. this.emit('notification-added', notification);
  266. this.emit('notification-request-banner', notification);
  267. }
  268. createBanner(notification) {
  269. return new NotificationBanner(notification);
  270. }
  271. });
  272. /**
  273. * If there is an active GtkNotificationDaemonAppSource for GSConnect when the
  274. * extension is loaded, it has to be patched in place.
  275. */
  276. export function patchGSConnectNotificationSource() {
  277. const source = Main.notificationDaemon._gtkNotificationDaemon._sources[APP_ID];
  278. if (source !== undefined) {
  279. // Patch in the subclassed methods
  280. source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
  281. source._parseNotificationId = Source.prototype._parseNotificationId;
  282. source._createNotification = Source.prototype._createNotification;
  283. source.addNotification = Source.prototype.addNotification;
  284. source._addNotificationToMessageTray = Source.prototype._addNotificationToMessageTray;
  285. source.createBanner = Source.prototype.createBanner;
  286. // Connect to existing notifications
  287. for (const notification of Object.values(source._notifications)) {
  288. const _id = notification.connect('destroy', (notification, reason) => {
  289. source._closeGSConnectNotification(notification, reason);
  290. notification.disconnect(_id);
  291. });
  292. }
  293. }
  294. }
  295. /**
  296. * Wrap GtkNotificationDaemon._ensureAppSource() to patch GSConnect's app source
  297. * https://gitlab.gnome.org/GNOME/gnome-shell/blob/master/js/ui/notificationDaemon.js#L742-755
  298. */
  299. const __ensureAppSource = GtkNotificationDaemon.prototype._ensureAppSource;
  300. // eslint-disable-next-line func-style
  301. const _ensureAppSource = function (appId) {
  302. const source = __ensureAppSource.call(this, appId);
  303. if (source._appId === APP_ID) {
  304. source._closeGSConnectNotification = Source.prototype._closeGSConnectNotification;
  305. source._parseNotificationId = Source.prototype._parseNotificationId;
  306. source._createNotification = Source.prototype._createNotification;
  307. source.addNotification = Source.prototype.addNotification;
  308. source._addNotificationToMessageTray = Source.prototype._addNotificationToMessageTray;
  309. source.createBanner = Source.prototype.createBanner;
  310. }
  311. return source;
  312. };
  313. export function patchGtkNotificationDaemon() {
  314. GtkNotificationDaemon.prototype._ensureAppSource = _ensureAppSource;
  315. }
  316. export function unpatchGtkNotificationDaemon() {
  317. GtkNotificationDaemon.prototype._ensureAppSource = __ensureAppSource;
  318. }
  319. /**
  320. * We patch other Gtk notification sources so we can notify remote devices when
  321. * notifications have been closed locally.
  322. */
  323. const _addNotification = NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification;
  324. export function patchGtkNotificationSources() {
  325. // eslint-disable-next-line func-style
  326. const _withdrawGSConnectNotification = function (id, notification, reason) {
  327. if (reason !== MessageTray.NotificationDestroyedReason.DISMISSED)
  328. return;
  329. // Avoid sending the request multiple times
  330. if (notification._remoteWithdrawn)
  331. return;
  332. notification._remoteWithdrawn = true;
  333. // Recreate the notification id as it would've been sent
  334. const target = new GLib.Variant('(ssbv)', [
  335. '*',
  336. 'withdrawNotification',
  337. true,
  338. new GLib.Variant('s', `gtk|${this._appId}|${id}`),
  339. ]);
  340. const platformData = getPlatformData();
  341. Gio.DBus.session.call(
  342. APP_ID,
  343. APP_PATH,
  344. 'org.freedesktop.Application',
  345. 'ActivateAction',
  346. GLib.Variant.new('(sava{sv})', ['device', [target], platformData]),
  347. null,
  348. Gio.DBusCallFlags.NO_AUTO_START,
  349. -1,
  350. null,
  351. (connection, res) => {
  352. try {
  353. connection.call_finish(res);
  354. } catch (e) {
  355. // If we fail, reset in case we can try again
  356. notification._remoteWithdrawn = false;
  357. }
  358. }
  359. );
  360. };
  361. NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification = _withdrawGSConnectNotification;
  362. }
  363. export function unpatchGtkNotificationSources() {
  364. NotificationDaemon.GtkNotificationDaemonAppSource.prototype.addNotification = _addNotification;
  365. delete NotificationDaemon.GtkNotificationDaemonAppSource.prototype._withdrawGSConnectNotification;
  366. }