notifications work

This commit is contained in:
Henry Jameson 2026-08-13 16:45:44 +03:00
commit 7e6d835125
5 changed files with 250 additions and 133 deletions

View file

@ -7,7 +7,6 @@ import { useNotificationsStore } from 'src/stores/notifications.js'
import { fetchTimeline } from 'src/api/timelines.js'
//
// For using include_types when fetching notifications.
// Note: chat_mention excluded as pleroma-fe polls them separately
const mastoApiNotificationTypes = new Set([
@ -23,7 +22,11 @@ const mastoApiNotificationTypes = new Set([
'pleroma:report',
])
const fetchAndUpdate = ({ credentials, older = false, sinceId }) => {
const fetchAndUpdate = (
{ credentials },
{ older = false, sinceId }
) => {
useNotificationsStore().setLoading(true)
const args = { credentials }
const timelineData = useNotificationsStore()
const hideMutedPosts = useMergedConfigStore().mergedConfig.hideMutedPosts
@ -107,24 +110,38 @@ const fetchNotifications = ({ args, older }) => {
})
console.error(error)
})
.finally(() => {
useNotificationsStore().setLoading(false)
})
}
const startFetching = ({ credentials, store }) => {
// Initially there's set flag to silence all desktop notifications so
// that there won't spam of them when user just opened up the FE we
// reset that flag after a while to show new notifications once again.
setTimeout(
() => useNotificationsStore().setNotificationsSilence(false),
10000,
)
const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store })
boundFetchAndUpdate()
return promiseInterval(boundFetchAndUpdate, 10000)
}
const notificationsFetcher = {
fetchAndUpdate,
startFetching,
const notificationsFetcher = (credentials) => {
const state = {
interval: null,
}
const boundFetchAndUpdate = ({ older = false, sinceId } = {}) =>
fetchAndUpdate({ credentials }, { older, sinceId })
const startFetching = () => {
if (state.interval) throw new Error('Interval already exists!')
boundFetchAndUpdate()
state.interval = promiseInterval(boundFetchAndUpdate, 10000)
}
const stopFetching = () => {
state.interval.stop()
state.interval = null
}
return {
startFetching,
stopFetching,
fetchAndUpdate: boundFetchAndUpdate,
}
}
export default notificationsFetcher

View file

@ -3,12 +3,13 @@ import { defineStore } from 'pinia'
import {
closeAllDesktopNotifications,
closeDesktopNotification,
} from '../services/desktop_notification_utils/desktop_notification_utils.js'
} from 'src/services/desktop_notification_utils/desktop_notification_utils.js'
import {
isValidNotification,
maybeShowNotification,
} from '../services/notification_utils/notification_utils.js'
import { isStatusNotification } from '../services/notification_utils/notification_utils_sw.js'
} from 'src/services/notification_utils/notification_utils.js'
import { isStatusNotification } from 'src/services/notification_utils/notification_utils_sw.js'
import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
@ -16,40 +17,113 @@ import { useOAuthStore } from 'src/stores/oauth.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js'
import { dismissNotification, markNotificationsAsSeen } from 'src/api/user.js'
export const defaultState = () => ({
desktopNotificationSilence: true,
maxId: 0,
minId: Number.POSITIVE_INFINITY,
maxId: '',
minId: '',
data: [],
statusNotificationRelations: new WeakMap(),
idStore: new Map(),
loading: false,
socket: null,
streaming: false,
fetcher: null,
})
export const useNotificationsStore = defineStore('notifications', {
state: defaultState,
actions: {
clearNotifications() {
const blankState = defaultState()
// Init
attachSocket() {
const et = new EventTarget()
const handleNotificationMessage = ({ data, timestamp }) => {
console.log(data)
this.addNewNotifications({ statuses: [data.notification], timestamp })
}
const notificationHandler = ({ detail: message }) => {
handleNotificationMessage(message)
}
const openHandler = () => this.onStreamConnect()
const closeHandler = () => this.onStreamDisconnect()
const socket = {
et,
handlers: {
openHandler,
closeHandler,
notificationHandler,
}
}
Object.keys(defaultState()).forEach((k) => {
et.addEventListener('notification', notificationHandler)
et.addEventListener('open', openHandler)
et.addEventListener('close', closeHandler)
useStreamingStore().addSubscriber(socket)
this.socket = socket
},
activate() {
this.attachSocket()
// Initially there's set flag to silence all desktop notifications so
// that there won't spam of them when user just opened up the FE we
// reset that flag after a while to show new notifications once again.
setTimeout(
() => this.desktopNotificationSilence = false,
10000,
)
if (this.fetcher) throw new Error('Fetcher already exists!')
this.fetcher = notificationsFetcher(useOAuthStore().token)
this.startFetching('Notifications activated')
},
deactivate() {
if (!this.streaming) {
this.stopFetching('Notifications deactivated')
}
useStreamingStore().removeSubscriber(this.socket)
const { openHandler, closeHandler, notificationHandler } = timeline.socket.handlers
this.socket.et.removeEventListener('notification', openHandler)
this.socket.et.removeEventListener('notification', closeHandler)
this.socket.et.removeEventListener('notification', notificationHandler)
const blankState = defaultState()
Object.keys(blankState).forEach((k) => {
this[k] = blankState[k]
})
},
// Poll & Push
onStreamConnect() {
console.debug('[Notifications] Notifications stream connected')
this.streaming = true
this.stopFetching('Socket connected')
},
onStreamDisconnect() {
console.debug('[Notifications] Notifications stream disconnected')
this.streaming = false
this.startFetching('Socket disconnected')
},
startFetching(reason) {
console.debug('[Notifications] Starting fetching notifications', 'Reason:', reason)
this.fetcher.startFetching()
},
stopFetching(reason) {
console.debug('[Notifications] Stopped fetching notifications', 'Reason:', reason)
this.fetcher.stopFetching()
},
// Updates
updateNotificationsMinMaxId(id) {
this.maxId = id > this.maxId ? id : this.maxId
this.minId = id < this.minId ? id : this.minId
},
setNotificationsLoading(value) {
this.loading = value
},
setNotificationsSilence(value) {
this.desktopNotificationSilence = value
},
updateNotification({ id, updater }) {
const notification = this.idStore.get(id)
notification && updater(notification)
@ -139,6 +213,8 @@ export const useNotificationsStore = defineStore('notifications', {
}
})
},
// Seen / Dismiss
notificationClicked(id) {
const notification = this.idStore.get(id)
const { type, seen } = notification
@ -190,5 +266,10 @@ export const useNotificationsStore = defineStore('notifications', {
credentials: useOAuthStore().token,
})
},
// Misc
setLoading(value) {
this.loading = value
},
},
})

View file

@ -38,6 +38,7 @@ export const defaultState = () => ({
scrobblesNextFetch: {},
conversations: new Map(),
favorites: new Set(),
socket: null,
})
const getLatestScrobble = (user) => {
@ -84,22 +85,36 @@ export const useStatusesStore = defineStore('statuses', {
const handleStatusMessage = ({ data, timestamp }) => {
this.addNewStatuses({ statuses: [data.status], timestamp })
}
et.addEventListener('update', ({ detail: message }) => {
handleStatusMessage(message)
})
et.addEventListener('status.update', ({ detail: message }) => {
handleStatusMessage(message)
})
et.addEventListener('delete', ({ detail: message }) => {
const handleUpdate = ({ detail: message }) => handleStatusMessage(message)
const handleDelete = ({ detail: message }) => {
console.log('DELETE', message)
this.deleteStatus(message.data)
})
}
const socket = {
et,
handlers: {
handleUpdate, handleDelete
}
}
useStreamingStore().addSubscriber({ et })
et.addEventListener('update', handleUpdate)
et.addEventListener('status.update', handleUpdate)
et.addEventListener('delete', handleDelete)
useStreamingStore().addSubscriber(socket)
this.socket = socket
},
resetStatuses() {
this.socket.et.removeEventListener('update', this.socket.handleUpdate)
this.socket.et.removeEventListener('status.update', this.socket.handleUpdate)
this.socket.et.removeEventListener('delete', this.socket.handleDelete)
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
this[key] = value
})
},
addNewStatuses({ statuses, timestamp }) {
// Sanity check
if (!Array.isArray(statuses)) {
@ -209,6 +224,8 @@ export const useStatusesStore = defineStore('statuses', {
return [map.get(newStatus.id), true]
},
// Fetches
fetchStatus(id) {
return fetchStatus({ id }).then(({ data: status, timestamp }) =>
this.addNewStatuses({ statuses: [status], timestamp }),

View file

@ -75,6 +75,86 @@ export const defaultState = () => {
export const useTimelinesStore = defineStore('timelines', {
state: defaultState,
actions: {
// (De)Initialization stuff
activate(timelineName, argument, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (
timelineName === 'favourites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
) {
return
}
timeline.fetcher = timelineFetcher(
timeline,
argument,
useOAuthStore().token,
)
this.startFetchingTimeline(timelineName, argument, 'Timeline activated')
const streamName = TIMELINE_STREAM_MAP[timelineName]
if (streamName) {
const et = new EventTarget()
const openHandler = () => this.onStreamConnect(timelineName, argument)
const closeHandler = () => this.onStreamDisconnect(timelineName, argument)
const messageHandler = () => ({ detail: message }) =>
this.onStreamMessage(timelineName, argument, message)
et.addEventListener('open', openHandler)
et.addEventListener('close', closeHandler)
et.addEventListener('update', messageHandler)
timeline.socket = {
stream: {
name: streamName,
argument,
},
et,
handlers: {
openHandler,
closeHandler,
messageHandler,
}
}
useStreamingStore().addSubscriber(timeline.socket)
}
},
deactivate(timelineName, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (!timeline.streaming) {
this.stopFetchingTimeline(timelineName, 'Timeline deactivation')
}
if (data.socket) {
useStreamingStore().removeSubscriber(timeline.socket)
const { openHandler, closeHandler, messageHandler } = timeline.socket.handlers
et.removeEventListener('open', openHandler)
et.removeEventListener('close', closeHandler)
et.removeEventListener('message', messageHandler)
}
this[timelineName] = emptyTl(timelineName)
},
activatePersistents() {
TIMELINES.forEach((name) => {
if (this[name].persistent) {
this.activate(name, undefined, true)
}
})
},
deactivateAll() {
TIMELINES.forEach((name) => {
this.deactivate(name, true)
})
},
// Update stuff
addStatusesToTimeline(
timelineName,
argument,
@ -146,65 +226,6 @@ export const useTimelinesStore = defineStore('timelines', {
}
})
},
activatePersistents() {
TIMELINES.forEach((name) => {
if (this[name].persistent) {
this.activate(name, undefined, true)
}
})
},
activate(timelineName, argument, persistent) {
const timeline = this[timelineName]
if (timeline.persistent && !persistent) return
if (
timelineName === 'favourites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
) {
return
}
timeline.fetcher = timelineFetcher(
timeline,
argument,
useOAuthStore().token,
)
this.startFetchingTimeline(timelineName, argument)
const streamName = TIMELINE_STREAM_MAP[timelineName]
if (streamName) {
const et = new EventTarget()
et.addEventListener('open', () =>
this.onStreamConnect(timelineName, argument),
)
et.addEventListener('close', () =>
this.onStreamDisconnect(timelineName, argument),
)
et.addEventListener('update', ({ detail: message }) =>
this.onStreamMessage(timelineName, argument, message),
)
timeline.socket = {
stream: {
name: streamName,
argument,
},
et,
}
useStreamingStore().addSubscriber(timeline.socket)
}
},
deactivate(timelineName) {
const timeline = this[timelineName]
if (timeline.persistent) return
this.clearTimeline(timelineName)
},
onStreamMessage(timeline, argument, event) {
// This relies on statuses store to process this event first
const status = useStatusesStore().allStatuses.get(event.data.status.id)
@ -214,26 +235,24 @@ export const useTimelinesStore = defineStore('timelines', {
})
},
// Poll & Push
onStreamConnect(timeline) {
console.log('STREAM OK', timeline)
console.debug('[Timelines] Stream connected', timeline)
this[timeline].streaming = true
this.stopFetchingTimeline(timeline)
this.stopFetchingTimeline(timeline, 'Socket connected')
},
onStreamDisconnect(timeline, argument) {
console.log('STREAM DED', timeline, argument)
console.debug('[Timelines] Stream disconnected', timeline, argument)
this[timeline].streaming = false
this.startFetchingTimeline(timeline, argument)
this.startFetchingTimeline(timeline, argument, 'Socket disconnected')
},
// Fetchers
startFetchingTimeline(timelineName, argument) {
console.log('START FETCHING', timelineName, argument)
startFetchingTimeline(timelineName, argument, reason) {
console.debug('[Timelines] Starting fetching timeline', timelineName, argument, 'Reason:', reason)
const timeline = this[timelineName]
timeline.fetcher.startFetching()
},
stopFetchingTimeline(timelineName) {
console.log('STOP FETCHING', timelineName)
stopFetchingTimeline(timelineName, reason) {
console.debug('[Timelines] Stopped fetching timeline', timelineName, 'Reason:', reason)
const timeline = this[timelineName]
timeline.fetcher.stopFetching()
},
@ -254,13 +273,6 @@ export const useTimelinesStore = defineStore('timelines', {
timeline.minId = minNew
}
},
resetStatuses() {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
this[key] = value
})
},
showNewStatuses(timelineName) {
const timeline = this[timelineName]
@ -276,15 +288,6 @@ export const useTimelinesStore = defineStore('timelines', {
this.updateTimelineExtremes(timeline, [...timeline.statuses.keys()])
},
clearTimeline(timeline) {
console.log('CLEAR TIMELINE', timeline)
const data = this[timeline]
if (!data.streaming) {
this.stopFetchingTimeline(timeline)
}
if (data.socket) {
useStreamingStore().removeSubscriber(data.socket)
}
this[timeline] = emptyTl(timeline)
},
queueFlush(timeline, id) {
this[timeline].flushMarker = id

View file

@ -651,13 +651,12 @@ export const useUsersStore = defineStore('users', {
})
.then(() => {
this.clearCurrentUser()
store.dispatch('disconnectFromSocket')
store.dispatch('stopFetchingTimeline', 'friends')
store.dispatch('stopFetchingNotifications')
useListsStore().stopFetching()
useBookmarkFoldersStore().stopFetching()
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
useTimelinesStore().deactivateAll()
useStatusesStore().resetStatuses()
useNotificationsStore().clearNotifications()
useChatsStore().resetChats()
@ -726,6 +725,8 @@ export const useUsersStore = defineStore('users', {
}
// DMs and Home
useStatusesStore().attachSocket()
useNotificationsStore().activate()
useTimelinesStore().activatePersistents()
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
@ -735,8 +736,6 @@ export const useUsersStore = defineStore('users', {
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
useStatusesStore().attachSocket()
//useNotificationsStore().attachSocket()
if (user.locked) {
dispatch('startFetchingFollowRequests')