Merge branch 'feature/push-subscriptions' into 'develop'

add service worker and push notifications

See merge request pleroma/pleroma-fe!404
This commit is contained in:
HJ 2018-12-13 13:46:57 +00:00
commit 8e4777ccc6
13 changed files with 213 additions and 21 deletions

View file

@ -2,6 +2,7 @@ var path = require('path')
var config = require('../config') var config = require('../config')
var utils = require('./utils') var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../') var projectRoot = path.resolve(__dirname, '../')
var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
var env = process.env.NODE_ENV var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the // check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -91,5 +92,10 @@ module.exports = {
browsers: ['last 2 versions'] browsers: ['last 2 versions']
}) })
] ]
} },
plugins: [
new ServiceWorkerWebpackPlugin({
entry: path.join(__dirname, '..', 'src/sw.js')
})
]
} }

View file

@ -90,6 +90,7 @@
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"selenium-server": "2.53.1", "selenium-server": "2.53.1",
"semver": "^5.3.0", "semver": "^5.3.0",
"serviceworker-webpack-plugin": "0.2.3",
"shelljs": "^0.7.4", "shelljs": "^0.7.4",
"sinon": "^1.17.3", "sinon": "^1.17.3",
"sinon-chai": "^2.8.0", "sinon-chai": "^2.8.0",

View file

@ -17,17 +17,21 @@ import FollowRequests from '../components/follow_requests/follow_requests.vue'
import OAuthCallback from '../components/oauth_callback/oauth_callback.vue' import OAuthCallback from '../components/oauth_callback/oauth_callback.vue'
import UserSearch from '../components/user_search/user_search.vue' import UserSearch from '../components/user_search/user_search.vue'
const afterStoreSetup = ({store, i18n}) => { const afterStoreSetup = ({ store, i18n }) => {
window.fetch('/api/statusnet/config.json') window.fetch('/api/statusnet/config.json')
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
const {name, closed: registrationClosed, textlimit, server} = data.site const { name, closed: registrationClosed, textlimit, server, vapidPublicKey } = data.site
store.dispatch('setInstanceOption', { name: 'name', value: name }) store.dispatch('setInstanceOption', { name: 'name', value: name })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') }) store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) }) store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server }) store.dispatch('setInstanceOption', { name: 'server', value: server })
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
var apiConfig = data.site.pleromafe var apiConfig = data.site.pleromafe
window.fetch('/static/config.json') window.fetch('/static/config.json')

View file

@ -47,6 +47,7 @@ const settings = {
scopeCopyLocal: user.scopeCopy, scopeCopyLocal: user.scopeCopy,
scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy), scopeCopyDefault: this.$t('settings.values.' + instance.scopeCopy),
stopGifs: user.stopGifs, stopGifs: user.stopGifs,
webPushNotificationsLocal: user.webPushNotifications,
loopSilentAvailable: loopSilentAvailable:
// Firefox // Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') || Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
@ -142,6 +143,10 @@ const settings = {
}, },
stopGifs (value) { stopGifs (value) {
this.$store.dispatch('setOption', { name: 'stopGifs', value }) this.$store.dispatch('setOption', { name: 'stopGifs', value })
},
webPushNotificationsLocal (value) {
this.$store.dispatch('setOption', { name: 'webPushNotifications', value })
if (value) this.$store.dispatch('registerPushNotifications')
} }
} }
} }

View file

@ -143,6 +143,18 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="setting-item">
<h2>{{$t('settings.notifications')}}</h2>
<ul class="setting-list">
<li>
<input type="checkbox" id="webPushNotifications" v-model="webPushNotificationsLocal">
<label for="webPushNotifications">
{{$t('settings.enable_web_push_notifications')}}
</label>
</li>
</ul>
</div>
</div> </div>
<div :label="$t('settings.theme')" > <div :label="$t('settings.theme')" >

View file

