utils.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. /**
  2. * @typedef {module:headless-plugins-muc-muc.MUCMessageAttributes} MUCMessageAttributes
  3. * @typedef {module:headless-plugins-muc-muc.MUCMessageData} MUCMessageData
  4. * @typedef {module:headless-plugins-chat-utils.MessageData} MessageData
  5. * @typedef {import('@converse/headless').RosterContact} RosterContact
  6. */
  7. import Favico from 'favico.js-slevomat';
  8. import { __, i18n } from 'i18n';
  9. import { _converse, api, converse, log } from '@converse/headless';
  10. const { Strophe, u } = converse.env;
  11. const { isEmptyMessage, isTestEnv } = u;
  12. const supports_html5_notification = 'Notification' in window;
  13. converse.env.Favico = Favico;
  14. let favicon;
  15. export function isMessageToHiddenChat (attrs) {
  16. return isTestEnv() || (_converse.state.chatboxes.get(attrs.from)?.isHidden() ?? false);
  17. }
  18. export function areDesktopNotificationsEnabled () {
  19. return isTestEnv() || (
  20. supports_html5_notification &&
  21. api.settings.get('show_desktop_notifications') &&
  22. Notification.permission === 'granted'
  23. );
  24. }
  25. /**
  26. * @typedef {Navigator & {clearAppBadge: Function, setAppBadge: Function} } navigator
  27. */
  28. export function clearFavicon () {
  29. favicon?.badge(0);
  30. favicon = null;
  31. /** @type navigator */(navigator).clearAppBadge?.()
  32. .catch(e => log.error("Could not clear unread count in app badge " + e));
  33. }
  34. export function updateUnreadFavicon () {
  35. if (api.settings.get('show_tab_notifications')) {
  36. favicon = favicon ?? new converse.env.Favico({ type: 'circle', animation: 'pop' });
  37. const chats = _converse.state.chatboxes.models;
  38. const num_unread = chats.reduce((acc, chat) => acc + (chat.get('num_unread') || 0), 0);
  39. favicon.badge(num_unread);
  40. /** @type navigator */(navigator).setAppBadge?.(num_unread)
  41. .catch(e => log.error("Could set unread count in app badge - " + e));
  42. }
  43. }
  44. /**
  45. * @param {Array<Object>} references - A list of objects representing XEP-0372 references
  46. * @param {string} muc_jid
  47. * @param {string} nick
  48. */
  49. function isReferenced (references, muc_jid, nick) {
  50. const bare_jid = _converse.session.get('bare_jid');
  51. const check = (r) => [bare_jid, `${muc_jid}/${nick}`].includes(r.uri.replace(/^xmpp:/, ''));
  52. return references.reduce((acc, r) => (acc || (r.uri && check(r))), false);
  53. }
  54. /**
  55. * Is this a group message for which we should notify the user?
  56. * @param {MUCMessageAttributes} attrs
  57. */
  58. export async function shouldNotifyOfGroupMessage (attrs) {
  59. if (!attrs?.body && !attrs?.message) {
  60. // attrs.message is used by 'info' messages
  61. return false;
  62. }
  63. const jid = attrs.from;
  64. const muc_jid = attrs.from_muc;
  65. const notify_all = api.settings.get('notify_all_room_messages');
  66. const room = _converse.state.chatboxes.get(muc_jid);
  67. const resource = Strophe.getResourceFromJid(jid);
  68. const sender = (resource && Strophe.unescapeNode(resource)) || '';
  69. let is_mentioned = false;
  70. const nick = room.get('nick');
  71. if (api.settings.get('notify_nicknames_without_references')) {
  72. is_mentioned = new RegExp(`\\b${nick}\\b`).test(attrs.body);
  73. }
  74. const is_not_mine = sender !== nick;
  75. const should_notify_user =
  76. notify_all === true ||
  77. (Array.isArray(notify_all) && notify_all.includes(muc_jid)) ||
  78. isReferenced(attrs.references, muc_jid, nick) ||
  79. is_mentioned;
  80. if (is_not_mine && !!should_notify_user) {
  81. /**
  82. * *Hook* which allows plugins to run further logic to determine
  83. * whether a notification should be sent out for this message.
  84. * @event _converse#shouldNotifyOfGroupMessage
  85. * @example
  86. * api.listen.on('shouldNotifyOfGroupMessage', (should_notify) => {
  87. * return should_notify && flurb === floob;
  88. * });
  89. */
  90. const should_notify = await api.hook('shouldNotifyOfGroupMessage', attrs, true);
  91. return should_notify;
  92. }
  93. return false;
  94. }
  95. async function shouldNotifyOfInfoMessage (attrs) {
  96. if (!attrs.from_muc) {
  97. return false;
  98. }
  99. const room = await api.rooms.get(attrs.from_muc);
  100. if (!room) {
  101. return false;
  102. }
  103. const nick = room.get('nick');
  104. const muc_jid = attrs.from_muc;
  105. const notify_all = api.settings.get('notify_all_room_messages');
  106. return (
  107. notify_all === true ||
  108. (Array.isArray(notify_all) && notify_all.includes(muc_jid)) ||
  109. isReferenced(attrs.references, muc_jid, nick)
  110. );
  111. }
  112. /**
  113. * @async
  114. * @method shouldNotifyOfMessage
  115. * @param {MessageData|MUCMessageData} data
  116. */
  117. function shouldNotifyOfMessage (data) {
  118. const { attrs } = data;
  119. if (!attrs || attrs.is_forwarded) {
  120. return false;
  121. }
  122. if (attrs['type'] === 'groupchat') {
  123. return shouldNotifyOfGroupMessage(attrs);
  124. } else if (attrs['type'] === 'info') {
  125. return shouldNotifyOfInfoMessage(attrs);
  126. } else if (attrs.is_headline) {
  127. // We want to show notifications for headline messages.
  128. return isMessageToHiddenChat(attrs);
  129. }
  130. const bare_jid = _converse.session.get('bare_jid');
  131. const is_me = Strophe.getBareJidFromJid(attrs.from) === bare_jid;
  132. return (
  133. !isEmptyMessage(attrs) &&
  134. !is_me &&
  135. (api.settings.get('show_desktop_notifications') === 'all' || isMessageToHiddenChat(attrs))
  136. );
  137. }
  138. export function showFeedbackNotification (data) {
  139. if (data.klass === 'error' || data.klass === 'warn') {
  140. const n = new Notification(data.subject, {
  141. body: data.message,
  142. lang: i18n.getLocale(),
  143. icon: api.settings.get('notification_icon')
  144. });
  145. setTimeout(n.close.bind(n), 5000);
  146. }
  147. }
  148. /**
  149. * Creates an HTML5 Notification to inform of a change in a
  150. * contact's chat state.
  151. * @param {RosterContact} contact
  152. */
  153. function showChatStateNotification (contact) {
  154. if (api.settings.get('chatstate_notification_blacklist')?.includes(contact.get('jid'))) {
  155. // Don't notify if the user is being ignored.
  156. return;
  157. }
  158. const chat_state = contact.presence.get('show');
  159. let message = null;
  160. if (chat_state === 'offline') {
  161. message = __('has gone offline');
  162. } else if (chat_state === 'away') {
  163. message = __('has gone away');
  164. } else if (chat_state === 'dnd') {
  165. message = __('is busy');
  166. } else if (chat_state === 'online') {
  167. message = __('has come online');
  168. }
  169. if (message === null) {
  170. return;
  171. }
  172. const n = new Notification(contact.getDisplayName(), {
  173. body: message,
  174. lang: i18n.getLocale(),
  175. icon: api.settings.get('notification_icon')
  176. });
  177. setTimeout(() => n.close(), 5000);
  178. }
  179. /**
  180. * Shows an HTML5 Notification with the passed in message
  181. * @private
  182. * @param { MessageData|MUCMessageData } data
  183. */
  184. function showMessageNotification (data) {
  185. const { attrs } = data;
  186. if (attrs.is_error) {
  187. return;
  188. }
  189. if (!areDesktopNotificationsEnabled()) {
  190. return;
  191. }
  192. let title, roster_item;
  193. const full_from_jid = attrs.from;
  194. const from_jid = Strophe.getBareJidFromJid(full_from_jid);
  195. if (attrs.type == 'info') {
  196. title = attrs.message;
  197. } else if (attrs.type === 'headline') {
  198. if (!from_jid.includes('@') || api.settings.get('allow_non_roster_messaging')) {
  199. title = __('Notification from %1$s', from_jid);
  200. } else {
  201. return;
  202. }
  203. } else if (!from_jid.includes('@')) {
  204. // workaround for Prosody which doesn't give type "headline"
  205. title = __('Notification from %1$s', from_jid);
  206. } else if (attrs.type === 'groupchat') {
  207. title = __('%1$s says', Strophe.getResourceFromJid(full_from_jid));
  208. } else {
  209. if (_converse.state.roster === undefined) {
  210. log.error('Could not send notification, because roster is undefined');
  211. return;
  212. }
  213. roster_item = _converse.state.roster.get(from_jid);
  214. if (roster_item !== undefined) {
  215. title = __('%1$s says', roster_item.getDisplayName());
  216. } else {
  217. if (api.settings.get('allow_non_roster_messaging')) {
  218. title = __('%1$s says', from_jid);
  219. } else {
  220. return;
  221. }
  222. }
  223. }
  224. let body;
  225. if (attrs.type == 'info') {
  226. body = attrs.reason;
  227. } else {
  228. body = attrs.is_encrypted ? attrs.plaintext : attrs.body;
  229. if (!body) {
  230. return;
  231. }
  232. }
  233. const n = new Notification(title, {
  234. 'body': body,
  235. 'lang': i18n.getLocale(),
  236. 'icon': api.settings.get('notification_icon'),
  237. 'requireInteraction': !api.settings.get('notification_delay')
  238. });
  239. if (api.settings.get('notification_delay')) {
  240. setTimeout(() => n.close(), api.settings.get('notification_delay'));
  241. }
  242. n.onclick = function (event) {
  243. event.preventDefault();
  244. window.focus();
  245. const chat = _converse.state.chatboxes.get(from_jid);
  246. chat.maybeShow(true);
  247. }
  248. }
  249. function playSoundNotification () {
  250. if (api.settings.get('play_sounds') && window.Audio !== undefined) {
  251. const audioOgg = new Audio(api.settings.get('sounds_path') + 'msg_received.ogg');
  252. const canPlayOgg = audioOgg.canPlayType('audio/ogg');
  253. if (canPlayOgg === 'probably') {
  254. return audioOgg.play();
  255. }
  256. const audioMp3 = new Audio(api.settings.get('sounds_path') + 'msg_received.mp3');
  257. const canPlayMp3 = audioMp3.canPlayType('audio/mp3');
  258. if (canPlayMp3 === 'probably') {
  259. audioMp3.play();
  260. } else if (canPlayOgg === 'maybe') {
  261. audioOgg.play();
  262. } else if (canPlayMp3 === 'maybe') {
  263. audioMp3.play();
  264. }
  265. }
  266. }
  267. /**
  268. * Event handler for the on('message') event. Will call methods
  269. * to play sounds and show HTML5 notifications.
  270. */
  271. export async function handleMessageNotification (data) {
  272. if (!await shouldNotifyOfMessage(data)) {
  273. return false;
  274. }
  275. /**
  276. * Triggered when a notification (sound or HTML5 notification) for a new
  277. * message has will be made.
  278. * @event _converse#messageNotification
  279. * @type {MessageData|MUCMessageData}
  280. * @example _converse.api.listen.on('messageNotification', data => { ... });
  281. */
  282. api.trigger('messageNotification', data);
  283. try{
  284. playSoundNotification();
  285. } catch (error) {
  286. // Likely "play() failed because the user didn't interact with the document first"
  287. log.error(error);
  288. }
  289. showMessageNotification(data);
  290. }
  291. export function handleFeedback (data) {
  292. if (areDesktopNotificationsEnabled()) {
  293. showFeedbackNotification(data);
  294. }
  295. }
  296. /**
  297. * Event handler for on('contactPresenceChanged').
  298. * Will show an HTML5 notification to indicate that the chat status has changed.
  299. * @param {RosterContact} contact
  300. */
  301. export function handleChatStateNotification (contact) {
  302. if (areDesktopNotificationsEnabled() && api.settings.get('show_chat_state_notifications')) {
  303. showChatStateNotification(contact);
  304. }
  305. }
  306. /**
  307. * @param {RosterContact} contact
  308. */
  309. function showContactRequestNotification (contact) {
  310. const n = new Notification(contact.getDisplayName(), {
  311. body: __('wants to be your contact'),
  312. lang: i18n.getLocale(),
  313. icon: api.settings.get('notification_icon')
  314. });
  315. setTimeout(() => n.close(), 5000);
  316. }
  317. /**
  318. * @param {RosterContact} contact
  319. */
  320. export function handleContactRequestNotification (contact) {
  321. if (areDesktopNotificationsEnabled()) {
  322. showContactRequestNotification(contact);
  323. }
  324. }
  325. export function requestPermission () {
  326. if (supports_html5_notification && !['denied', 'granted'].includes(Notification.permission)) {
  327. // Ask user to enable HTML5 notifications
  328. Notification.requestPermission();
  329. }
  330. }