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 <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/frontend/pulls/57
This commit is contained in:
parent
161f853361
commit
04d7d48b68
4 changed files with 177 additions and 4 deletions
BIN
public/images/icons/badge-monochrome.png
Normal file
BIN
public/images/icons/badge-monochrome.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2 KiB |
|
@ -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();
|
workbox.core.clientsClaim();
|
||||||
// The precaching code provided by Workbox.
|
// The precaching code provided by Workbox.
|
||||||
self.__precacheManifest = [].concat(self.__precacheManifest || []);
|
self.__precacheManifest = [].concat(self.__precacheManifest || []);
|
||||||
|
|
|
@ -16,10 +16,17 @@ export default class TaskModel extends AbstractModel {
|
||||||
this.startDate = new Date(this.startDate)
|
this.startDate = new Date(this.startDate)
|
||||||
this.endDate = new Date(this.endDate)
|
this.endDate = new Date(this.endDate)
|
||||||
|
|
||||||
|
// Cancel all scheduled notifications for this task to be sure to only have available notifications
|
||||||
|
this.cancelScheduledNotifications()
|
||||||
|
.then(() => {
|
||||||
this.reminderDates = this.reminderDates.map(d => {
|
this.reminderDates = this.reminderDates.map(d => {
|
||||||
return new Date(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
|
this.reminderDates.push(null) // To trigger the datepicker
|
||||||
|
})
|
||||||
|
|
||||||
// Parse the repeat after into something usable
|
// Parse the repeat after into something usable
|
||||||
this.parseRepeatAfter()
|
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
|
let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709
|
||||||
return luma > 128
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { register } from 'register-service-worker'
|
import { register } from 'register-service-worker'
|
||||||
import swEvents from './ServiceWorker/events'
|
import swEvents from './ServiceWorker/events'
|
||||||
|
import auth from './auth'
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
register(`${process.env.BASE_URL}sw.js`, {
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue