386 lines
9.4 KiB
Vue
386 lines
9.4 KiB
Vue
<template>
|
|
<div class="notifications">
|
|
<slot
|
|
name="trigger"
|
|
toggle-open="() => showNotifications = !showNotifications"
|
|
:has-unread-notifications="unreadNotifications > 0"
|
|
>
|
|
<BaseButton
|
|
class="trigger-button"
|
|
@click.stop="showNotifications = !showNotifications"
|
|
>
|
|
<span class="is-sr-only">{{ $t('notification.title') }}</span>
|
|
<span
|
|
v-if="unreadNotifications > 0"
|
|
class="unread-indicator"
|
|
/>
|
|
<Icon icon="bell" />
|
|
</BaseButton>
|
|
</slot>
|
|
|
|
<CustomTransition name="fade">
|
|
<div
|
|
v-if="showNotifications"
|
|
ref="popup"
|
|
class="notifications-list"
|
|
>
|
|
<div class="head">
|
|
<span>{{ $t('notification.title') }}</span>
|
|
<BaseButton
|
|
v-tooltip="$t('notification.subscribeFeed')"
|
|
class="feed-link"
|
|
:to="{name: 'user.settings.feeds'}"
|
|
@click="showNotifications = false"
|
|
>
|
|
<span class="is-sr-only">{{ $t('notification.subscribeFeed') }}</span>
|
|
<Icon icon="rss" />
|
|
</BaseButton>
|
|
</div>
|
|
<div
|
|
v-for="(n, index) in notifications"
|
|
:key="n.id"
|
|
class="single-notification"
|
|
:class="{'is-clickable': notificationHasRoute(n)}"
|
|
@click="() => notificationHasRoute(n) && to(n, index)()"
|
|
>
|
|
<div
|
|
class="read-indicator"
|
|
:class="{'read': n.readAt !== null}"
|
|
/>
|
|
<User
|
|
v-if="n.notification.doer"
|
|
:user="n.notification.doer"
|
|
:show-username="false"
|
|
:avatar-size="16"
|
|
/>
|
|
<div class="detail">
|
|
<div>
|
|
<span
|
|
v-if="n.notification.doer"
|
|
class="has-text-weight-bold mie-1"
|
|
>
|
|
{{ getDisplayName(n.notification.doer) }}
|
|
</span>
|
|
{{ n.toText(userInfo) }}
|
|
</div>
|
|
<span
|
|
v-tooltip="formatDateLong(n.created)"
|
|
class="created"
|
|
>
|
|
{{ formatDisplayDate(n.created) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<XButton
|
|
v-if="notifications.length > 0 && unreadNotifications > 0"
|
|
variant="tertiary"
|
|
class="mbs-2 is-fullwidth"
|
|
@click="markAllRead"
|
|
>
|
|
{{ $t('notification.markAllRead') }}
|
|
</XButton>
|
|
<p
|
|
v-if="notifications.length === 0"
|
|
class="nothing"
|
|
>
|
|
{{ $t('notification.none') }}<br>
|
|
<span class="explainer">
|
|
{{ $t('notification.explainer') }}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</CustomTransition>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
|
import {useRouter, isNavigationFailure, NavigationFailureType, RouteLocationRaw} from 'vue-router'
|
|
|
|
import NotificationService from '@/services/notification'
|
|
import NotificationModel from '@/models/notification'
|
|
import BaseButton from '@/components/base/BaseButton.vue'
|
|
import CustomTransition from '@/components/misc/CustomTransition.vue'
|
|
import User from '@/components/misc/User.vue'
|
|
import {NOTIFICATION_NAMES as names, type INotification} from '@/modelTypes/INotification'
|
|
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
|
|
import {formatDateLong, formatDisplayDate} from '@/helpers/time/formatDate'
|
|
import {getDisplayName} from '@/models/user'
|
|
import {useAuthStore} from '@/stores/auth'
|
|
import {useWebSocket} from '@/composables/useWebSocket'
|
|
import XButton from '@/components/input/Button.vue'
|
|
import {success} from '@/message'
|
|
import {useI18n} from 'vue-i18n'
|
|
|
|
const {subscribe, connected: wsConnected} = useWebSocket()
|
|
|
|
const authStore = useAuthStore()
|
|
const router = useRouter()
|
|
const {t} = useI18n()
|
|
|
|
const allNotifications = ref<INotification[]>([])
|
|
const showNotifications = ref(false)
|
|
const popup = ref(null)
|
|
|
|
const unreadNotifications = computed(() => {
|
|
return notifications.value.filter(n => n.readAt === null).length
|
|
})
|
|
const notifications = computed(() => {
|
|
return allNotifications.value ? allNotifications.value.filter(n => n.name !== '') : []
|
|
})
|
|
const userInfo = computed(() => authStore.info)
|
|
|
|
let unsubscribeWs: (() => void) | null = null
|
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
const POLL_INTERVAL = 10000
|
|
|
|
onMounted(async () => {
|
|
// Initial load via REST - wrapped in try/catch so the rest of setup
|
|
// (click handler, WS subscription, polling) still runs if this fails
|
|
try {
|
|
await loadNotifications()
|
|
} catch (e) {
|
|
console.warn('Failed to load initial notifications:', e)
|
|
}
|
|
|
|
document.addEventListener('click', hidePopup)
|
|
|
|
// Subscribe to real-time notifications
|
|
unsubscribeWs = subscribe('notification.created', (msg) => {
|
|
if (msg.event === 'notification.created' && msg.data) {
|
|
const notification = new NotificationModel(msg.data as Partial<INotification>)
|
|
// Avoid duplicates if the same notification was already loaded via REST
|
|
const exists = allNotifications.value.some(n => n.id === notification.id)
|
|
if (!exists) {
|
|
allNotifications.value = [notification, ...allNotifications.value]
|
|
}
|
|
}
|
|
})
|
|
|
|
// Fallback polling when WebSocket is not available
|
|
startPollingFallback()
|
|
})
|
|
|
|
// Reload notifications when WebSocket disconnects to catch any events
|
|
// that may have been missed during the disconnect window
|
|
watch(wsConnected, (isConnected, wasConnected) => {
|
|
if (wasConnected && !isConnected) {
|
|
loadNotifications().catch(e => console.warn('Failed to reload notifications after WS disconnect:', e))
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('click', hidePopup)
|
|
unsubscribeWs?.()
|
|
stopPollingFallback()
|
|
})
|
|
|
|
function startPollingFallback() {
|
|
pollInterval = setInterval(async () => {
|
|
if (!wsConnected.value && document.visibilityState === 'visible') {
|
|
await loadNotifications()
|
|
}
|
|
}, POLL_INTERVAL)
|
|
}
|
|
|
|
function stopPollingFallback() {
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval)
|
|
pollInterval = null
|
|
}
|
|
}
|
|
|
|
async function loadNotifications() {
|
|
const notificationService = new NotificationService()
|
|
allNotifications.value = await notificationService.getAll()
|
|
}
|
|
|
|
function hidePopup(e) {
|
|
if (showNotifications.value) {
|
|
closeWhenClickedOutside(e, popup.value, () => showNotifications.value = false)
|
|
}
|
|
}
|
|
|
|
function getNotificationRoute(n: INotification): RouteLocationRaw | null {
|
|
switch (n.name) {
|
|
case names.TASK_COMMENT:
|
|
case names.TASK_ASSIGNED:
|
|
case names.TASK_REMINDER:
|
|
case names.TASK_MENTIONED:
|
|
return {name: 'task.detail', params: {id: (n.notification as {task: {id: number}}).task.id}}
|
|
case names.PROJECT_CREATED:
|
|
return {name: 'task.index', params: {projectId: (n.notification as {project: {id: number}}).project.id}}
|
|
case names.TEAM_MEMBER_ADDED:
|
|
return {name: 'teams.edit', params: {id: (n.notification as {team: {id: number}}).team.id}}
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function notificationHasRoute(n: INotification): boolean {
|
|
return getNotificationRoute(n) !== null
|
|
}
|
|
|
|
function to(n: INotification, index: number) {
|
|
return async () => {
|
|
const route = getNotificationRoute(n)
|
|
if (route === null) return
|
|
|
|
const failure = await router.push(route)
|
|
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
|
|
router.go(0)
|
|
}
|
|
|
|
n.read = true
|
|
if (allNotifications.value[index]) {
|
|
const notificationService = new NotificationService()
|
|
Object.assign(allNotifications.value[index], await notificationService.update(n))
|
|
}
|
|
|
|
showNotifications.value = false
|
|
}
|
|
}
|
|
|
|
async function markAllRead() {
|
|
const notificationService = new NotificationService()
|
|
await notificationService.markAllRead()
|
|
success({message: t('notification.markAllReadSuccess')})
|
|
|
|
notifications.value.forEach(n => n.readAt = new Date())
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.notifications {
|
|
display: flex;
|
|
|
|
.trigger-button {
|
|
inline-size: 100%;
|
|
position: relative;
|
|
}
|
|
|
|
.unread-indicator {
|
|
position: absolute;
|
|
inset-block-start: 1rem;
|
|
inset-inline-end: .5rem;
|
|
inline-size: .75rem;
|
|
block-size: .75rem;
|
|
|
|
background: var(--primary);
|
|
border-radius: 100%;
|
|
border: 2px solid var(--white);
|
|
}
|
|
|
|
.notifications-list {
|
|
position: absolute;
|
|
inset-inline-end: 1rem;
|
|
inset-block-start: calc(100% + 1rem);
|
|
max-block-size: 400px;
|
|
overflow-y: auto;
|
|
|
|
background: var(--white);
|
|
inline-size: 350px;
|
|
max-inline-size: calc(100vw - 2rem);
|
|
padding: .75rem .25rem;
|
|
border-radius: $radius;
|
|
box-shadow: var(--shadow-sm);
|
|
font-size: .85rem;
|
|
|
|
@media screen and (max-width: $tablet) {
|
|
max-block-size: calc(100vh - 1rem - #{$navbar-height});
|
|
}
|
|
|
|
.head {
|
|
font-family: $vikunja-font;
|
|
font-size: 1rem;
|
|
padding: .5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
|
|
.feed-link {
|
|
color: var(--grey-500);
|
|
transition: color $transition;
|
|
|
|
&:hover,
|
|
&:focus {
|
|
color: var(--primary);
|
|
}
|
|
}
|
|
}
|
|
|
|
.single-notification {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0.25rem 0;
|
|
|
|
transition: background-color $transition;
|
|
|
|
&.is-clickable {
|
|
cursor: pointer;
|
|
}
|
|
|
|
&:hover {
|
|
background: var(--grey-100);
|
|
border-radius: $radius;
|
|
}
|
|
|
|
.read-indicator {
|
|
inline-size: .35rem;
|
|
block-size: .35rem;
|
|
background: var(--primary);
|
|
border-radius: 100%;
|
|
margin: 0 .5rem;
|
|
flex-shrink: 0;
|
|
|
|
&.read {
|
|
background: transparent;
|
|
}
|
|
}
|
|
|
|
.user {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
inline-size: auto;
|
|
margin: 0 .5rem;
|
|
|
|
span {
|
|
font-family: $family-sans-serif;
|
|
}
|
|
|
|
.avatar {
|
|
block-size: 16px;
|
|
}
|
|
|
|
img {
|
|
margin-inline-end: 0;
|
|
}
|
|
}
|
|
|
|
.created {
|
|
color: var(--grey-400);
|
|
}
|
|
|
|
&:last-child {
|
|
margin-block-end: .25rem;
|
|
}
|
|
|
|
a {
|
|
color: var(--grey-800);
|
|
}
|
|
}
|
|
|
|
.nothing {
|
|
text-align: center;
|
|
padding: 1rem 0;
|
|
color: var(--grey-500);
|
|
|
|
.explainer {
|
|
font-size: .75rem;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|