From 04d7d48b684dd7c304f32cd257627ba120aaa708 Mon Sep 17 00:00:00 2001 From: konrad Date: Sat, 8 Feb 2020 17:28:17 +0000 Subject: [PATCH] Notifications for task reminders (#57) Add actions for reminders Remove scheduled reminders Better styling Start adding support for triggered offline notifications Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/57 --- public/images/icons/badge-monochrome.png | Bin 0 -> 2068 bytes src/ServiceWorker/sw.js | 76 +++++++++++++++++++++ src/models/task.js | 81 +++++++++++++++++++++-- src/registerServiceWorker.js | 24 +++++++ 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 public/images/icons/badge-monochrome.png diff --git a/public/images/icons/badge-monochrome.png b/public/images/icons/badge-monochrome.png new file mode 100644 index 0000000000000000000000000000000000000000..6e346df50e30305e82db7f8ffaa92b6dcb7abe58 GIT binary patch literal 2068 zcmXw44LH-=A3xiSF>f2@eP)HwM%=s=MjH{KR9?b1@7A6ikGCg-z+@E;>Wv4&S+s~(Q4yXOji{MVG*`BGhsA+|i(f4FZC z6~{99e8a+Xfkk^`ig6_y4pZOA2JyxpVz23x>r>-=9>0#c!j}s9+1AThPW8jk&Scjw zWK31fQ75KRezTUVO@z zindx>KuZd1I-{Q*B(r|@s14s9gmBurvaO&9@TCNy2eZ;Kqo=c8N_o$~^z@#pz;>g6 zP}6`v;SmpdOJG0GBk*mJjq1_xm9RH!iL8#Kp!OXS1Ff-?5bP>Udc1wgq-S!$DAX2SuGtETumS)SWcPr6pg=c+i@6P|C zhnaMO@x7?sxpN3>g*+}F<%Mc#QT}1jKVANB4xsuFJ)=@_i0v#0LT!*YmSbG0M0ix zr%|)8kW(n8XnBupl8ZMX?aE3XR+iNXVIq2n37*X@ z)APx#-w^#~(riodNOIsVn~Ws1TA3BA5E6^T5+!Q~R+xqs%;h@C-b;2-S|Z2Rt!VRn zJCQbu5O(f+DGhx zNyC!&cti)DXM@WKt8W1of}5mZ^Br~6%M5o=5w8w>=5|JCQ4YdVnpG)o)oQ_c3v##~ z^8>d74=SFH?ql9|SD(=0W%L>Jo*gJOs#wqv@7LjB8;^aCcDAhSjntCFMc`9UW(hum z+Gn_1WieeyImFzTj1J}Z;7-sz!E_QjRLcD>Mij(-V%vGTs5%S9>vWvk7&0-4cXC)nNq=&qp2bEMOnyrz8=;oL#g zQ<$KhH2UZVcCJIFCa>$)8pkf=V}%1)ucNT)oTJ2D8HRwax%Mhb+<9ls5;qjFe!&v> zRke~vl`Glo2g(vA(m)r(a=v-`kDVZ`6DhYx*7Ps=>+n)~d?E?Bc9{LkEaR|SB<-Sc zAlM)#RI8P#SFn#H+fW?ZE{SWv)*Gr6TBZiCs|UoYC!+@;mcwQb$XHD4>k2WH9~;p> zD>=X}G`O!UNkM|J+fW0)F~OT_56R&>AjDe_uR!o8mD(J8+-Wqk8oVg}$XYhXbi5jv z&&gDmbjjiFD3|}j@dyP&b&Vv4)BZBx#mnJHs5e^zfz4nn*nUcd72i`C`3S<)!vls! zOudf4w9CCVw)Y#!aNF`)fnxDQYtg=bOYm`qNth=A{5x1NV?ApqClf94Q`{0$^?i8y zxnq*R$K(Re_%??aqjSz2PmY<;J7msGafWWZf)~2KXUr>eVM#P94%|iDE&1HC$oR!t z!BV6t&~#taOlIJ)f8J-%NhIbTi;!VOUaz%qN)R { } }); +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', + }) + } + }); +} +