@ -133,7 +133,7 @@
"inputRadius": "Input fields", "inputRadius": "Input fields",
"checkboxRadius": "Checkboxes", "checkboxRadius": "Checkboxes",
"instance_default": "(default: {value})", "instance_default": "(default: {value})",
"instance_default_simple" : "(default)", "instance_default_simple": "(default)",
"interface": "Interface", "interface": "Interface",
"interfaceLanguage": "Interface language", "interfaceLanguage": "Interface language",
"invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.", "invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.",
@ -190,6 +190,8 @@
"false": "no", "false": "no",
"true": "yes" "true": "yes"
}, },
"notifications": "Notifications",
"enable_web_push_notifications": "Enable web push notifications",
"style": { "style": {
"switcher": { "switcher": {
"keep_color": "Keep colors", "keep_color": "Keep colors",

View file

@ -50,6 +50,32 @@ const persistedStateOptions = {
'oauth' 'oauth'
] ]
} }
const registerPushNotifications = store => {
store.subscribe((mutation, state) => {
const vapidPublicKey = state.instance.vapidPublicKey
const permission = state.interface.notificationPermission === 'granted'
const isUserMutation = mutation.type === 'setCurrentUser'
if (isUserMutation && vapidPublicKey && permission) {
return store.dispatch('registerPushNotifications')
}
const user = state.users.currentUser
const isVapidMutation = mutation.type === 'setInstanceOption' && mutation.payload.name === 'vapidPublicKey'
if (isVapidMutation && user && permission) {
return store.dispatch('registerPushNotifications')
}
const isPermMutation = mutation.type === 'setNotificationPermission' && mutation.payload === 'granted'
if (isPermMutation && user && vapidPublicKey) {
return store.dispatch('registerPushNotifications')
}
})
}
createPersistedState(persistedStateOptions).then((persistedState) => { createPersistedState(persistedStateOptions).then((persistedState) => {
const store = new Vuex.Store({ const store = new Vuex.Store({
modules: { modules: {
@ -62,10 +88,10 @@ createPersistedState(persistedStateOptions).then((persistedState) => {
chat: chatModule, chat: chatModule,
oauth: oauthModule oauth: oauthModule
}, },
plugins: [persistedState], plugins: [persistedState, registerPushNotifications],
strict: false // Socket modifies itself, let's ignore this for now. strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production' // strict: process.env.NODE_ENV !== 'production'
}) })
afterStoreSetup({store, i18n}) afterStoreSetup({ store, i18n })
}) })

View file

@ -24,6 +24,7 @@ const defaultState = {
likes: true, likes: true,
repeats: true repeats: true
}, },
webPushNotifications: true,
muteWords: [], muteWords: [],
highlight: {}, highlight: {},
interfaceLanguage: browserLocale, interfaceLanguage: browserLocale,

View file

@ -3,12 +3,13 @@ import { set, delete as del } from 'vue'
const defaultState = { const defaultState = {
settings: { settings: {
currentSaveStateNotice: null, currentSaveStateNotice: null,
noticeClearTimeout: null noticeClearTimeout: null,
notificationPermission: null
}, },
browserSupport: { browserSupport: {
cssFilter: window.CSS && window.CSS.supports && ( cssFilter: window.CSS && window.CSS.supports && (
window.CSS.supports('filter', 'drop-shadow(0 0)') || window.CSS.supports('filter', 'drop-shadow(0 0)') ||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
) )
} }
} }
@ -23,10 +24,13 @@ const interfaceMod = {
} }
set(state.settings, 'currentSaveStateNotice', { error: false, data: success }) set(state.settings, 'currentSaveStateNotice', { error: false, data: success })
set(state.settings, 'noticeClearTimeout', set(state.settings, 'noticeClearTimeout',
setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000)) setTimeout(() => del(state.settings, 'currentSaveStateNotice'), 2000))
} else { } else {
set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error }) set(state.settings, 'currentSaveStateNotice', { error: true, errorData: error })
} }
},
setNotificationPermission (state, permission) {
state.notificationPermission = permission
} }
}, },
actions: { actions: {
@ -35,6 +39,9 @@ const interfaceMod = {
}, },
settingsSaved ({ commit, dispatch }, { success, error }) { settingsSaved ({ commit, dispatch }, { success, error }) {
commit('settingsSaved', { success, error }) commit('settingsSaved', { success, error })
},
setNotificationPermission ({ commit }, permission) {
commit('setNotificationPermission', permission)
} }
} }
} }

View file

