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 zcmXw$dpOhk1IItxj4`*3&3$Ht&_?U*{hlT#T@hv|Y2%E`%-6P@4F({tX>^ZNJwexCQMkg?xSOT$`Iz24?;PHfX zKI>#W;Pd&EtEQ=RUIes#+=KXGC0MyF;Y2Klkwaax;0(`vwNKR>FYVWzw-ll}x z)gk^VCVyomH!>LM2DDlFk?C|ZOy;u;OK&ps+IP-9IX4TVab@zP1kN-xsT_DrP7-7qk14jDx(C{47 zFE=c`-f`$}%rI^gqY>I0`4GY6W9&7(asyhN@1xf-R|GPVAlqg+%ej6;A7hm4_LYLE zs(CbfV-HPUC`-CfVNd$#>r=UIX8Q0GL%lf(Jwzfc=1%0cL^*%4E7z7DGW(i?Te&I_ zo+}M{+(jaBpc16BN5tL7t_l3CWtg%sloZd?1DGnU`>KE8CO1l}Gk$iuGqU(ZAQNx3 zvgGLjr45tS&kdH_JRN8wvggpEm88dS@K!9`XZd zr8iDwTDZ+2z{`W|YgcgB7yRoqG$(8hsOrGBrNzv!kg7BWmdWfoedgb~KvZmv*Qqopo+VGDpx3*O4+uH!p_*%sa#! zZ3&xZzeQb+&|RIoWIgUDC7$>&+mumwC0?vb9QUIxE&o2l=@0@rg0bf0bO_-6Qga%$ z3k!LL5~_~R=q9Cja}Nz)R`Rg2tWE?IGlMKmm}?ctIa6NVlG{MIxKExhg)w7Lvrn8y zdOV*l^4_d<#d?5vyXVhHVr;iw7$f(BKt^C%ix88hK86-w@t=<1xrm$a{w z?E!3Vo*Mt(lmfmiB0yK}oLPM1uu>(V?P^nF(ju>uqBSgzE<6Z7-4=sX5!*86a?5bO zx%C_3-%OkBsGdoV{AJV8gjQ?wVl`r7kwmIuq@LVT4MU{r_Gpdj+h8cTTNL2stClZEFyBwF zt2l&ne^OH#aC0xZn)&Q~ap$^&9Kk!^*_!Jr@B{bF!J6h;5hb2 zeg^?mJQ>@^zU`qosRL#78TOtXEHtiorzP312VonJeTjCls_c!_k;X+3Qcq?HKY`k( z_*-Q$T}TDX1N0@MLj^tf6HG5KlY|bH@xRN6QowO1ww-5+tFus0&)ZPT$)Ct$>d#=Z zdugc?YqubsPNX}_9k_i+K?jztN-uoBI_UFP!%3`AfawGzo6^++tr1F8g&EGQ=N_C z(w0~7mpGrv7V=ZH3yyf+;o>!0(dgi)nja1j|M~bf->{BZwMEK9^$!b&gOC7aQ46BS z*B1H@+gqF0B8l@s++4!_HnMJhYbmsY7H9wG%;bOa2IK1bpKyxwhhve@(wc}aIqkl# zbOF0Mk?t$y66*G7#7e$|YNGNvG?IjGkA*zN>J632_ljRLP^@wZZ9Bz|I-R~=ChIId+lYEr1Q?26@EBk{el(n zuWBWasaA5i4^*XWq@g~B<8t%#tDPXN6RB`O*7Pq0=s_txzL7+HJIvulmPyzxvTo4? z5N?nXsx`_qE4W9JZK;lJmn1b{>kV}(JyQ$n>H)Eu$>^b1JHcV|2NW!(^>u|LQwtg& zH8?9hz%4YquPRMJg0S0U-TZG&@aEb>3iyspE$FSMcM$l4Mr)2e?mU)R4W1W&;w)QW zI$jPg3UE zSq~2!9yRkm0@E$`-q_x6EXQxlYX!>16Rk!2`mMmHSvGNj3<&RG#jN$L;hapg)L(f^ zLNoA%406Y%L60Z}yoqg&F~;XycwRhnkx$6Hx$+FtWQ8E|_`q6F<-?NbG(5PAxLfk2 zWs&uZjgq5GQ=*xEsM*Y*k$}7}pp!((Jr*IyioIX!id_FOBBK4gUNl|~kINap3F%3V t#HleOvWLCryuuN(P3hN-U7FUof8dY6)b(Y3CKdny{OS8?w|$s-{|BXcb>#p6 literal 0 HcmV?d00001 diff --git a/src/ServiceWorker/sw.js b/src/ServiceWorker/sw.js index 8d7724947..84b0ce33f 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 9d20831b8..a95009df8 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 ef9b0eb12..826afd59d 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', + }) + } + }); +} +