diff --git a/public/images/icons/badge-monochrome.png b/public/images/icons/badge-monochrome.png new file mode 100644 index 00000000..6e346df5 Binary files /dev/null and b/public/images/icons/badge-monochrome.png differ diff --git a/src/ServiceWorker/sw.js b/src/ServiceWorker/sw.js index 8d772494..84b0ce33 100644 --- a/src/ServiceWorker/sw.js +++ b/src/ServiceWorker/sw.js @@ -36,6 +36,82 @@ self.addEventListener('message', (e) => { } }); +const getBearerToken = async () => { + // we can't get a client that sent the current request, therefore we need + // to ask any controlled page for auth token + const allClients = await self.clients.matchAll(); + const client = allClients.filter(client => client.type === 'window')[0]; + + // if there is no page in scope, we can't get any token + // and we indicate it with null value + if(!client) { + return null; + } + + // to communicate with a page we will use MessageChannels + // they expose pipe-like interface, where a receiver of + // a message uses one end of a port for messaging and + // we use the other end for listening + const channel = new MessageChannel(); + + client.postMessage({ + 'action': 'getBearerToken' + }, [channel.port1]); + + // ports support only onmessage callback which + // is cumbersome to use, so we wrap it with Promise + return new Promise((resolve, reject) => { + channel.port2.onmessage = event => { + if (event.data.error) { + console.error('Port error', event.error); + reject(event.data.error); + } + + resolve(event.data.authToken); + } + }); +} + +// Notification action +self.addEventListener('notificationclick', function(event) { + const taskID = event.notification.data.taskID + event.notification.close() + + switch (event.action) { + case 'mark-as-done': + // FIXME: Ugly as hell, but no other way of doing this, since we can't use modules + // in service workersfor now. + fetch('/config.json') + .then(r => r.json()) + .then(config => { + + getBearerToken() + .then(token => { + fetch(`${config.VIKUNJA_API_BASE_URL}tasks/${taskID}`, { + method: 'post', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({id: taskID, done: true}) + }) + .then(r => r.json()) + .then(r => { + console.debug('Task marked as done from notification', r) + }) + .catch(e => { + console.debug('Error marking task as done from notification', e) + }) + }) + }) + break + case 'show-task': + clients.openWindow(`/tasks/${taskID}`) + break + } +}) + workbox.core.clientsClaim(); // The precaching code provided by Workbox. self.__precacheManifest = [].concat(self.__precacheManifest || []); diff --git a/src/models/task.js b/src/models/task.js index 9d20831b..a95009df 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -16,10 +16,17 @@ export default class TaskModel extends AbstractModel { this.startDate = new Date(this.startDate) this.endDate = new Date(this.endDate) - this.reminderDates = this.reminderDates.map(d => { - return new Date(d) - }) - this.reminderDates.push(null) // To trigger the datepicker + // Cancel all scheduled notifications for this task to be sure to only have available notifications + this.cancelScheduledNotifications() + .then(() => { + this.reminderDates = this.reminderDates.map(d => { + d = new Date(d) + // Every time we see a reminder, we schedule a notification for it + this.scheduleNotification(d) + return d + }) + this.reminderDates.push(null) // To trigger the datepicker + }) // Parse the repeat after into something usable this.parseRepeatAfter() @@ -136,4 +143,70 @@ export default class TaskModel extends AbstractModel { let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709 return luma > 128 } + + async cancelScheduledNotifications() { + const registration = await navigator.serviceWorker.getRegistration() + if (typeof registration === 'undefined') { + return + } + + // Get all scheduled notifications for this task and cancel them + const scheduledNotifications = await registration.getNotifications({ + tag: `vikunja-task-${this.id}`, + includeTriggered: true, + }) + console.debug('Already scheduled notifications:', scheduledNotifications) + scheduledNotifications.forEach(n => n.close()) + } + + async scheduleNotification(date) { + + // Don't need to do anything if the notification date is in the past + if (date < (new Date())) { + return + } + + if (!('showTrigger' in Notification.prototype)) { + console.debug('This browser does not support triggered notifications') + return + } + + const {state} = await navigator.permissions.request({name: 'notifications'}); + if (state !== 'granted') { + console.debug('Notification permission not granted, not showing notifications') + return + } + + const registration = await navigator.serviceWorker.getRegistration() + if (typeof registration === 'undefined') { + return + } + + // Register the actual notification + registration.showNotification('Vikunja Reminder', { + tag: `vikunja-task-${this.id}`, // Group notifications by task id so we're only showing one notification per task + body: this.text, + // eslint-disable-next-line no-undef + showTrigger: new TimestampTrigger(date), + badge: '/images/icons/badge-monochrome.png', + icon: '/images/icons/android-chrome-512x512.png', + data: {taskID: this.id}, + actions: [ + { + action: 'mark-as-done', + title: 'Done' + }, + { + action: 'show-task', + title: 'Show task' + }, + ], + }) + .then(() => { + console.debug('Notification scheduled for ' + date) + }) + .catch(e => { + console.debug('Error scheduling notification', e) + }) + } } \ No newline at end of file diff --git a/src/registerServiceWorker.js b/src/registerServiceWorker.js index ef9b0eb1..826afd59 100644 --- a/src/registerServiceWorker.js +++ b/src/registerServiceWorker.js @@ -2,6 +2,7 @@ import { register } from 'register-service-worker' import swEvents from './ServiceWorker/events' +import auth from './auth' if (process.env.NODE_ENV === 'production') { register(`${process.env.BASE_URL}sw.js`, { @@ -32,3 +33,26 @@ if (process.env.NODE_ENV === 'production') { } }) } + +if(navigator && navigator.serviceWorker) { + navigator.serviceWorker.addEventListener('message', event => { + // for every message we expect an action field + // determining operation that we should perform + const { action } = event.data; + // we use 2nd port provided by the message channel + const port = event.ports[0]; + + if(action === 'getBearerToken') { + console.debug('Token request from sw'); + port.postMessage({ + authToken: auth.getToken(), + }) + } else { + console.error('Unknown event', event); + port.postMessage({ + error: 'Unknown request', + }) + } + }); +} +