@ -1,8 +1,9 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { compact, map, each, merge } from 'lodash' import { compact, map, each, merge } from 'lodash'
import { set } from 'vue' import { set } from 'vue'
import registerPushNotifications from '../services/push/push.js'
import oauthApi from '../services/new_api/oauth' import oauthApi from '../services/new_api/oauth'
import {humanizeErrors} from './errors' import { humanizeErrors } from './errors'
// TODO: Unify with mergeOrAdd in statuses.js // TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => { export const mergeOrAdd = (arr, obj, item) => {
@ -11,17 +12,25 @@ export const mergeOrAdd = (arr, obj, item) => {
if (oldItem) { if (oldItem) {
// We already have this, so only merge the new info. // We already have this, so only merge the new info.
merge(oldItem, item) merge(oldItem, item)
return {item: oldItem, new: false} return { item: oldItem, new: false }
} else { } else {
// This is a new item, prepare it // This is a new item, prepare it
arr.push(item) arr.push(item)
obj[item.id] = item obj[item.id] = item
return {item, new: true} return { item, new: true }
} }
} }
const getNotificationPermission = () => {
const Notification = window.Notification
if (!Notification) return Promise.resolve(null)
if (Notification.permission === 'default') return Notification.requestPermission()
return Promise.resolve(Notification.permission)
}
export const mutations = { export const mutations = {
setMuted (state, { user: {id}, muted }) { setMuted (state, { user: { id }, muted }) {
const user = state.usersObject[id] const user = state.usersObject[id]
set(user, 'muted', muted) set(user, 'muted', muted)
}, },
@ -45,7 +54,7 @@ export const mutations = {
setUserForStatus (state, status) { setUserForStatus (state, status) {
status.user = state.usersObject[status.user.id] status.user = state.usersObject[status.user.id]
}, },
setColor (state, { user: {id}, highlighted }) { setColor (state, { user: { id }, highlighted }) {
const user = state.usersObject[id] const user = state.usersObject[id]
set(user, 'highlight', highlighted) set(user, 'highlight', highlighted)
}, },
@ -77,9 +86,16 @@ const users = {
mutations, mutations,
actions: { actions: {
fetchUser (store, id) { fetchUser (store, id) {
store.rootState.api.backendInteractor.fetchUser({id}) store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => store.commit('addNewUsers', user)) .then((user) => store.commit('addNewUsers', user))
}, },
registerPushNotifications (store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = store.rootState.instance.vapidPublicKey
const isEnabled = store.rootState.config.webPushNotifications
registerPushNotifications(isEnabled, vapidPublicKey, token)
},
addNewStatuses (store, { statuses }) { addNewStatuses (store, { statuses }) {
const users = map(statuses, 'user') const users = map(statuses, 'user')
const retweetedUsers = compact(map(statuses, 'retweeted_status.user')) const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))
@ -143,6 +159,9 @@ const users = {
commit('setCurrentUser', user) commit('setCurrentUser', user)
commit('addNewUsers', [user]) commit('addNewUsers', [user])
getNotificationPermission()
.then(permission => commit('setNotificationPermission', permission))
// Set our new backend interactor // Set our new backend interactor
commit('setBackendInteractor', backendInteractorService(accessToken)) commit('setBackendInteractor', backendInteractorService(accessToken))
@ -161,12 +180,8 @@ const users = {
store.commit('addNewUsers', mutedUsers) store.commit('addNewUsers', mutedUsers)
}) })
if ('Notification' in window && window.Notification.permission === 'default') {
window.Notification.requestPermission()
}
// Fetch our friends // Fetch our friends
store.rootState.api.backendInteractor.fetchFriends({id: user.id}) store.rootState.api.backendInteractor.fetchFriends({ id: user.id })
.then((friends) => commit('addNewUsers', friends)) .then((friends) => commit('addNewUsers', friends))
}) })
} else { } else {

69
src/services/push/push.js Normal file
View file

@ -0,0 +1,69 @@
import runtime from 'serviceworker-webpack-plugin/lib/runtime'
function urlBase64ToUint8Array (base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
}
function isPushSupported () {
return 'serviceWorker' in navigator && 'PushManager' in window
}
function registerServiceWorker () {
return runtime.register()
.catch((err) => console.error('Unable to register service worker.', err))
}
function subscribe (registration, isEnabled, vapidPublicKey) {
if (!isEnabled) return Promise.reject(new Error('Web Push is disabled in config'))
if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found'))
const subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
}
return registration.pushManager.subscribe(subscribeOptions)
}
function sendSubscriptionToBackEnd (subscription, token) {
return window.fetch('/api/v1/push/subscription/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
subscription,
data: {
alerts: {
follow: true,
favourite: true,
mention: true,
reblog: true
}
}
})
})
.then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response.json()
})
.then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.')
return responseData
})
}
export default function registerPushNotifications (isEnabled, vapidPublicKey, token) {
if (isPushSupported()) {
registerServiceWorker()
.then((registration) => subscribe(registration, isEnabled, vapidPublicKey))
.then((subscription) => sendSubscriptionToBackEnd(subscription, token))
.catch((e) => console.warn(`Failed to setup Web Push Notifications: ${e.message}`))
}
}

38
src/sw.js Normal file
View file

@ -0,0 +1,38 @@
/* eslint-env serviceworker */
import localForage from 'localforage'
function isEnabled () {
return localForage.getItem('vuex-lz')
.then(data => data.config.webPushNotifications)
}
function getWindowClients () {
return clients.matchAll({ includeUncontrolled: true })
.then((clientList) => clientList.filter(({ type }) => type === 'window'))
}
self.addEventListener('push', (event) => {
if (event.data) {
event.waitUntil(isEnabled().then((isEnabled) => {
return isEnabled && getWindowClients().then((list) => {
const data = event.data.json()
if (list.length === 0) return self.registration.showNotification(data.title, data)
})
}))
}
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(getWindowClients().then((list) => {
for (var i = 0; i < list.length; i++) {
var client = list[i]
if (client.url === '/' && 'focus' in client) { return client.focus() }
}
if (clients.openWindow) return clients.openWindow('/')
}))
})

View file

@ -3925,7 +3925,7 @@ mime@^1.3.4, mime@^1.5.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2: "minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2:
version "3.0.4" version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
dependencies: dependencies:
@ -5289,6 +5289,12 @@ serve-static@1.13.1:
parseurl "~1.3.2" parseurl "~1.3.2"
send "0.16.1" send "0.16.1"
serviceworker-webpack-plugin@0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/serviceworker-webpack-plugin/-/serviceworker-webpack-plugin-0.2.3.tgz#1873ed6fc83c873ac8240fac443c615d374feeb2"
dependencies:
minimatch "^3.0.3"
set-blocking@^2.0.0, set-blocking@~2.0.0: set-blocking@^2.0.0, set-blocking@~2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"