notification.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. /**
  2. * This file is part of Threema Web.
  3. *
  4. * Threema Web is free software: you can redistribute it and/or modify it
  5. * under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or (at
  7. * your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful, but
  10. * WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  12. * General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with Threema Web. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. import {StateService as UiStateService} from '@uirouter/angularjs';
  18. import {SettingsService} from './settings';
  19. export class NotificationService {
  20. private static SETTINGS_NOTIFICATIONS = 'notifications';
  21. private static SETTINGS_NOTIFICATION_PREVIEW = 'notificationPreview';
  22. private static SETTINGS_NOTIFICATION_SOUND = 'notificationSound';
  23. private static NOTIFICATION_SOUND = 'sounds/notification.mp3';
  24. private $log: ng.ILogService;
  25. private $window: ng.IWindowService;
  26. private $state: UiStateService;
  27. private settingsService: SettingsService;
  28. private logTag = '[NotificationService]';
  29. // Whether user has granted notification permission
  30. private notificationPermission: boolean = null;
  31. // Whether user wants to receive desktop notifications
  32. private desktopNotifications: boolean = null;
  33. // Whether the browser supports notifications
  34. private notificationAPIAvailable: boolean = null;
  35. // Whether the user wants notification preview
  36. private notificationPreview: boolean = null;
  37. // Whether the user wants notification sound
  38. private notificationSound: boolean = null;
  39. // Cache notifications
  40. private notificationCache: any = {};
  41. public static $inject = ['$log', '$window', '$state', 'SettingsService'];
  42. constructor($log: ng.ILogService, $window: ng.IWindowService,
  43. $state: UiStateService, settingsService: SettingsService) {
  44. this.$log = $log;
  45. this.$window = $window;
  46. this.$state = $state;
  47. this.settingsService = settingsService;
  48. }
  49. public init(): void {
  50. this.checkNotificationAPI();
  51. this.fetchSettings();
  52. }
  53. /**
  54. * Ask user for desktop notification permissions.
  55. *
  56. * Possible values for 'notificationPermission'
  57. * - denied: User has denied the notification permission
  58. * - granted: User has granted the notification permission
  59. * - default: User has visits Threema Web the first time.
  60. * It stays default unless he denies/grants us the
  61. * notification permission or if the result is sth. else
  62. * If the user grants the permission, the 'desktopNotification' flag
  63. * becomes true and is stored in the local storage.
  64. */
  65. private requestNotificationPermission(): void {
  66. if (this.notificationAPIAvailable) {
  67. const Notification = this.$window.Notification;
  68. this.$log.debug(this.logTag, 'Requesting notification permission...');
  69. Notification.requestPermission((result) => {
  70. switch (result) {
  71. case 'denied':
  72. this.notificationPermission = false;
  73. break;
  74. case 'granted':
  75. this.notificationPermission = true;
  76. this.desktopNotifications = true;
  77. this.storeSetting(NotificationService.SETTINGS_NOTIFICATIONS, 'true');
  78. break;
  79. case 'default':
  80. this.desktopNotifications = false;
  81. this.notificationPermission = null;
  82. break;
  83. default:
  84. this.notificationPermission = false;
  85. break;
  86. }
  87. this.$log.debug(this.logTag, 'Notification permission', this.notificationPermission);
  88. });
  89. }
  90. }
  91. /**
  92. * Check the notification api availability and permission state
  93. *
  94. * If the api is available, 'notificationAPIAvailable' becomes true and
  95. * the permission state is checked
  96. */
  97. private checkNotificationAPI(): void {
  98. this.notificationAPIAvailable = ('Notification' in this.$window);
  99. this.$log.debug(this.logTag, 'Notification API available:', this.notificationAPIAvailable);
  100. if (this.notificationAPIAvailable) {
  101. const Notification = this.$window.Notification;
  102. switch (Notification.permission) {
  103. // denied means the user must manually re-grant permission via browser settings
  104. case 'denied':
  105. this.notificationPermission = false;
  106. break;
  107. case 'granted':
  108. this.notificationPermission = true;
  109. break;
  110. case 'default':
  111. this.notificationPermission = null;
  112. break;
  113. default:
  114. this.notificationPermission = false;
  115. break;
  116. }
  117. }
  118. this.$log.debug(this.logTag, 'Initial notificationPermission', this.notificationPermission);
  119. }
  120. /**
  121. * Get the initial settings from local storage
  122. */
  123. private fetchSettings(): void {
  124. this.$log.debug(this.logTag, 'Fetching settings...');
  125. const notifications = this.retrieveSetting(NotificationService.SETTINGS_NOTIFICATIONS);
  126. const preview = this.retrieveSetting(NotificationService.SETTINGS_NOTIFICATION_PREVIEW);
  127. const sound = this.retrieveSetting(NotificationService.SETTINGS_NOTIFICATION_SOUND);
  128. if (notifications === 'true') {
  129. this.$log.debug(this.logTag, 'Desktop notifications:', notifications);
  130. this.desktopNotifications = true;
  131. // check permission because user may have revoked them
  132. this.requestNotificationPermission();
  133. } else if (notifications === 'false') {
  134. this.$log.debug(this.logTag, 'Desktop notifications:', notifications);
  135. // user does not want notifications
  136. this.desktopNotifications = false;
  137. } else {
  138. this.$log.debug(this.logTag, 'Desktop notifications:', notifications, 'Asking user...');
  139. // Neither true nor false was in local storage, so we have to ask the user if he wants notifications
  140. // If he grants (or already has granted) us the permission, we will set the flag true (default setting)
  141. this.requestNotificationPermission();
  142. }
  143. if (preview === 'false') {
  144. this.$log.debug(this.logTag, 'Notification preview:', preview);
  145. this.notificationPreview = false;
  146. } else {
  147. // set the flag true if true/nothing or sth. else is in local storage (default setting)
  148. this.$log.debug(this.logTag, 'Notification preview:', preview, 'Using default value (true)');
  149. this.notificationPreview = true;
  150. this.storeSetting(NotificationService.SETTINGS_NOTIFICATION_PREVIEW, 'true');
  151. }
  152. if (sound === 'true') {
  153. this.$log.debug(this.logTag, 'Notification sound:', sound);
  154. this.notificationSound = true;
  155. } else {
  156. // set the flag false if false/nothing or sth. else is in local storage (default setting)
  157. this.$log.debug(this.logTag, 'Notification sound:', sound, 'Using default value (false)');
  158. this.notificationSound = false;
  159. this.storeSetting(NotificationService.SETTINGS_NOTIFICATION_SOUND, 'false');
  160. }
  161. }
  162. /**
  163. * Returns if the user has granted the notification permission
  164. */
  165. public getNotificationPermission(): boolean {
  166. return this.notificationPermission;
  167. }
  168. /**
  169. * Returns if the user wants to receive notifications
  170. */
  171. public getWantsNotifications(): boolean {
  172. return this.desktopNotifications;
  173. }
  174. /**
  175. * Returns if the user wants a message preview in the notification
  176. */
  177. public getWantsPreview(): boolean {
  178. return this.notificationPreview;
  179. }
  180. /**
  181. * Returns if the user wants sound when a new message arrives
  182. */
  183. public getWantsSound(): boolean {
  184. return this.notificationSound;
  185. }
  186. /**
  187. * Returns if the notification api is available
  188. */
  189. public isNotificationApiAvailable(): boolean {
  190. return this.notificationAPIAvailable;
  191. }
  192. /**
  193. * Sets if the user wants desktop notifications
  194. */
  195. public setWantsNotifications(wantsNotifications: boolean): void {
  196. this.$log.debug(this.logTag, 'Requesting notification preference change to', wantsNotifications);
  197. if (wantsNotifications) {
  198. this.requestNotificationPermission();
  199. } else {
  200. this.desktopNotifications = false;
  201. this.storeSetting(NotificationService.SETTINGS_NOTIFICATIONS, 'false');
  202. }
  203. }
  204. /**
  205. * Sets if the user wants a message preview
  206. */
  207. public setWantsPreview(wantsPreview: boolean): void {
  208. this.$log.debug(this.logTag, 'Requesting preview preference change to', wantsPreview);
  209. this.notificationPreview = wantsPreview;
  210. this.storeSetting(NotificationService.SETTINGS_NOTIFICATION_PREVIEW, wantsPreview ? 'true' : 'false');
  211. }
  212. /**
  213. * Sets if the user wants sound when a new message arrives
  214. */
  215. public setWantsSound(wantsSound: boolean): void {
  216. this.$log.debug(this.logTag, 'Requesting sound preference change to', wantsSound);
  217. this.notificationSound = wantsSound;
  218. this.storeSetting(NotificationService.SETTINGS_NOTIFICATION_SOUND, wantsSound ? 'true' : 'false');
  219. }
  220. /**
  221. * Stores the given key/value pair in local storage
  222. */
  223. private storeSetting(key: string, value: string): void {
  224. this.settingsService.storeUntrustedKeyValuePair(key, value);
  225. }
  226. /**
  227. * Retrieves the value for the given key from local storage
  228. */
  229. private retrieveSetting(key: string): string {
  230. return this.settingsService.retrieveUntrustedKeyValuePair(key);
  231. }
  232. /**
  233. * Parse the conversation notification settings and return a simplified version.
  234. */
  235. public getAppNotificationSettings(conversation: threema.Conversation): threema.SimplifiedNotificationSettings {
  236. if (!conversation.notifications) {
  237. return {
  238. sound: {muted: false},
  239. dnd: {enabled: false, mentionOnly: false},
  240. };
  241. }
  242. const sound = conversation.notifications.sound;
  243. const dnd = conversation.notifications.dnd;
  244. const simpleSound = {
  245. muted: !(sound.mode === threema.NotificationSoundMode.Default),
  246. };
  247. const simpleDnd = {
  248. enabled: dnd.mode === threema.NotificationDndMode.On,
  249. mentionOnly: (dnd.mentionOnly === undefined || dnd.mentionOnly === null) ? false : dnd.mentionOnly,
  250. };
  251. if (dnd.mode === threema.NotificationDndMode.Until) {
  252. if (!dnd.until || dnd.until <= 0) {
  253. simpleDnd.enabled = false;
  254. } else {
  255. const until: Date = new Date(dnd.until);
  256. const now: Date = new Date();
  257. simpleDnd.enabled = until > now;
  258. }
  259. }
  260. // Mention only does not make sense if DND is not enabled at all.
  261. if (!simpleDnd.enabled) {
  262. simpleDnd.mentionOnly = false;
  263. }
  264. return {
  265. sound: simpleSound,
  266. dnd: simpleDnd,
  267. };
  268. }
  269. /**
  270. * Return a simplified DND mode.
  271. *
  272. * This will return either 'on', 'off' or 'mention'.
  273. * The 'until' mode will be processed depending on the expiration timestamp.
  274. */
  275. public getDndModeSimplified(conversation: threema.Conversation): 'on' | 'mention' | 'off' {
  276. const simplified = this.getAppNotificationSettings(conversation);
  277. if (simplified.dnd.enabled) {
  278. return simplified.dnd.mentionOnly ? 'mention' : 'on';
  279. }
  280. return 'off';
  281. }
  282. /**
  283. * Notify the user via Desktop Notification API.
  284. *
  285. * Return a boolean indicating whether the notification has been shown.
  286. *
  287. * @param tag A tag used to group similar notifications.
  288. * @param title The notification title
  289. * @param body The notification body
  290. * @param avatar URL to the avatar file (optional, default is the Threema logo)
  291. * @param clickCallback Callback function to be executed on click (optional)
  292. * @param forceShowBody Show the notification body even if the user has disabled
  293. * notification preview (optional, default false)
  294. * @param overwriteOlder Do not append message to pre-existing notifications with
  295. * the same tag, replace them instead (optional, default false)
  296. */
  297. public showNotification(tag: string, title: string, body: string,
  298. avatar: string = '/img/threema-64x64.png',
  299. clickCallback?: any,
  300. forceShowBody: boolean = false,
  301. overwriteOlder: boolean = false,
  302. forceMute: boolean = false): boolean {
  303. // Play sound on new message if the user wants to
  304. if (this.notificationSound && !forceMute) {
  305. const audio = new Audio(NotificationService.NOTIFICATION_SOUND);
  306. audio.play();
  307. }
  308. // Only show notifications if user granted permission to do so
  309. if (this.notificationPermission !== true || this.desktopNotifications !== true) {
  310. return false;
  311. }
  312. // Clear body string if the user does not want a notification preview
  313. if (!this.notificationPreview && !forceShowBody) {
  314. body = '';
  315. // Clear notification cache
  316. if (this.notificationCache[tag]) {
  317. this.clearCache(tag);
  318. }
  319. }
  320. // Clear cache if the user wants to overwrite older messages of the same tag
  321. if (overwriteOlder === true) {
  322. this.clearCache(tag);
  323. }
  324. // If the cache is not empty, append old messages
  325. if (this.notificationCache[tag]) {
  326. body += '\n' + this.notificationCache[tag].body;
  327. }
  328. // Show notification
  329. this.$log.debug(this.logTag, 'Showing notification', tag);
  330. const notification = new this.$window.Notification(title, {
  331. icon: avatar,
  332. body: body.trim(),
  333. tag: tag,
  334. });
  335. // Hide notification on click
  336. notification.onclick = () => {
  337. this.$window.focus();
  338. // Redirect to welcome screen
  339. if (clickCallback !== undefined) {
  340. clickCallback();
  341. }
  342. this.$log.debug(this.logTag, 'Hiding notification', tag, 'on click');
  343. notification.close();
  344. this.clearCache(tag);
  345. };
  346. // Update notification cache
  347. this.notificationCache[tag] = notification;
  348. return true;
  349. }
  350. /**
  351. * Hide the notification with the specified tag.
  352. *
  353. * Return whether the notification was hidden.
  354. */
  355. public hideNotification(tag: string): boolean {
  356. const notification = this.notificationCache[tag];
  357. if (notification !== undefined) {
  358. this.$log.debug(this.logTag, 'Hiding notification', tag);
  359. notification.close();
  360. this.clearCache(tag);
  361. return true;
  362. } else {
  363. return false;
  364. }
  365. }
  366. /**
  367. * Clear the notification cache for the specified tag.
  368. */
  369. public clearCache(tag: string) {
  370. delete this.notificationCache[tag];
  371. }
  372. }