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

View file

@ -3,12 +3,13 @@ import { defineStore } from 'pinia'
import { import {
closeAllDesktopNotifications, closeAllDesktopNotifications,
closeDesktopNotification, closeDesktopNotification,
} from '../services/desktop_notification_utils/desktop_notification_utils.js' } from 'src/services/desktop_notification_utils/desktop_notification_utils.js'
import { import {
isValidNotification, isValidNotification,
maybeShowNotification, maybeShowNotification,
} from '../services/notification_utils/notification_utils.js' } from 'src/services/notification_utils/notification_utils.js'
import { isStatusNotification } from '../services/notification_utils/notification_utils_sw.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 { useI18nStore } from 'src/stores/i18n.js'
import { useMergedConfigStore } from 'src/stores/merged_config.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 { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js' import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js' import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js' import { useUsersStore } from 'src/stores/users.js'
import { dismissNotification, markNotificationsAsSeen } from 'src/api/user.js' import { dismissNotification, markNotificationsAsSeen } from 'src/api/user.js'
export const defaultState = () => ({ export const defaultState = () => ({
desktopNotificationSilence: true, desktopNotificationSilence: true,
maxId: 0, maxId: '',
minId: Number.POSITIVE_INFINITY, minId: '',
data: [], data: [],
statusNotificationRelations: new WeakMap(), statusNotificationRelations: new WeakMap(),
idStore: new Map(), idStore: new Map(),
loading: false, loading: false,
socket: null,
streaming: false,
fetcher: null,
}) })
export const useNotificationsStore = defineStore('notifications', { export const useNotificationsStore = defineStore('notifications', {
state: defaultState, state: defaultState,
actions: { actions: {
clearNotifications() { // Init
const blankState = defaultState() 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] 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) { updateNotificationsMinMaxId(id) {
this.maxId = id > this.maxId ? id : this.maxId this.maxId = id > this.maxId ? id : this.maxId
this.minId = id < this.minId ? id : this.minId this.minId = id < this.minId ? id : this.minId
}, },
setNotificationsLoading(value) {
this.loading = value
},
setNotificationsSilence(value) {
this.desktopNotificationSilence = value
},
updateNotification({ id, updater }) { updateNotification({ id, updater }) {
const notification = this.idStore.get(id) const notification = this.idStore.get(id)
notification && updater(notification) notification && updater(notification)
@ -139,6 +213,8 @@ export const useNotificationsStore = defineStore('notifications', {
} }
}) })
}, },
// Seen / Dismiss
notificationClicked(id) { notificationClicked(id) {
const notification = this.idStore.get(id) const notification = this.idStore.get(id)
const { type, seen } = notification const { type, seen } = notification
@ -190,5 +266,10 @@ export const useNotificationsStore = defineStore('notifications', {
credentials: useOAuthStore().token, credentials: useOAuthStore().token,
}) })
}, },
// Misc
setLoading(value) {
this.loading = value
},
}, },
}) })

View file

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

View file

@ -75,6 +75,86 @@ export const defaultState = () => {
export const useTimelinesStore = defineStore('timelines', { export const useTimelinesStore = defineStore('timelines', {
state: defaultState, state: defaultState,
actions: { 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( addStatusesToTimeline(
timelineName, timelineName,
argument, 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) { onStreamMessage(timeline, argument, event) {
// This relies on statuses store to process this event first // This relies on statuses store to process this event first
const status = useStatusesStore().allStatuses.get(event.data.status.id) const status = useStatusesStore().allStatuses.get(event.data.status.id)
@ -214,26 +235,24 @@ export const useTimelinesStore = defineStore('timelines', {
}) })
}, },
// Poll & Push
onStreamConnect(timeline) { onStreamConnect(timeline) {
console.log('STREAM OK', timeline) console.debug('[Timelines] Stream connected', timeline)
this[timeline].streaming = true this[timeline].streaming = true
this.stopFetchingTimeline(timeline) this.stopFetchingTimeline(timeline, 'Socket connected')
}, },
onStreamDisconnect(timeline, argument) { onStreamDisconnect(timeline, argument) {
console.log('STREAM DED', timeline, argument) console.debug('[Timelines] Stream disconnected', timeline, argument)
this[timeline].streaming = false this[timeline].streaming = false
this.startFetchingTimeline(timeline, argument) this.startFetchingTimeline(timeline, argument, 'Socket disconnected')
}, },
startFetchingTimeline(timelineName, argument, reason) {
// Fetchers console.debug('[Timelines] Starting fetching timeline', timelineName, argument, 'Reason:', reason)
startFetchingTimeline(timelineName, argument) {
console.log('START FETCHING', timelineName, argument)
const timeline = this[timelineName] const timeline = this[timelineName]
timeline.fetcher.startFetching() timeline.fetcher.startFetching()
}, },
stopFetchingTimeline(timelineName) { stopFetchingTimeline(timelineName, reason) {
console.log('STOP FETCHING', timelineName) console.debug('[Timelines] Stopped fetching timeline', timelineName, 'Reason:', reason)
const timeline = this[timelineName] const timeline = this[timelineName]
timeline.fetcher.stopFetching() timeline.fetcher.stopFetching()
}, },
@ -254,13 +273,6 @@ export const useTimelinesStore = defineStore('timelines', {
timeline.minId = minNew timeline.minId = minNew
} }
}, },
resetStatuses() {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
this[key] = value
})
},
showNewStatuses(timelineName) { showNewStatuses(timelineName) {
const timeline = this[timelineName] const timeline = this[timelineName]
@ -276,15 +288,6 @@ export const useTimelinesStore = defineStore('timelines', {
this.updateTimelineExtremes(timeline, [...timeline.statuses.keys()]) this.updateTimelineExtremes(timeline, [...timeline.statuses.keys()])
}, },
clearTimeline(timeline) { 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) { queueFlush(timeline, id) {
this[timeline].flushMarker = id this[timeline].flushMarker = id

View file

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