notification.js 15 KB

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