Merge branch 'notifications-thru-sw' into 'develop'
Notifications improvements. See merge request pleroma/pleroma-fe!1873
This commit is contained in:
commit
2b41c1cfe8
48 changed files with 771 additions and 385 deletions
|
|
@ -671,6 +671,7 @@ const fetchTimeline = ({
|
|||
timeline,
|
||||
credentials,
|
||||
since = false,
|
||||
minId = false,
|
||||
until = false,
|
||||
userId = false,
|
||||
listId = false,
|
||||
|
|
@ -705,6 +706,9 @@ const fetchTimeline = ({
|
|||
url = url(listId)
|
||||
}
|
||||
|
||||
if (minId) {
|
||||
params.push(['min_id', minId])
|
||||
}
|
||||
if (since) {
|
||||
params.push(['since_id', since])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,38 @@
|
|||
import {
|
||||
showDesktopNotification as swDesktopNotification,
|
||||
closeDesktopNotification as swCloseDesktopNotification,
|
||||
isSWSupported
|
||||
} from '../sw/sw.js'
|
||||
const state = { failCreateNotif: false }
|
||||
|
||||
export const showDesktopNotification = (rootState, desktopNotificationOpts) => {
|
||||
if (!('Notification' in window && window.Notification.permission === 'granted')) return
|
||||
if (rootState.statuses.notifications.desktopNotificationSilence) { return }
|
||||
if (rootState.notifications.desktopNotificationSilence) { return }
|
||||
|
||||
const desktopNotification = new window.Notification(desktopNotificationOpts.title, desktopNotificationOpts)
|
||||
// Chrome is known for not closing notifications automatically
|
||||
// according to MDN, anyway.
|
||||
setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
|
||||
if (isSWSupported()) {
|
||||
swDesktopNotification(desktopNotificationOpts)
|
||||
} else if (!state.failCreateNotif) {
|
||||
try {
|
||||
const desktopNotification = new window.Notification(desktopNotificationOpts.title, desktopNotificationOpts)
|
||||
setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
|
||||
} catch {
|
||||
state.failCreateNotif = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const closeDesktopNotification = (rootState, { id }) => {
|
||||
if (!('Notification' in window && window.Notification.permission === 'granted')) return
|
||||
|
||||
if (isSWSupported()) {
|
||||
swCloseDesktopNotification({ id })
|
||||
}
|
||||
}
|
||||
|
||||
export const closeAllDesktopNotifications = (rootState) => {
|
||||
if (!('Notification' in window && window.Notification.permission === 'granted')) return
|
||||
|
||||
if (isSWSupported()) {
|
||||
swCloseDesktopNotification({})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -439,7 +439,6 @@ export const parseNotification = (data) => {
|
|||
output.type = mastoDict[data.type] || data.type
|
||||
output.seen = data.pleroma.is_seen
|
||||
output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
|
||||
output.action = output.status // TODO: Refactor, this is unneeded
|
||||
output.target = output.type !== 'move'
|
||||
? null
|
||||
: parseUser(data.target)
|
||||
|
|
|
|||
|
|
@ -55,10 +55,13 @@ const createFaviconService = () => {
|
|||
})
|
||||
}
|
||||
|
||||
const getOriginalFavicons = () => [...favicons]
|
||||
|
||||
return {
|
||||
initFaviconService,
|
||||
clearFaviconBadge,
|
||||
drawFaviconBadge
|
||||
drawFaviconBadge,
|
||||
getOriginalFavicons
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,36 @@
|
|||
import { filter, sortBy, includes } from 'lodash'
|
||||
import { muteWordHits } from '../status_parser/status_parser.js'
|
||||
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
|
||||
|
||||
export const notificationsFromStore = store => store.state.statuses.notifications.data
|
||||
import FaviconService from 'src/services/favicon_service/favicon_service.js'
|
||||
|
||||
export const ACTIONABLE_NOTIFICATION_TYPES = new Set(['mention', 'pleroma:report', 'follow_request'])
|
||||
|
||||
let cachedBadgeUrl = null
|
||||
|
||||
export const notificationsFromStore = store => store.state.notifications.data
|
||||
|
||||
export const visibleTypes = store => {
|
||||
const rootState = store.rootState || store.state
|
||||
// When called from within a module we need rootGetters to access wider scope
|
||||
// however when called from a component (i.e. this.$store) we already have wider scope
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
const { notificationVisibility } = rootGetters.mergedConfig
|
||||
|
||||
return ([
|
||||
rootState.config.notificationVisibility.likes && 'like',
|
||||
rootState.config.notificationVisibility.mentions && 'mention',
|
||||
rootState.config.notificationVisibility.repeats && 'repeat',
|
||||
rootState.config.notificationVisibility.follows && 'follow',
|
||||
rootState.config.notificationVisibility.followRequest && 'follow_request',
|
||||
rootState.config.notificationVisibility.moves && 'move',
|
||||
rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
|
||||
rootState.config.notificationVisibility.reports && 'pleroma:report',
|
||||
rootState.config.notificationVisibility.polls && 'poll'
|
||||
notificationVisibility.likes && 'like',
|
||||
notificationVisibility.mentions && 'mention',
|
||||
notificationVisibility.repeats && 'repeat',
|
||||
notificationVisibility.follows && 'follow',
|
||||
notificationVisibility.followRequest && 'follow_request',
|
||||
notificationVisibility.moves && 'move',
|
||||
notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
|
||||
notificationVisibility.reports && 'pleroma:report',
|
||||
notificationVisibility.polls && 'poll'
|
||||
].filter(_ => _))
|
||||
}
|
||||
|
||||
const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction', 'poll']
|
||||
const statusNotifications = new Set(['like', 'mention', 'repeat', 'pleroma:emoji_reaction', 'poll'])
|
||||
|
||||
export const isStatusNotification = (type) => includes(statusNotifications, type)
|
||||
export const isStatusNotification = (type) => statusNotifications.has(type)
|
||||
|
||||
export const isValidNotification = (notification) => {
|
||||
if (isStatusNotification(notification.type) && !notification.status) {
|
||||
|
|
@ -49,35 +57,57 @@ const sortById = (a, b) => {
|
|||
|
||||
const isMutedNotification = (store, notification) => {
|
||||
if (!notification.status) return
|
||||
return notification.status.muted || muteWordHits(notification.status, store.rootGetters.mergedConfig.muteWords).length > 0
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
return notification.status.muted || muteWordHits(notification.status, rootGetters.mergedConfig.muteWords).length > 0
|
||||
}
|
||||
|
||||
export const maybeShowNotification = (store, notification) => {
|
||||
const rootState = store.rootState || store.state
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
|
||||
if (notification.seen) return
|
||||
if (!visibleTypes(store).includes(notification.type)) return
|
||||
if (notification.type === 'mention' && isMutedNotification(store, notification)) return
|
||||
|
||||
const notificationObject = prepareNotificationObject(notification, store.rootGetters.i18n)
|
||||
const notificationObject = prepareNotificationObject(notification, rootGetters.i18n)
|
||||
showDesktopNotification(rootState, notificationObject)
|
||||
}
|
||||
|
||||
export const filteredNotificationsFromStore = (store, types) => {
|
||||
// map is just to clone the array since sort mutates it and it causes some issues
|
||||
let sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById)
|
||||
sortedNotifications = sortBy(sortedNotifications, 'seen')
|
||||
const sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById)
|
||||
// TODO implement sorting elsewhere and make it optional
|
||||
return sortedNotifications.filter(
|
||||
(notification) => (types || visibleTypes(store)).includes(notification.type)
|
||||
)
|
||||
}
|
||||
|
||||
export const unseenNotificationsFromStore = store =>
|
||||
filter(filteredNotificationsFromStore(store), ({ seen }) => !seen)
|
||||
export const unseenNotificationsFromStore = store => {
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
const ignoreInactionableSeen = rootGetters.mergedConfig.ignoreInactionableSeen
|
||||
|
||||
return filteredNotificationsFromStore(store).filter(({ seen, type }) => {
|
||||
if (!ignoreInactionableSeen) return !seen
|
||||
if (seen) return false
|
||||
return ACTIONABLE_NOTIFICATION_TYPES.has(type)
|
||||
})
|
||||
}
|
||||
|
||||
export const prepareNotificationObject = (notification, i18n) => {
|
||||
if (cachedBadgeUrl === null) {
|
||||
const favicons = FaviconService.getOriginalFavicons()
|
||||
const favicon = favicons[favicons.length - 1]
|
||||
if (!favicon) {
|
||||
cachedBadgeUrl = 'about:blank'
|
||||
} else {
|
||||
cachedBadgeUrl = favicon.favimg.src
|
||||
}
|
||||
}
|
||||
|
||||
const notifObj = {
|
||||
tag: notification.id
|
||||
tag: notification.id,
|
||||
type: notification.type,
|
||||
badge: cachedBadgeUrl
|
||||
}
|
||||
const status = notification.status
|
||||
const title = notification.from_profile.name
|
||||
|
|
@ -126,15 +156,16 @@ export const prepareNotificationObject = (notification, i18n) => {
|
|||
}
|
||||
|
||||
export const countExtraNotifications = (store) => {
|
||||
const mergedConfig = store.getters.mergedConfig
|
||||
const rootGetters = store.rootGetters || store.getters
|
||||
const mergedConfig = rootGetters.mergedConfig
|
||||
|
||||
if (!mergedConfig.showExtraNotifications) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return [
|
||||
mergedConfig.showChatsInExtraNotifications ? store.getters.unreadChatCount : 0,
|
||||
mergedConfig.showAnnouncementsInExtraNotifications ? store.getters.unreadAnnouncementCount : 0,
|
||||
mergedConfig.showFollowRequestsInExtraNotifications ? store.getters.followRequestCount : 0
|
||||
mergedConfig.showChatsInExtraNotifications ? rootGetters.unreadChatCount : 0,
|
||||
mergedConfig.showAnnouncementsInExtraNotifications ? rootGetters.unreadAnnouncementCount : 0,
|
||||
mergedConfig.showFollowRequestsInExtraNotifications ? rootGetters.followRequestCount : 0
|
||||
].reduce((a, c) => a + c, 0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
|
|||
const args = { credentials }
|
||||
const { getters } = store
|
||||
const rootState = store.rootState || store.state
|
||||
const timelineData = rootState.statuses.notifications
|
||||
const timelineData = rootState.notifications
|
||||
const hideMutedPosts = getters.mergedConfig.hideMutedPosts
|
||||
|
||||
args.includeTypes = mastoApiNotificationTypes
|
||||
|
|
@ -49,10 +49,14 @@ const fetchAndUpdate = ({ store, credentials, older = false, since }) => {
|
|||
// The normal maxId-check does not tell if older notifications have changed
|
||||
const notifications = timelineData.data
|
||||
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
|
||||
const numUnseenNotifs = notifications.length - readNotifsIds.length
|
||||
if (numUnseenNotifs > 0 && readNotifsIds.length > 0) {
|
||||
args.since = Math.max(...readNotifsIds)
|
||||
fetchNotifications({ store, args, older })
|
||||
const unreadNotifsIds = notifications.filter(n => !n.seen).map(n => n.id)
|
||||
if (readNotifsIds.length > 0 && readNotifsIds.length > 0) {
|
||||
const minId = Math.min(...unreadNotifsIds) // Oldest known unread notification
|
||||
if (minId !== Infinity) {
|
||||
args.since = false // Don't use since_id since it sorta conflicts with min_id
|
||||
args.minId = minId - 1 // go beyond
|
||||
fetchNotifications({ store, args, older })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ function urlBase64ToUint8Array (base64String) {
|
|||
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
|
||||
}
|
||||
|
||||
export function isSWSupported () {
|
||||
return 'serviceWorker' in navigator
|
||||
}
|
||||
|
||||
function isPushSupported () {
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window
|
||||
return 'PushManager' in window
|
||||
}
|
||||
|
||||
function getOrCreateServiceWorker () {
|
||||
|
|
@ -24,7 +28,7 @@ function subscribePush (registration, isEnabled, vapidPublicKey) {
|
|||
if (!vapidPublicKey) return Promise.reject(new Error('VAPID public key is not found'))
|
||||
|
||||
const subscribeOptions = {
|
||||
userVisibleOnly: true,
|
||||
userVisibleOnly: false,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
}
|
||||
return registration.pushManager.subscribe(subscribeOptions)
|
||||
|
|
@ -39,7 +43,7 @@ function unsubscribePush (registration) {
|
|||
}
|
||||
|
||||
function deleteSubscriptionFromBackEnd (token) {
|
||||
return window.fetch('/api/v1/push/subscription/', {
|
||||
return fetch('/api/v1/push/subscription/', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -78,6 +82,44 @@ function sendSubscriptionToBackEnd (subscription, token, notificationVisibility)
|
|||
return responseData
|
||||
})
|
||||
}
|
||||
export async function initServiceWorker (store) {
|
||||
if (!isSWSupported()) return
|
||||
await getOrCreateServiceWorker()
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
const { dispatch } = store
|
||||
const { type, ...rest } = event.data
|
||||
|
||||
switch (type) {
|
||||
case 'notificationClicked':
|
||||
dispatch('notificationClicked', { id: rest.id })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function showDesktopNotification (content) {
|
||||
if (!isSWSupported) return
|
||||
const { active: sw } = await window.navigator.serviceWorker.getRegistration()
|
||||
if (!sw) return console.error('No serviceworker found!')
|
||||
sw.postMessage({ type: 'desktopNotification', content })
|
||||
}
|
||||
|
||||
export async function closeDesktopNotification ({ id }) {
|
||||
if (!isSWSupported) return
|
||||
const { active: sw } = await window.navigator.serviceWorker.getRegistration()
|
||||
if (!sw) return console.error('No serviceworker found!')
|
||||
if (id >= 0) {
|
||||
sw.postMessage({ type: 'desktopNotificationClose', content: { id } })
|
||||
} else {
|
||||
sw.postMessage({ type: 'desktopNotificationClose', content: { all: true } })
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFocus () {
|
||||
if (!isSWSupported) return
|
||||
const { active: sw } = await window.navigator.serviceWorker.getRegistration()
|
||||
if (!sw) return console.error('No serviceworker found!')
|
||||
sw.postMessage({ type: 'updateFocus' })
|
||||
}
|
||||
|
||||
export function registerPushNotifications (isEnabled, vapidPublicKey, token, notificationVisibility) {
|
||||
if (isPushSupported()) {
|
||||
|
|
@ -98,13 +140,8 @@ export function unregisterPushNotifications (token) {
|
|||
})
|
||||
.then(([registration, unsubResult]) => {
|
||||
if (!unsubResult) {
|
||||
console.warn('Push subscription cancellation wasn\'t successful, killing SW anyway...')
|
||||
console.warn('Push subscription cancellation wasn\'t successful')
|
||||
}
|
||||
return registration.unregister().then((result) => {
|
||||
if (!result) {
|
||||
console.warn('Failed to kill SW')
|
||||
}
|
||||
})
|
||||
})
|
||||
]).catch((e) => console.warn(`Failed to disable Web Push Notifications: ${e.message}`))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue